diff --git a/.github/workflows/build-pull-request.yml b/.github/workflows/build-pull-request.yml index e74946bc..964add54 100644 --- a/.github/workflows/build-pull-request.yml +++ b/.github/workflows/build-pull-request.yml @@ -18,14 +18,10 @@ jobs: - name: Setup node uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: - node-version-file: '.node-version' + node-version-file: ".node-version" package-manager-cache: false - name: Install dependencies run: npm ci - - name: Ridge asset gate - run: | - npx vitest run src/app/features/scene/ridge/ridgeAssets.test.ts - echo "ridge assets: $(find public/ridge -type f | wc -l) files, $(du -sb public/ridge | cut -f1) bytes" - name: Build app env: NODE_OPTIONS: '--max_old_space_size=4096' diff --git a/README.md b/README.md index abeba8b2..dfc722ad 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ and your own place in it) instead of an invite list. Mobile first. **Live now:** the Cinny engine (matrix-js-sdk, Rust crypto, sync) under a Ratatoskr UI: branding, GameCult theme, estate navigation, pose portraits -(opt-in setting), a background scene (on in the dev config, `backgroundScene.tier`; the device can lower it) and an opt-in soundscape. +(opt-in setting), a background scene and soundscape (opt-in, off by default). **Planned:** see [`docs/gamecult/ratatoskr-target.md`](docs/gamecult/ratatoskr-target.md) for the target and cut sequence. Internal names that are protocol or stored state (`cinny_*` localStorage keys, the `in.cinny.room.power_level_tags` event diff --git a/config.json b/config.json index fea9a59e..0ebdf7c3 100644 --- a/config.json +++ b/config.json @@ -1,38 +1,38 @@ { "defaultHomeserver": 0, "homeserverList": [ - "chat.gamecult.org" + "gamecult.org", + "converser.eu", + "matrix.org", + "mozilla.org", + "unredacted.org", + "xmr.se" ], - "allowCustomHomeservers": false, + "allowCustomHomeservers": true, "featuredCommunities": { "openAsDefault": false, - "spaces": [ - "#gamecult:gamecult.org" + "spaces": ["#gamecult:gamecult.org"], + "rooms": [ + "#tuwunel:grin.hu", + "#free-software-movement:matrix.org", + "#gentoo:matrix.org" ], - "rooms": [], - "servers": [ - "gamecult.org" - ] + "servers": ["activematrixrooms.com", "matrixrooms.info", "matrix.org", "mozilla.org", "unredacted.org"] }, + "hashRouter": { - "enabled": true, + "enabled": false, "basename": "/" }, + "backgroundScene": { - "tier": "full", - "style": "ridge" + "tier": "off" }, + "dings": { "enabled": false }, - "fileZoneShare": { - "enabled": false - }, - "push": { - "vapidPublicKey": "", - "gatewayUrl": "", - "appId": "" - }, + "registration": { "enabled": false, "url": "" diff --git a/deployment/idunn/recipe.toml b/deployment/idunn/recipe.toml deleted file mode 100644 index 56840740..00000000 --- a/deployment/idunn/recipe.toml +++ /dev/null @@ -1,34 +0,0 @@ -schema = "gamecult.idunn.target_declaration.v1" -target = "ratatoskr" -# Idunn stamps the source commit into the build environment under this name. The release is not built from source: the -# dist tarball below is built by scripts the release owner runs (build-prod-dist.sh) and enters pinned by sha256, so -# each release is a recipe commit whose pin is its provenance. The stamp the served index carries is the commit the -# tarball was built from, which is not this commit. -source_stamp_environment = "RATATOSKR_BUILD_COMMIT" - -# Release dist b30ddac0522cc110d44d12276416c000841d086a: the output of build-prod-dist.sh (ALLOW_CONFIG_DRIFT=1), packed -# as dist/ at the top of one gzip tar. The tree carries the repo's own config.json; what is served is the operator's -# carried file, which Idunn copies beside dist/ and the vhost aliases over the tree's (a vhost without that alias would -# serve the repo config). -[[external_inputs]] -id = "dist-tarball" -url = "https://eureka.gamecult.org/forge/GameCult/Ratatoskr/releases/download/dist-b30ddac0/ratatoskr-b30ddac0-dist.tar.gz" -sha256 = "1654dc5f0ceefc5d1abf997fce1cd31ed7aef28d50e52c8cde6ce7b5c14efd23" -runner = "unpack" -destination = "release/dist.tar.gz" - -[[steps]] -id = "unpack-dist" -phase = "prepare" -runner = "unpack" -argv = ["tar", "-xzf", "release/dist.tar.gz", "-C", "release"] - -[[artifacts]] -id = "dist" -source_kind = "runner-output" -runner = "unpack" -source = "release/dist" -destination = "dist" - -[static_release] -artifact = "dist" diff --git a/docs/commons-files-desk.md b/docs/commons-files-desk.md deleted file mode 100644 index fa7242d2..00000000 --- a/docs/commons-files-desk.md +++ /dev/null @@ -1,291 +0,0 @@ -# Files desk: Commons and per-Persona file zones, two designs compared - -Status: **decided: Option A, Matrix as the single authority** (Meta, #swarm 2026-11-13 14:44Z: "Let's try matrix shaped"). Design only; no Files desk code is merged and nothing is deployed. Option B (the host service) is parked, not deleted: Eureka's branch `eureka-self/commons-host` at `a391960d` has no PR and a Soul verdict of NOT READY with fixes owed, and is woken only if Meta later wants a real file store. PR #143 (B's cut 1 in this repo) is closed unmerged. Items marked **UNKNOWN** are not verified; **Q-E** are questions for Eureka, **Q-M** for Meta. - -### Eureka's answers (`/opt/commons/handoff/commons-matrix-notes-20261113.md`, read 2026-11-13; final answers folded in 2026-11-13) - -- **Upload cap (Q-E1, read).** Synapse 1.164.1; the repo template `homeserver.yaml` has `max_upload_size: 50M` and the nginx vhost `client_max_body_size 50m`; the live runtime file is root-only and was not read. No per-room or per-user per-file cap exists; `media_upload_limits` is a per-user time-window quota. `GET /_matrix/client/v1/media/config` returns `m.upload.size` to an authenticated client, which is what the client reads. Raising the cap takes the config line, a Synapse restart and nginx at least as large. -- **Posting (Q-E1, read).** A Self holds no Matrix credentials, so the publisher needs its own bot account and token (a MAS compatibility token via `mas-cli`). It uploads with `POST /_matrix/media/v3/upload` and sends `m.room.message` with `msgtype` `m.file` or `m.image`. The Persona mouth sends `m.text` only today, so file events are new for it too. -- **Room (Q-E3, answered).** The Commons room is `#commons:gamecult.org` (create type `org.gamecult.file_zone`, unencrypted, membership by the estate Space, publisher bot at power level 50). Provisioning and push muting are Eureka's. -- **Cap (Q-E1, answered).** 50 MiB per file. The client reads it live from `m.upload.size`, not from this document. -- **Poster (Q-E1, answered).** The publisher posts as the owning Mind: each file appears under the Mind whose tree it came from, not under a generic bot. -- **Final marker (Q-E2, answered).** A handoff document is final when a sibling `NAME.final` holds its sha256; the publisher watches for that, posts once, and carries `org.gamecult.publish.replaces` (prior event id and sha256) on a later final version. -- **Range (Q-E4, unverified).** Whether Synapse media honours `Range` has not been checked against a live mxc. Playback therefore stays on the blob download until it is. -- **Edited-doc stale copies:** a redacted file's media persists unless purged; the publisher dedupes by sha256. - -Source: Meta, #swarm 2026-11-13 14:28Z and 14:40Z; ruling `ratatoskr:ruling:persona-file-sharing-zone` (a browsable, previewing collection per Persona; the Self writes to her outbox, her Persona decides what is shared, the Self never holds Matrix credentials). - -## What exists on Matrix today (the base A must fit) - -Read from this repo at dev `8ba407cd`: - -- **Zone = a room** whose `m.room.create` type is `org.gamecult.file_zone` (`isFileZone.ts`), or one listed in config `fileZoneRooms`. One per Mind, `#-files:`, found by alias or by the carried id in the root Space's `org.gamecult.stewardships` (`mediaRoom.ts`, `MediaRow.tsx`); rendered as the Media row of each Mind's group. -- **Album = a thread root** (a text event; its `body`, or its latest `m.replace` edit, is the title); **file = a thread reply** with `msgtype` `m.audio|m.image|m.video|m.file` (`buildAlbums.ts`). A redacted event or an `m.replace` is never a file. Loose room messages are not shown. -- **Read** = `GET /rooms/{id}/threads` and `/relations/{root}/m.thread`, every page, 100 per request, at most 20 pages per endpoint (`useAlbums.ts`, `readPages.ts`; a capped read shows a note in the list). Unencrypted rooms only. -- **Write** = `sendShareSet` (`shareSet.ts`, behind `fileZoneShare.enabled`): a root, then one thread reply per file, content from `getMsgContent` with `info.sha256`. -- **Media** = authenticated Matrix media fetched to a blob (`downloadMedia` in `FileContent.tsx`): so the "media element cannot send a header" problem that Option B needed a ticket for is already solved this way, at the cost that a file is fully downloaded before it plays. -- **Receipts** = `org.gamecult.file_received` from a Mind's mouth after it fetches and verifies the sha256 (`docs/file-zone-mind-contract.md`); the mouth enforces the Mind's membership in the room. - -Option A is an extension of this, not a second system. - -## The two options - -**A. Matrix is the only authority.** Commons is one more file-zone room; each Mind's zone is the room she already has. Audience is room membership. Upload is the ordinary Matrix upload. Folders are albums (thread roots). A small publisher service with its own Matrix account posts allowlisted handoff documents into the Commons room. The Files desk is a view over file events in those rooms. - -**B. The host service.** A file host on Yggdrasil serves allowlisted directories (`GET /index`, `GET /file`, a capped `PUT`), authenticated by Matrix OpenID; the client browses it as a tree. Written out in full below the line. - -| | A: Matrix only | B: host service | -| -------------------------- | ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------- | -| Authorities | one (Matrix: identity, audience, storage, audit) | two (Matrix for identity, the host for files and access) | -| Audience | room membership and power levels | the host derives a role from Matrix identity and enforces it again | -| Audit of writes | the event `sender`, kept by the homeserver | the host's own log | -| Overwriting someone's file | impossible: events are immutable and only the sender's own edit counts; a redaction is a steward's act | host rule, host code | -| Upload size cap | the homeserver's `m.upload.size`, global, 50 MiB (Eureka) | the host's own, chosen freely | -| Type allowlist | not enforceable by the homeserver; client-side courtesy, steward redaction | enforced by the host (sniffed) | -| What is browsable | what was posted (published or uploaded) | what is on disk under the allowlist, live | -| Mind-written handoff docs | a publisher posts them once, when final (below) | the host serves the tree | -| Per-Persona zone | the existing Media room; the Self's outbox reaches it through the mouth, as the ruling says | a `shared/` directory the host serves | -| Folders | albums; nesting by path in the album title | real directories | -| Mind fetch | unchanged (mouth, room id and event id, receipts) | a second route the mouth would need | -| Media playback | blob download, as today; no seeking before the whole file arrives | ranges and a ticket, or the same blob | -| New moving parts | a room, a publisher | a host service, OpenID verification, CORS, tickets, path containment, rate limits | - -## Recommendation: A (decided) - -Confidence: high on the authority question (it agrees with Meta's ruling, with Eureka's lean, with the standing ruling that the Self never holds credentials while her Persona decides what is shared, and with the fact that everything built so far reads Matrix); moderate on the details below, which Eureka's answers can change. - -What A gives up, stated plainly: - -- **A live view of what is on disk.** Only what was published or uploaded appears. A handoff doc that was never marked final is not in the Commons. -- **Files over the homeserver's upload cap.** B had no Matrix limit; A has exactly one. -- **A server-side type allowlist.** Under A a member can upload any type the homeserver accepts; the client shows a refusal for types it does not want, and stewards redact. If a strict allowlist matters, a gating bot (post, check, redact) would have to exist, and it is a second authority again. -- **Seeking inside a large audio or video file** until Matrix media supports ranges for the client (**UNKNOWN**; the blob route is what runs today). -- **Hierarchy beyond the album level** as a native structure; see Folders. - -What it buys: one identity, one audience, one audit trail, immutable files, existing receipts and the mouth's fetch, and no new service to keep secure. - -## Option A in detail - -**Rooms.** Commons is a room with create type `org.gamecult.file_zone`, alias `#commons:gamecult.org`, join rule `restricted` to the estate Space so members join without an invite and Minds are invited, history `shared`, unencrypted (the zones are, and `info.sha256` is written only there). Power levels: `events_default` 0 so members post; `redact` and `state_default` at steward level. The room is muted for push by provisioning: file posts are thread replies, which can raise unread counts and notifications (**UNKNOWN** under this server's push rules), so the room must not shout. Per-Persona zones are the rooms Eureka already provisions; nothing new. - -**Folders.** Four ways to have folders on Matrix, in order of preference: - -1. _Albums are folders; nesting is a path in the album title_ (`handoff/2026-11`, `cast-art/poses`). Zero wire change: `buildAlbums` already reads it, and the client groups albums by title path as a pure view. Cost: a thread root per folder, and the rule that a folder's root is posted once by whoever opens the folder. **Recommended.** -2. A `org.gamecult.file.path` key on each file event. Gives a real tree with files anywhere; needs every poster to write it and every client to honour it; other Matrix clients ignore it and show a flat thread. -3. A room per folder, in a Space. Native hierarchy and per-folder audience; heavy (a room is a membership, a sync entry and provisioning each) and it multiplies the sync load on a phone. -4. A state event per folder (`org.gamecult.file_folder`). Mutable folder metadata (title, order), writable only at steward level; useful later if folders need descriptions, not needed for a first cut. - -**Albums in each Mind's room** are the same mechanism: a Mind's Media room holds that Mind's albums, the Commons holds the shared ones. The Files desk is the same view over either room; a zone differs only by room, not by code. - -**The publisher.** A small service on Yggdrasil with its own Matrix account (a bot account that posts as the owning Mind, per Eureka's answer above). The Self never holds this account; the publisher holds it. It owns the allowlist. - -- **When it posts.** Handoff documents are edited in place, so mirroring every save would spam the room with drafts. The rule (Eureka's lean, adopted): **post once, when the document is marked final**; a later final version posts again with a `replaces` relation to the earlier event. "Final" is a sibling `NAME.final` holding the document's sha256 (answered Q-E2); the client must not know. -- **Allowlist.** Lives on the publisher: a list of source roots and patterns, read by the publisher only. `handoff-private` is not on it and the publisher never lists, reads or posts from it. The client holds no path to anything on disk. -- **The event.** A file event in the thread of the folder's album, built the way `getMsgContent` builds them (`msgtype`, `body`, `filename`, `url` from the media repo, `info.mimetype|size|sha256`), plus one extra key `org.gamecult.publish`: `{ source, sha256, replaces? }` where `source` is a path relative to the allowlisted root, never absolute, and `replaces` is the event id of the version it supersedes. -- **`replaces`, not `m.replace`.** `m.replace` is the stock edit relation, but Ratatoskr's `buildAlbums` deliberately never treats an edit as a file, several clients do not render an edited file, and an edit hides the earlier version. A custom key keeps both events as files; the view shows the latest of a chain and a "N earlier versions" row under it, and other Matrix clients see two ordinary file posts. A chain is read from the events themselves (each carries its `replaces`), not from a table, and a `replaces` counts only when its event has the same sender as the event it names, so nobody else in the thread can fold a file away. -- **Idempotence.** The publisher's own state (a typed CultCache document of `source`, `sha256`, `event_id`) says what it posted; if that is lost, it rebuilds by reading its own events back from the room (`org.gamecult.publish.source` plus `sha256`) rather than guessing. The same `sha256` never posts twice. -- **Receipts and fetch** work unchanged: the event is an ordinary file event in a file-zone room, so a Mind's mouth can fetch it by room id and event id (5 MiB cap there) and tick `fetched for`. - -**Uploads (approved for members and Minds).** The ordinary Matrix upload, through the composer pipeline and `ShareSet`. The host rules Eureka gave map onto Matrix as follows: - -| Host rule | Under A | -| ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- | -| only under an `uploads/` subtree | an album (folder) named `uploads/...`; the client offers upload only there (and in a Mind's own zone) | -| never overwrite Mind-written handoff files | free: other people's events cannot be replaced; a duplicate name is just another post in the thread | -| size cap | the homeserver's `m.upload.size`, shown from `MediaConfigLoader`; the client refuses a larger file before sending | -| type allowlist | client-side only; see "gives up" | -| explicit flag to overwrite | no overwrite exists; a new version is a new post, optionally with `replaces` set by the same sender | -| audit log with the Matrix id | the event `sender` | - -What A adds to `ShareSet`: sharing into an existing album ("add files", listed as not built in `docs/file-zone.md`), progress per file, cancel, and per-error refusal messages written against the SDK's `MatrixError` (`M_TOO_LARGE`, `M_FORBIDDEN`, `M_LIMIT_EXCEEDED`) instead of host codes. The draft refusal table of Option B's upload flow carries over with those codes. **UNKNOWN:** whether the SDK exposes upload progress and abort for media (it does for `uploadContent` with a progress callback; the composer already shows progress, `state/upload.ts`). - -## What each costs on the phone - -Under both, the phone design is the same shape (one column, rows 56 px or more, hidden controls, in-place preview; see the Option B sections below, which are written against the host but whose layout applies). - -- **A, new cost:** the Commons room is one more room in sync and it must be muted; albums and files are read to a cap of 20 pages of 100 per endpoint (a Commons past that shows a truncation note); a folder tree is a title-path grouping, so a deep tree is a long list of albums with breadcrumbs, not true directories; a file plays after a full download (small audio and images are fine; a 40 MB clip is not). -- **A, no cost:** no second sign-in, no ticket, no CORS, no new fetch layer, no host-unreachable state beyond the homeserver's own. The album list, album view, share panel and receipts exist and are tested; the view is their extension. -- **B, new cost:** every screen is a second client against a second service with its own errors, token refresh and unreachable state; media needs the ticket or the blob; the tree and preview are new UI. -- **B, benefit:** a tree that is live and complete, ranges, and no Matrix size limit. - -## Changes to Eureka's host work - -Under A the paused service is not built, and most of its list disappears: - -- **Dropped:** `/index`, `/file`, `PUT`, OpenID verification pinned to the server name, CORS, path containment and sniff rules, tickets, the `uploads/` write rules, the role response, the per-path audit log (Q-E3, Q-E4, Q-E6, Q-E7, Q-E8 to Q-E11 of the Option B list). -- **Kept:** the Commons allowlist decision (Q-E1 of that list), moved to the publisher: which roots, and that `handoff` is only ever published through the final-marker rule, never browsed. -- **New:** (1) provision the Commons room (type, alias, restricted join to the estate Space, power levels, push muted); (2) the publisher: account, allowlist, final-marker rule, media upload, posting into folder threads, the `org.gamecult.publish` key, idempotence from its own events; (3) confirm the homeserver upload cap and whether the account may post file events from Yggdrasil (**Q-E1**, asked on the PR); (4) the per-Persona path stays the one already specified (`album-outbox-ops`: outbox, Persona decision, mouth post), so the `shared/` convention of Option B is retired. - -## Cuts under A - -Status 2026-11-14 (dev da822e9e plus the size-refusal cut): built: folders by album title path with navigation and breadcrumb (#147), pagination (#148), version folding (#149), add-to-album with named refusals (#150), Stop for a send in flight, replaces loops shown, freshest last event on add (#151), and the pre-send size refusal: a file larger than the server's `m.upload.size` is named and left unsent before any upload or album root, and a server stating no cap refuses nothing. **Not built: the Commons entry row** (item 1 below, found by `#commons:gamecult.org` or the carried `media.commons` id through `lookupMediaRoom`, behind a dark `filesDesk.enabled` flag; neither the row nor the flag exists in the code). It waits on Eureka's Commons room. - -1. **Commons entry**: a Commons row in the navigator (like `MediaRow`, found by alias or carried id), opening the room's album view. Flagged, no new data layer. -2. **Pagination** of albums and files in `useAlbums` (done, PR #148). -3. **Folders by title path** and the album list grouped by it. -4. **Versions**: read `org.gamecult.publish.replaces`, show the latest of a chain with the earlier versions folded under it (done, PR #149). -5. **Add files to an existing album**, with per-file progress, cancel and the refusal messages above (the upload flow). Done: adding to an open album and the three refusal messages (PR #150); Stop for a send in flight is in too (PR for follow-up `files-desk-release-146-and-send-cancel`): it cancels the running upload through `onPromise` and `mx.cancelUpload`. - -PR #143 (Option B cut 1: contract types, host client, fake host) is **held**; under A it is not needed and would be closed. Its Soul pass found, and a revived B would have to close, an unvalidated `host` that could send the bearer to the wrong origin, hostile-looking entry names beyond separators, loose numeric checks and missing CSP and `Content-Disposition` headers in the fake. - -## Not in scope - -The Mind-side fetch (the mouth's); encrypted rooms; deletion and rename by this client; search across zones; any permission model of our own beyond Matrix room membership and power levels. - -## Open questions - -**Eureka:** answered; see "Eureka's answers" at the top. Still open on Eureka's side: Range support (Q-E4) and the live runtime `max_upload_size`. - -**Meta:** - -- **Q-M1.** Commons audience: members and Minds as proposed; Users and Patrons never, or later? -- **Q-M2.** Is a Persona's zone visible to all members or to her stewards and Minds? (The Matrix zone's audience question, `ratatoskr:question:file-zone-audience`, is the same fork.) -- **Q-M3.** May Users or Patrons upload if they are ever let in? -- **Q-M4.** Names: "Files" for the view, "Commons" for the shared room. -- **Q-M5.** A type allowlist is not enforceable under A without a gating bot; is a client-side refusal plus steward redaction enough? - ---- - -# Option B in full (kept for the comparison; superseded in part by Option A above) - -The sections below were written against the host service. Where they speak of a host, a role, a ticket, a token, `/index` or `/file`, they are Option B. The phone layout, the zone table and the refusal wording carry over to A with the Matrix equivalents given above. - -### What exists, and what this adds - -| | Matrix file zone (`docs/file-zone.md`, built) | Files desk (this page) | -| -------- | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | -| Storage | Matrix media in a `#files-` room, albums as threads | Directories on Yggdrasil, served by a small host | -| Listing | thread roots, `buildAlbums` | `GET /index?path=` | -| Unit | album of posted files | file in a directory tree | -| Strength | marks (keep / fix / drop), receipts (`fetched for`), any Matrix client works | whole trees without N uploads, no Matrix size limit, `/opt/commons` material becomes visible to people on a phone | -| Entry | Media row per Mind (`MediaRow.tsx`) | Files row (below) | - -They do not compete. The Matrix zone stays where a file needs a mark or a receipt (Aqua's listening kits); the Files desk is for browsing and fetching what is already on disk. Neither replaces the other, and the Files desk does not write Matrix events. The ruling left "Matrix media plus a listing versus a served directory" open; this page is the served-directory half, built beside the Matrix half already live. - -### Zones (host) - -| Zone | Contents | Who sees it | -| ---------------- | ------------------------------------------------------------ | -------------------------------------------------------------- | -| Commons | a curated tree of `/opt/commons` | members and Minds | -| Persona `` | what that Persona has chosen to share from her Self's outbox | **Q-M:** all members, or the Persona's stewards and Minds only | -| handoff-private | `/opt/commons/handoff-private` (0750) | never: not a zone, not listed, not a path the host resolves | - -Layout of `/opt/commons` today (read 2026-11-13): `backlog` (755, per-Mind backlog md and summary.json), `bin`, `cast-art` (setgid, group-writable: poses, busts), `handoff` (setgid, group-writable, 92 entries of briefs, patches, receipts, dings, kits), `handoff-private` (0750, ACL), `opt` (toolchains, root-owned), `situation` (0750, setgid), all `nobody:nogroup`. **`handoff` is not a publishable tree:** it holds ops notes, custody receipts (`bifrost-appsecret-custody-receipt-*`) and patches written for Minds, with no sticky bit. Serving it wholesale shows secrets-adjacent notes to every member and lets any Mind with group write change what a phone user sees. The Commons zone is therefore an allowlist of roots (candidate: `cast-art`, `backlog`, and a new `commons/` directory Minds publish into on purpose), not `/opt/commons` itself. **Q-E:** which roots; whether `handoff` ever, and if so through a reviewed `published/` subdirectory. - -Per-Persona source is the outbox from `ratatoskr:follow_up:album-outbox-ops-and-persona-specs`: `/var/lib/mind--outbox`, owned by `mind--self`, 0750, ACL read for `mind-` and the host user. The Self writes there; the Persona decides what is shared. Making that decision a filesystem fact (a `shared/` subdirectory the Persona's turn populates by copy or hardlink, the host serves only `shared/`) keeps the host dumb: it never learns what "decides" means. **Q-E:** `shared/` subdirectory versus a per-file marker. - -### Phone-first UI (host) - -Phone width is the design width; desktop adds the navigator beside it (as `Desk` does in `Router.tsx`). Vertical space is cheap, so a screen shows one thing and hides what is not in use. - -- **Entry.** A `Files` row pinned in the navigator beside `DeskEntry` and `MindMedia`: one at the estate level (Commons) and one inside each Mind's group (her zone), same `NavItem` anatomy as `DeskEntry`, dim when the zone has no items. Route `/files/:zone/*path`, sibling of `DESK_PATH`. -- **List.** One column. Row = icon or thumbnail, name (truncates in the middle, keeping the extension), size and age on a second dim line. Directories first. Rows are at least 56 px tall; the tap target is the whole row (the `PhoneHit` variants and the repo-wide guard test from `phoneHit.coverage.test.ts` apply to any IconButton inside). -- **Hidden until needed.** Breadcrumb is one line (current folder, a back arrow; the full path on tap). Search, sort and the grid toggle sit behind a single icon in the header and open inline above the list. Download and share appear on the open file, not on every row. Nothing is permanently stacked in the header. -- **Preview.** Tap a file: it opens in place below its row, full width, and pushes the rows below it down (vertical room is the cheap axis). Image: fitted, tap for the existing `ImageViewer`. Audio: the `AudioContent` controls rehosted (`renderMediaControl`), one player at a time, a mini-player pinned to the bottom only while something plays. Video: native controls. Text, md, json, patch: monospace, first 64 KiB, "show all" below. PDF: open in a new tab. Anything else: name, size, hash, one Download button. -- **Honesty rows.** Loading, empty, "host unreachable", "not allowed" are rows that say so (as `MediaNote` does); no spinner without a name for what it waits on. -- **Role-driven.** The index response carries the caller's role; the client uses it only to hide controls (upload), never to decide access. - -### Host contract - -The host is a service on Yggdrasil that Eureka owns. The client holds no filesystem knowledge. All JSON here is the xenos boundary between a browser and a file server, not state. - -- `GET /index?path=

` returns `{ role, zone, path, entries: [{ name, kind: "file"|"dir", size, mtime, mime, hash }] }`. `mtime` is unix seconds. `hash` is the lower-case hex sha256 of the plaintext bytes, absent for directories, and may be absent for a file whose hash is not computed yet (client shows nothing, never blocks on it). `mime` is sniffed by the host from magic bytes, not guessed from the extension. -- `GET /file?path=

` streams the bytes with `Accept-Ranges: bytes` and 206 for `Range` (audio and video seek; a phone on mobile data does not fetch 40 MB to play 10 seconds). `Content-Type` is the sniffed mime, `X-Content-Type-Options: nosniff`, `Content-Disposition: attachment` for anything that can run in the origin (`html`, `svg`, `js`, `xhtml`), and a `Content-Security-Policy: sandbox` on every response. -- `path` is `/`, with the zone one of the names the host announces. The host resolves the real path of every request and refuses anything that leaves the zone's root: `..`, absolute paths, NUL, symlinks that cross the root, dotfiles, and `handoff-private`, which is not on any allowlist. Errors are typed (`401 unauthenticated`, `403 zone not allowed`, `404`, `416`), and a path outside the allowlist is 404, not 403, so it says nothing about what exists. -- **Auth: Matrix OpenID.** The client calls `mx.getOpenIdToken()` (already used in `CallWidgetDriver.ts`) and sends it as `Authorization: Bearer `. The host verifies it against the homeserver's federation `openid/userinfo` endpoint, **pinned to our server name** (an `@void:evil.example` token must fail), caches the verified `sub` for the token's lifetime, and derives the role. It never sees the user's access token, and a Mind's Self never holds a token (ruling above): Minds reach the same bytes through the filesystem or the mouth, not this host. -- **Role** in the response: `member`, `mind`, `steward` (host decides from the Matrix id and the room/space membership the host reads; the client trusts none of it for access). **Q-E:** exact source of membership (estate Space membership read by the host with a service account, versus the Persona catalogue file for Minds). -- **Uploads** (Meta approved for members and Minds, #swarm 2026-11-13 14:30Z; rules are Eureka's, below). `PUT /file?path=

` writes only under an `uploads/` subtree of a zone, with `Content-Length` required; the host enforces a size cap, a type allowlist (sniffed, not the client's claim), no overwrite unless the request carries an explicit flag, and never replaces a file a Mind wrote into the handoff tree; every write is audit-logged with the caller's Matrix id. Reads cover the whole allowlisted tree, so an uploaded file is listed like any other. Success is `201` with the stored name, size and hash. The `role` in the index response gates the upload control; the host gates the write. - -### The one problem the contract leaves: media elements cannot send headers - -`

venv/bin/python -I tests/mutate_export.py [JOBS=4 and ONLY=[|] in the environment] (prints one line per mutant as it lands; exit 1 if one survives) -Each mutant runs under a 12 GB address-space cap and a 20 minute limit. Equivalent mutants are named in EQUIVALENT with the argument; they are reported but do not fail the run.""" -import os, sys, shutil, subprocess, tempfile, resource -from concurrent.futures import ThreadPoolExecutor -HERE = os.path.dirname(os.path.abspath(__file__)); SRC = os.path.join(HERE, '..') -EQUIVALENT = {'decode budget cap is exclusive': 'decoded = 2 * 3 * w * h * 4 is a multiple of 24 and 20_000_000 is not (20_000_000 / 24 = 833333.33), so decoded == DECODE_BYTES_MAX never holds for integer w and h and > equals >='} -M = { - 'no per-channel ref correction (P = X)': ('ridge.py', 'P = X + ref_w[None] * (Yc - Y[..., None])', 'P = X'), - 'alpha ignores haze (alpha = cov)': ('ridge.py', 'alpha = np.clip(win(cov) - Y, 0, 1)', 'alpha = np.clip(win(cov), 0, 1)'), - 'alpha from the max haze channel': ('ridge.py', 'Y = Yc.mean(-1); alpha', 'Y = Yc.max(-1); alpha'), - 'haze share halved in alpha': ('ridge.py', 'alpha = np.clip(win(cov) - Y, 0, 1)', 'alpha = np.clip(win(cov) - 0.5 * Y, 0, 1)'), - 'default ref = the sky horizon colour, not the haze colour': ('ridge.py', 'ref = haze_colour(sky_hz(st, paperf)[1]) if ref is None', 'ref = sky_hz(st, paperf)[1] if ref is None'), - 'default ref = mid grey': ('ridge.py', 'ref = haze_colour(sky_hz(st, paperf)[1]) if ref is None', 'ref = np.full((W, 3), 0.5) if ref is None'), - 'grain dropped from the planes': ('ridge.py', 'P / np.maximum(alpha, 1e-4)[..., None], 0.0) * win(grain)[..., None]', 'P / np.maximum(alpha, 1e-4)[..., None], 0.0)'), - 'grain squared': ('ridge.py', 'P / np.maximum(alpha, 1e-4)[..., None], 0.0) * win(grain)[..., None]', 'P / np.maximum(alpha, 1e-4)[..., None], 0.0) * win(grain)[..., None] ** 2'), - 'floor dropped from far': ('ridge.py', "if name == 'far' else np.zeros_like(r[0]) for r in runs]", "if False else np.zeros_like(r[0]) for r in runs]"), - 'mid = only the first middle slot': ('ridge.py', 'mid=list(range(1, n - 1))', 'mid=[1]'), - 'cast shadows off in planes': ('ridge.py', 'shs = cast_shadows(layers, az, el) if shadows else [None] * len(layers); out = {}', 'shs = [None] * len(layers); out = {}'), - 'encode premultiplies colour': ('export.py', 'def encode(plane): return bytes(imagecodecs.webp_encode(u8(plane)', 'def encode(plane): return bytes(imagecodecs.webp_encode(u8(np.concatenate([plane[..., :3] * plane[..., 3:], plane[..., 3:]], -1))'), - 'encode drops alpha': ('export.py', 'def encode(plane): return bytes(imagecodecs.webp_encode(u8(plane)', 'def encode(plane): return bytes(imagecodecs.webp_encode(u8(plane)[..., :3]'), - 'encode quality 5': ('export.py', 'WEBP_QUALITY = 80', 'WEBP_QUALITY = 5'), - 'encode flips rows': ('export.py', 'def encode(plane): return bytes(imagecodecs.webp_encode(u8(plane)', 'def encode(plane): return bytes(imagecodecs.webp_encode(u8(plane)[::-1].copy()'), - 'night at -6 deg': ('export.py', 'NIGHT_EL = -12.0', 'NIGHT_EL = -6.0'), - 'golden at 10 deg': ('export.py', 'golden=find(6.0, noon, noon + 9)', 'golden=find(10.0, noon, noon + 9)'), - 'altitude includes eye height': ('export.py', "altitude_km=p['ground_m'] / 1000.0", "altitude_km=(p['ground_m'] + p['eye_m']) / 1000.0"), - 'horizon_row in the wrong unit (degrees)': ('export.py', "horizon_row=v['horizon_row']", "horizon_row=v['top_deg']"), - 'manifest plane h lies': ('export.py', 'w=w, h=h) for n in PLANES}', 'w=w, h=h + 1) for n in PLANES}'), - 'manifest plane w from the constant': ('export.py', 'w=w, h=h) for n in PLANES}', 'w=PLANE_W, h=h) for n in PLANES}'), - 'manifest view plane_w from the constant': ('export.py', 'window_deg=WINDOW_DEG, plane_w=w)', 'window_deg=WINDOW_DEG, plane_w=PLANE_W)'), - 'luma sky 0.8 grey': ('export.py', 'LUMA_SKY = 0.5', 'LUMA_SKY = 0.8'), - 'luma coverage threshold 0.3': ('export.py', 'LUMA_COVER = 0.5', 'LUMA_COVER = 0.3'), - 'luma coverage threshold 0.8': ('export.py', 'LUMA_COVER = 0.5', 'LUMA_COVER = 0.8'), - 'luma composites near to far': ('export.py', 'for a in decoded:', 'for a in decoded[::-1]:'), - 'luma coverage = strongest plane alpha': ('export.py', 'clear = clear * (1 - al[..., 0])', 'clear = np.minimum(clear, 1 - al[..., 0])'), - 'luma coverage = last plane alpha': ('export.py', 'clear = clear * (1 - al[..., 0])', 'clear = 1 - al[..., 0]'), - 'luma never refuses an empty frame': ('export.py', 'if not m.any(): raise ValueError', 'if False: raise ValueError'), - 'planes of mixed size accepted': ('export.py', 'if len(shapes) != 1:', 'if False:'), - 'plane rows not checked against the view': ('export.py', 'if h != view', 'if False and h != view'), - 'budget checked against the constant width': ('export.py', 'check_budget([sum(sizes[k].values()) for k in KEYS], w, h)', 'check_budget([sum(sizes[k].values()) for k in KEYS], PLANE_W, h)'), - 'budget checked against the view height only': ('export.py', 'check_budget([sum(sizes[k].values()) for k in KEYS], w, h)', 'check_budget([sum(sizes[k].values()) for k in KEYS], w, 1)'), - 'width cap is exclusive of 4096': ('export.py', 'if plane_w > PLANE_W_MAX:', 'if plane_w >= PLANE_W_MAX:'), - 'width cap 8192': ('export.py', 'PLANE_W_MAX = 4096', 'PLANE_W_MAX = 8192'), - 'pair cap is exclusive of the cap': ('export.py', 'if frame_bytes[i] + frame_bytes[j] > PAIR_BYTES_MAX:', 'if frame_bytes[i] + frame_bytes[j] >= PAIR_BYTES_MAX:'), - 'pair check does not wrap night to dawn': ('export.py', 'for i in range(len(frame_bytes)):', 'for i in range(len(frame_bytes) - 1):'), - 'decode budget counts one frame, not a pair': ('export.py', 'decoded = 2 * len(PLANES)', 'decoded = len(PLANES)'), - 'decode budget cap doubled': ('export.py', 'DECODE_BYTES_MAX = 20_000_000', 'DECODE_BYTES_MAX = 40_000_000'), - 'decode budget cap is exclusive': ('export.py', 'if decoded > DECODE_BYTES_MAX:', 'if decoded >= DECODE_BYTES_MAX:'), - 'planes order mid far near': ('export.py', "PLANES = ('far', 'mid', 'near')", "PLANES = ('mid', 'far', 'near')"), - 'morning ignores the time zone': ('export.py', 'morning=9.5 - tz', 'morning=9.5'), - 'dawn three hours after sunrise': ('export.py', 'dawn=rise + 0.25', 'dawn=rise + 3'), - 'dusk at -4 deg': ('export.py', 'dusk=find(-2.5, noon, noon + 9)', 'dusk=find(-4.0, noon, noon + 9)'), - 'noon is solar noon of the meridian, not the sun maximum': ('export.py', 'noon = max((12 - lon / 15.0 - 1 + x / 600 for x in range(0, 1200)), key=lambda t: sun.solar(day + timedelta(hours=t), lat, lon)[1])', 'noon = 12 - lon / 15.0 + 1.5'), - 'sun key hour_utc off by one hour': ('export.py', "hour_utc=h) for k, h in hours.items()}", "hour_utc=h + 1) for k, h in hours.items()}"), - 'altitude from the eye, not the ground': ('export.py', "altitude_km=p['ground_m'] / 1000.0", "altitude_km=p['eye_m'] / 1000.0"), - 'look azimuth from the wrong field': ('export.py', "look_az_deg=p['az_center']", "look_az_deg=p['lat']"), - 'view px_per_deg_v is the horizontal one': ('export.py', "px_per_deg_v=v['px_per_deg']", "px_per_deg_v=ridge.HPX_DEG"), - 'sky_row left as a fraction': ('export.py', "sky_row=v['sky_row'] * ridge.H", "sky_row=v['sky_row']"), - 'near_row left as a fraction': ('export.py', "near_row=v['near_row'] * ridge.H", "near_row=v['near_row']"), - 'index written without replacing a rebaked place': ('export.py', "idx['places'][place['id']] = name;", "idx['places'].setdefault(place['id'], name);"), - 'plane files written under the wrong key': ('export.py', "(tmp / f'{k}-{n}.webp').write_bytes(frames_webp[k][n])", "(tmp / f'{k}-{n}.webp').write_bytes(frames_webp['noon'][n])"), - 'a refused bake still creates the out dir': ('export.py', "sizes = {k: {n: len(frames_webp[k][n])", "Path(out).mkdir(parents=True, exist_ok=True); sizes = {k: {n: len(frames_webp[k][n])"), - - 'luma in sRGB, not linear': ('export.py', 'lin = np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4)', 'lin = c'), - 'pair check skips the night-dawn pair and the last day pair': ('export.py', 'for i in range(len(frame_bytes)):', 'for i in range(len(frame_bytes) - 2):'), - 'decode budget never checked': ('export.py', 'if decoded > DECODE_BYTES_MAX:', 'if False:'), - 'old dir of the place kept': ('export.py', 'if old and old != name: shutil.rmtree(out / old, ignore_errors=True)', 'pass'), - 'tmp dir left in out on success': ('export.py', 'os.rename(tmp, dest)', 'shutil.copytree(tmp, dest)'), - 'index default always the baked place': ('export.py', "idx['default'] = default or (idx['default'] if idx['default'] in idx['places'] else place['id'])", "idx['default'] = place['id']"), - 'attribution credit_line blanked': ('export.py', "credit_line='Contains modified", "credit_line='' and 'Contains modified"), - 'attribution credit_line loses the Airbus line': ('export.py', ' and © Airbus Defence and Space GmbH 2014-2018', ''), - 'attribution licence blanked': ('export.py', "licence='Copernicus DEM licence", "licence='' and 'Copernicus DEM licence"), - 'attribution dem_source names the 90 m product': ('export.py', "dem_source='Copernicus DEM GLO-30 (30 m)'", "dem_source='Copernicus DEM GLO-90 (90 m)'"), - 'attribution url over plain http': ('export.py', "url='https://spacedata", "url='http://spacedata"), - 'attribution url blanked': ('export.py', "url='https://spacedata.copernicus.eu/collections/copernicus-digital-elevation-model')", "url='')"), - 'knock-out removed: the farther planes show through the nearer': ('ridge.py', "out[names[i]][..., 3] *= np.clip(left / np.maximum(got, 1e-3), 0, 1)", "out[names[i]][..., 3] *= 1.0"), - 'knock-out ignores the nearer planes\' own alpha': ('ridge.py', "out[names[i]][..., 3] *= np.clip(left / np.maximum(got, 1e-3), 0, 1)", "out[names[i]][..., 3] *= np.clip(left, 0, 1)"), - 'knock-out margin filter is a min (hides what the slide uncovers)': ('ridge.py', 'sliding_window_view(pad, 2 * m + 1, axis=1).max(-1), m / 2.0)', 'sliding_window_view(pad, 2 * m + 1, axis=1).min(-1), m / 2.0)'), - 'knock-out has a hard edge (no easing)': ('ridge.py', 'return np.clip(blurw(np.lib.stride_tricks.sliding_window_view(pad, 2 * m + 1, axis=1).max(-1), m / 2.0)[:, m:-m], 0, 1)', 'return np.clip(np.lib.stride_tricks.sliding_window_view(pad, 2 * m + 1, axis=1).max(-1)[:, m:-m], 0, 1)'), - 'knock-out margin of one column': ('ridge.py', 'OCCLUDE_MARGIN_PX = 48 ', 'OCCLUDE_MARGIN_PX = 1 '), - 'knock-out hides the nearer plane from the farther one, not the reverse': ('ridge.py', 'nearer = names[i + 1:]; left', 'nearer = names[:i]; left'), - 'near foot floor removed: the near body dissolves above the horizon': ('ridge.py', 'if near: foot = np.maximum(foot,', 'if False: foot = np.maximum(foot,'), - 'near foot floor starts the fade at the foot, not at the horizon': ('ridge.py', 'np.clip(ang * PXDEG_V, 0, None) / FADE_AT)', 'np.clip(ang * PXDEG_V, 0, None) / 1.0)'), - 'near flag passed to every layer': ('ridge.py', 'near=rank == len(slots) - 1)', 'near=True)'), - 'near flag passed to no layer': ('ridge.py', 'near=rank == len(slots) - 1)', 'near=False)'), - 'near flag on the first layer, not the last': ('ridge.py', 'near=rank == len(slots) - 1)', 'near=rank == 0)'), - 'near cover never checked at the writer': ('export.py', "for k in KEYS: check_near_cover(dec[k][-1], view['horizon_row'])", "pass"), - 'near cover checked on the mid plane': ('export.py', "check_near_cover(dec[k][-1], view['horizon_row'])", "check_near_cover(dec[k][1], view['horizon_row'])"), - 'near cover checked on noon only': ('export.py', "for k in KEYS: check_near_cover(", "for k in ('noon',): check_near_cover("), - 'near cover hole threshold of zero': ('export.py', 'NEAR_HOLE_ALPHA = 24 ', 'NEAR_HOLE_ALPHA = 0 '), - 'near cover looks below the horizon too': ('export.py', '& (rows < int(horizon_row))', '& (rows < h)'), - 'near cover crest threshold above any alpha': ('export.py', 'NEAR_CREST_ALPHA = 64 ', 'NEAR_CREST_ALPHA = 256 '), - 'near cover lets a transparent column through': ('export.py', 'if none.any(): raise ValueError', 'if False: raise ValueError'), - 'near cover looks only above the crest row': ('export.py', '(rows >= crest[None, :]) & (rows < int(horizon_row))', '(rows >= crest[None, :] + 400) & (rows < int(horizon_row))'), - 'relight is not deterministic (noise in the planes)': ('ridge.py', 'P = X + ref_w[None] * (Yc - Y[..., None])', 'P = X + ref_w[None] * (Yc - Y[..., None]) + np.random.random() * 0.05'), -} - -def _cap(): resource.setrlimit(resource.RLIMIT_AS, (12 << 30, 12 << 30)) -def run(item): - name, (f, a, b) = item; d = tempfile.mkdtemp(dir=os.environ.get('TMPDIR')); s = os.path.join(d, 'src'); shutil.copytree(SRC, s, ignore=shutil.ignore_patterns('__pycache__')) - p = os.path.join(s, f); t = open(p).read(); assert a in t, name; open(p, 'w').write(t.replace(a, b, 1)) - code = ("import sys; sys.path.insert(0, %r); import test_ridge as t\nfor f in sorted(t.PLANES_TESTS, key=lambda f: ('vienna' in f.__name__, f.__name__)):\n" - " try: f()\n except BaseException as e: print(f.__name__[5:50] + ': ' + repr(e)[:120]); break\n") % os.path.join(s, 'tests') - try: r = subprocess.run([sys.executable, '-I', '-B', '-c', code], env=dict(os.environ, RIDGE_SRC=s), capture_output=True, text=True, preexec_fn=_cap, timeout=1200); fails = r.stdout.strip().splitlines()[-1] if r.stdout.strip() else ('CRASH ' + r.stderr[-300:] if r.returncode else '') - except subprocess.TimeoutExpired: fails = 'TIMEOUT' - shutil.rmtree(d); line = ('KILLED ' if fails else 'SURVIVED ') + name + ' -> ' + (fails or '-'); print(line, flush=True); return line -if __name__ == '__main__': - only = os.environ.get('ONLY'); items = [kv for kv in M.items() if not only or any(o in kv[0] for o in only.split('|'))] - with ThreadPoolExecutor(int(os.environ.get('JOBS', '4'))) as ex: lines = list(ex.map(run, items)) - live = [l for l in lines if l.startswith('SURVIVED') and l[9:].split(' -> ')[0] not in EQUIVALENT] - print('%d mutants, %d killed, %d survived (%d named equivalent)' % (len(lines), sum(l.startswith('KILLED') for l in lines), sum(l.startswith('SURVIVED') for l in lines), len(EQUIVALENT))); sys.exit(1 if live else 0) diff --git a/scripts/ridge-bake/tests/run.sh b/scripts/ridge-bake/tests/run.sh deleted file mode 100755 index f97a126c..00000000 --- a/scripts/ridge-bake/tests/run.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/sh -# Usage: RIDGE_PY= RIDGE_TILES= tests/run.sh [test_ridge.py|mutate.py] -# RIDGE_WORK (cache dir) defaults to a fresh directory under $TMPDIR. -: "${RIDGE_PY:?RIDGE_PY must name the python of the baker venv}" -export RIDGE_WORK="${RIDGE_WORK:-${TMPDIR:-/tmp}/ridge-bake-work}"; mkdir -p "$RIDGE_WORK" -HERE=$(cd "$(dirname "$0")" && pwd) -exec "$RIDGE_PY" -I "$HERE/${1:-test_ridge.py}" diff --git a/scripts/ridge-bake/tests/test_ridge.py b/scripts/ridge-bake/tests/test_ridge.py deleted file mode 100644 index 351c2938..00000000 --- a/scripts/ridge-bake/tests/test_ridge.py +++ /dev/null @@ -1,670 +0,0 @@ -"""Fixtures for the A+D sweep/chain/taper. Plain asserts, no pytest needed: venv/bin/python tests/test_ridge.py (exit code 1 on failure). -RIDGE_SRC selects the source directory (default ..) (the mutation runner points it at a patched copy). -Synthetic terrain has a known right answer; the Vienna test pins the skyline of the real bake (its rays come from stage0 for the vienna preset: RIDGE_TILES needs the tiles, or a cached rays file must exist).""" -import os, sys, math, traceback -SRC = os.environ.get('RIDGE_SRC') or os.path.join(os.path.dirname(os.path.abspath(__file__)), '..'); sys.path.insert(0, SRC) # RIDGE_WORK and RIDGE_TILES must be set (tests/run.sh sets them) -import numpy as np, ridge -from ridge import W, TAPER, TAPER_MAX, taper_len - -EYE = 100.0; GROUND = 50.0 # ground below the eye: every ray climbs, so only the placed blocks make ridges -ridge.P = dict(id='synthetic', rmax_km=60) -D = ridge.schedule(False) -def az_of(x): return (x + 0.5) / W * 360.0 -def col_of(az): return int(round(az / 360.0 * W - 0.5)) % W - -def rays(hfun): - az = (np.arange(W) + 0.5) / W * 360.0; A = np.zeros((len(D), W), np.float32); Hh = np.zeros_like(A) - for k, dk in enumerate(D): - h = hfun(dk, az); Hh[k] = h; A[k] = np.degrees(np.arctan2(h - EYE - dk * dk / (2 * ridge.R_EARTH) * (1 - ridge.KREF), dk)) - return A, Hh -def theta(h, d): return math.degrees(math.atan2(h - EYE - d * d / (2 * ridge.R_EARTH) * (1 - ridge.KREF), d)) -def in_arc(az, a0, a1): return ((az - a0) % 360.0) <= ((a1 - a0) % 360.0) -def block(a0, a1, d0, d1, h): return lambda dk, az: np.where(in_arc(az, a0, a1) & (dk >= d0) & (dk < d1), h, 0.0) -def terrain(*blocks): return lambda dk, az: np.maximum.reduce([np.full_like(az, GROUND)] + [np.asarray(b(dk, az), float) for b in blocks]) -def slot_at(r, col, ang): - """the slot whose top at `col` is closest to `ang` (the layer that owns the block)""" - return min(r['slots'], key=lambda S: abs(S['top'][col] - ang)) -def jump_bound(S, x, w=26): - """steepest step allowed inside a taper: smoothstep slope 1.5 x the end drop / taper_len(drop) (+ margin), the end drop taken as max((highest top within w columns) - lo, 0.4)""" - seg = S['top'][[(x + k) % W for k in range(-w, w + 1)]]; drop = max(seg.max() - S['lo'][x], 0.4); return 1.6 * drop / float(taper_len(drop)) + 0.02 # the taper always falls at least 0.4 deg (top - 0.4 when the valley is shallower) -def active_pairs(S): return [x for x in range(W) if S['wt'][x] > 0 and S['wt'][(x + 1) % W] > 0] -def test_plateau_has_no_spike_and_no_cliff_beyond_taper(): - """Flat-topped plateau, az 100-140, 20-24 km, 900 m. Right answer: top = theta(900, 20 km) inside, nothing above it anywhere, outside the plateau the top only falls - (monotonically, within the taper), and no single-column step exceeds the taper bound.""" - A, Hh = rays(terrain(block(100, 140, 20000, 24000, 900))); r = ridge.sweep(D, A, Hh); th = theta(900, 20000) - S = slot_at(r, col_of(120), th); top = S['top'] - inside = np.arange(col_of(101), col_of(139)); assert np.abs(top[inside] - th).max() < 0.03, ('plateau top not flat/right', np.abs(top[inside] - th).max()) - assert top.max() <= th + 0.03, ('spike above the plateau', top.max() - th) - for side in (-1, 1): - x0 = col_of(100) if side < 0 else col_of(140) - n = int(taper_len(max(th - S['lo'][col_of(120)], 0.4))); seg = np.array([top[(x0 + side * j) % W] for j in range(1, n)]) - assert (np.diff(seg) <= 1e-9).all(), ('taper is not monotone away from the plateau', side, np.diff(seg).max()) - assert seg[-1] < seg[0] - 0.3, 'taper does not die away' - assert S['wt'][(x0 + side * (n + 3)) % W] == 0, 'the layer is still drawn far outside the plateau' - j = np.abs(np.diff(top, append=top[0])); bad = [(x, round(float(j[x]), 3)) for x in active_pairs(S) if j[x] > jump_bound(S, x)]; assert not bad, ('column jump above the bound', bad[:5]) - drop = th - S['lo'][col_of(120)]; width = int(((S['wt'] > 0) & (S['wt'] < 1))[col_of(140):col_of(140) + 90].sum()); assert drop > 1.5 and width >= 0.9 * min(72, 30 * drop), ('a tall end must fall over ~30 columns per degree of drop up to 72 (absolute, not the constant) (its drop is %.2f deg)' % drop, width) - -def test_ridge_whose_two_ends_differ_does_not_borrow_the_far_end(): - """Plateau az 100-140 whose height climbs 600 -> 1200 m with azimuth. Right answer: left of the left end nothing is higher than the left end's own top, right of the right end - nothing is higher than the right end's top (the round-1 bug held the END value on both sides: a 0.9 deg step on the low side).""" - hfun = lambda dk, az: np.where(in_arc(az, 100, 140) & (dk >= 20000) & (dk < 24000), 600.0 + 600.0 * np.clip((az - 100.0) / 40.0, 0, 1), 0.0) - A, Hh = rays(terrain(hfun)); r = ridge.sweep(D, A, Hh); tl, tr = theta(600, 20000), theta(1200, 20000) - S = slot_at(r, col_of(120), (tl + tr) / 2); top = S['top']; xl, xr = col_of(100), col_of(140) - left = top[[(xl - j) % W for j in range(0, TAPER_MAX + 3)]]; right = top[[(xr + j) % W for j in range(0, TAPER_MAX + 3)]] - assert left.max() <= tl + 0.05, ('left of the low end rises above it', left.max() - tl) - assert right.max() <= tr + 0.05 and right[0] >= tr - 0.1, ('right end value wrong', right[:3]) - assert (np.diff(left) <= 1e-9).all() and (np.diff(right) <= 1e-9).all(), 'tapers are not monotone' - -def test_slot_union_only_lets_present_chains_compete(): - """Two chains in one slot: L1 present on cols 0-99 (top 0.2), L2 present on cols 200-299 (top 2.0) but its HELD value elsewhere is 0.5 > 0.2. Right answer: on cols 0-99 - the slot is L1 (top 0.2, wt 1); a chain that is absent there must not win with its held value.""" - def lay(c0, c1, top, held): - wt = np.zeros(W); wt[c0:c1] = 1.0; tp = np.full(W, held); tp[c0:c1] = top - return dict(top=tp, lo=np.full(W, -1.0), ph=np.full(W, 100.0), vh=np.full(W, 0.0), slope=np.full(W, 5.0), wt=wt, pd=np.full(W, 20000.0), vd=np.full(W, 19000.0), hd=np.full((ridge.NH, W), 19500.0), hh=np.full((ridge.NH, W), 50.0), dist=20000.0) - S = ridge.to_slots([lay(0, 100, 0.2, -1.0), lay(200, 300, 2.0, 0.5)]) - assert len(S) == 1 and (S[0]['wt'][0:100] == 1).all() and np.allclose(S[0]['top'][0:100], 0.2), ('absent chain won the slot union', S[0]['top'][:3], S[0]['wt'][:3]) - assert (S[0]['wt'][200:300] == 1).all() and np.allclose(S[0]['top'][200:300], 2.0) - -def S_ok(S, i): return S['wt'][i] > 0 and S['wt'][(i + 1) % W] > 0 -def test_step_cliff_is_one_step_and_levels_are_right(): - """Two plateaus side by side, 700 m (az 60-180) then 1300 m (180-300). Right answer: level theta_A left, theta_B right, one step at az 180 (col 3200).""" - A, Hh = rays(terrain(block(60, 180, 20000, 24000, 700), block(180, 300, 20000, 24000, 1300))); r = ridge.sweep(D, A, Hh) - tA, tB = theta(700, 20000), theta(1300, 20000); x = col_of(180) - SA = slot_at(r, col_of(120), tA); top = SA['top'] - assert np.abs(top[col_of(62):x - TAPER_MAX - 2] - tA).max() < 0.03, 'level A wrong' - assert np.abs(top[x + 2:col_of(298)] - tB).max() < 0.03, 'level B wrong' - assert top.max() <= tB + 0.03, 'spike above the higher plateau' - j = np.abs(np.diff(top)); ends = (col_of(60), col_of(300)); big = [i for i in np.where(j > 0.2)[0] if S_ok(SA, i) and not (x - TAPER_MAX - 2 <= i <= x + 2) and min(abs(i - e) for e in ends) > TAPER_MAX + 2] - assert not big, ('a jump away from the step and the two outer ends', big[:6]) - assert (top[x - TAPER_MAX - 2:x + 3].max() - tA) <= (tB - tA) + 0.03 - -def test_single_peak_has_one_maximum(): - """Gaussian peak 1800 m at az 90, 30 km, sigma 1.5 km. Right answer: one maximum at az 90 +-0.2, monotone either side inside the chain, no secondary bump.""" - def peak(dk, az): - lat = dk * np.sin(np.radians(az - 90.0)); return 1800.0 * np.exp(-(lat ** 2 + (dk - 30000.0) ** 2) / (2 * 1500.0 ** 2)) - A, Hh = rays(terrain(peak)); r = ridge.sweep(D, A, Hh); top_peak = theta(1800, 30000) - S = max(r['slots'], key=lambda S: S['top'][col_of(90)]); top = S['top'] - xm = int(np.argmax(top[col_of(60):col_of(120)])) + col_of(60); assert abs(az_of(xm) - 90.0) < 0.25, ('maximum at', az_of(xm)) - assert abs(top[xm] - top_peak) < 0.1, (top[xm], top_peak) - seg = top[col_of(60):col_of(120)]; peaks = [i for i in range(1, len(seg) - 1) if seg[i] >= seg[i - 1] and seg[i] > seg[i + 1] and (seg[i] - min(seg[max(0, i - 30):i + 30])) > 0.01] - assert len(peaks) == 1, ('secondary local maxima', len(peaks)) - -def test_wraparound_ridge_across_north_is_seamless_and_rotation_invariant(): - """Plateau az 350 -> 10 (across 0/360). Right answer: flat top, no step across the seam, one chain crossing it, and the result does not depend on where column 0 is.""" - A, Hh = rays(terrain(block(350, 10, 20000, 24000, 900))); r = ridge.sweep(D, A, Hh); th = theta(900, 20000) - S = slot_at(r, 0, th); top = S['top'] - assert abs(top[W - 1] - top[0]) < 0.003, ('seam step', top[W - 1] - top[0]) - assert np.abs(top[[col_of(351), col_of(355), W - 1, 0, col_of(5), col_of(9)]] - th).max() < 0.03 - near20 = [l for l in r['keep'] if abs(math.log(l['dist'] / 20000.0)) < 0.3] - assert len(near20) == 1 and near20[0]['x0'] + near20[0]['span'] > W, ('the 20 km plateau must be ONE chain crossing the seam', [(l['x0'], l['span']) for l in near20]) - R = 1600; r2 = ridge.sweep(D, np.roll(A, -R, axis=1), np.roll(Hh, -R, axis=1)) - for S1 in r['slots']: - S2 = next(s for s in r2['slots'] if s['slot'] == S1['slot']); d = np.abs(np.roll(S2['top'], R) - S1['top']).max(); assert d < 1e-9, ('not rotation invariant', S1['slot'], d) - -def test_vienna_skyline_has_no_invented_jump_and_seam_is_seamless(): - """Real bake: no per-column step above the stated bound. In a chain (wt == 1) a step above 0.12 deg is allowed only if both columns carry the raw span peak that wins there (a real step of the DEM, e.g. a roof edge); in a taper the - steepest allowed step is 1.6 x (top-lo)/TAPER + 0.02. The envelope across the seam must not jump.""" - saved = ridge.P - ridge.configure(ridge.load_preset('vienna')); d, A, Hh, _ = ridge.stage0(False) # cached under the key of its own inputs (computed once if absent) - ridge.P = dict(id='vienna', rmax_km=95); r = ridge.sweep(d, A, Hh); ridge.P = saved - worst = []; cols = r['cols'] - real = lambda x, top: any(abs(s[0] - top) < 1e-4 for s in cols[x % W]) # the top at x is an actual raw span peak: a step between two such columns is a step of the DEM - for S in r['slots']: - j = np.abs(np.diff(S['top'], append=S['top'][0])) - for x in active_pairs(S): - bound = jump_bound(S, x) if min(S['wt'][x], S['wt'][(x + 1) % W]) < 1 else 0.12 - if j[x] > bound and not (S['wt'][x] == 1 and S['wt'][(x + 1) % W] == 1 and real(x, S['top'][x]) and real(x + 1, S['top'][(x + 1) % W])): - worst.append((S['slot'], x, round(float(j[x]), 3), round(float(bound), 3), round(float(S['wt'][x]), 2))) - assert not worst, ('Vienna jumps above the bound', worst[:6]) - sky = np.max([S['top'] for S in r['slots']], axis=0); assert abs(sky[W - 1] - sky[0]) < 0.05, ('envelope jumps across the seam', sky[W - 1] - sky[0]) - for S in r['slots']: - if S['wt'][W - 1] > 0 and S['wt'][0] > 0: assert abs(S['top'][W - 1] - S['top'][0]) < 0.05, ('layer jumps across the seam', S['slot']) - -def _shadow_fixture(): - """flat 100 m terrain with one 1000 m wall at lat 48.45-48.46 (5.6-6.7 km north of the sample point), sample point lat 48.40 lon 16.5 at 103 m""" - m = np.full((3600, 3600), 100.0, np.float32); y0 = int((49 - 48.46) * 3600); y1 = int((49 - 48.45) * 3600); m[y0:y1, :] = 1000.0 - ridge.P = dict(id='syn', lat=48.40, lon=16.5, rmax_km=60); ridge._mos[('syn', ridge.mosaic_key(ridge.P))] = (m, 49, 16) - return [dict(spts=dict(lat=np.full((3, W), 48.40), lon=np.full((3, W), 16.5), h=np.full((3, W), 103.0)))] -def test_cast_shadow_follows_the_sun_ray_over_the_dem(): - """Right answer: sun due north at 5 deg: the ray is 580 m at the wall, the wall is 1000 m => fully shadowed (1.0). Sun due north at 30 deg clears the wall (0.0). Sun due south: the wall is behind the sun (0.0). - Sun at 0.2 deg: no direct light, no shadow (0.0).""" - L = _shadow_fixture(); f = lambda az, el: float(ridge.cast_shadows(L, az, el)[0].mean()) - assert f(0.0, 5.0) > 0.99, ('wall does not shadow a low sun behind it', f(0.0, 5.0)) - assert f(0.0, 30.0) < 0.01, ('high sun clears the wall', f(0.0, 30.0)) - assert f(180.0, 5.0) < 0.01, ('wall casts a shadow toward the sun', f(180.0, 5.0)) - assert f(0.0, 0.2) == 0.0 - -def test_cast_shadow_east_west_and_longitude_scale(): - """Wall of 1000 m at lon 16.575-16.585 (5.5-6.2 km EAST of the sample point at lat 48.4, cos(lat) matters), flat 100 m elsewhere. Sun due east at 5 deg: shadowed (580 m ray vs 1000 m). - Sun due west: clear. Without the cos(lat) factor the wall would sit at 8.3 km and a 6 deg ray (980 m) would barely touch it, so the fully-shadowed assertion at 6 deg pins the scale.""" - m = np.full((3600, 3600), 100.0, np.float32); x0 = int((16.575 - 16) * 3600); x1 = int((16.585 - 16) * 3600); m[:, x0:x1] = 1000.0 - ridge.P = dict(id='syn2', lat=48.40, lon=16.5, rmax_km=60); ridge._mos[('syn2', ridge.mosaic_key(ridge.P))] = (m, 49, 16) - L = [dict(spts=dict(lat=np.full((3, W), 48.40), lon=np.full((3, W), 16.5), h=np.full((3, W), 103.0)))]; f = lambda az, el: float(ridge.cast_shadows(L, az, el)[0].mean()) - assert f(90.0, 5.0) > 0.99, ('east wall does not shadow a low east sun', f(90.0, 5.0)) - assert f(270.0, 5.0) < 0.01, ('east wall shadows a west sun', f(270.0, 5.0)) - assert f(90.0, 6.0) > 0.99 and f(90.0, 12.0) < 0.01, ('east-wall shadow edge is at the wrong elevation (wall is 5.5 km east of the point: the sun ray at 6 deg is 660 m there, at 12 deg 1.2 km)', f(90.0, 6.0), f(90.0, 12.0)) - -def test_shadow_darkens_the_face_it_covers(): - """Whole pipeline on a synthetic ramp (slope 0.35, lit by the relief light): with the shadow march forced to 1 every face pixel is darker than with it forced to 0 (the shadow removes direct sun); forced 0 equals shadows off.""" - A, Hh = rays(terrain(lambda dk, az: np.where(in_arc(az, 100, 140) & (dk >= 20000) & (dk < 24000), 50.0 + 0.35 * (dk - 20000), 0.0))); ridge.P = dict(id='synthetic', rmax_km=60, lat=48.0, lon=16.0, floor_agl_m=30.0, s_ref_deg=8.0, win0=(col_of(120) - 640) % W) - r = ridge.sweep(D, A, Hh); ridge.set_view(r['slots']); L = ridge.bake(r['slots']); pf = ridge.statics(); real = ridge.cast_shadows - try: - ridge.cast_shadows = lambda layers, az, el: [np.ones((3, W)) for _ in layers]; dark = ridge.relight(L, 300.0, 30.0, *pf) - ridge.cast_shadows = lambda layers, az, el: [np.zeros((3, W)) for _ in layers]; lit = ridge.relight(L, 300.0, 30.0, *pf) - finally: ridge.cast_shadows = real - off = ridge.relight(L, 300.0, 30.0, *pf, shadows=False); c = slice(col_of(105), col_of(135)); rows = slice(0, ridge.H); Ly = max(L, key=lambda l: ((l['m'][rows, c] > 0.99) & (l['w'][rows, c] < 0.05)).sum()); m = (Ly['m'][rows, c] > 0.99) & (Ly['w'][rows, c] < 0.05) # body pixels (not the mist foot) of the layer that owns the plateau - assert m.any(), 'no solid face pixels to compare' - assert dark[rows, c][m].mean() < lit[rows, c][m].mean() - 0.02, ('shadow does not darken the face', dark[rows, c][m].mean(), lit[rows, c][m].mean()) - assert np.abs(lit - off).max() < 1e-4, 'a zero shadow differs from shadows off' - -# ---------------------------------------------------------------------------------------- shading fixtures (round 3): the face history -> per-pixel normal -def _bake_view(hfun, az0): - A, Hh = rays(terrain(hfun)); ridge.P = dict(id='synthetic', rmax_km=60, lat=48.0, lon=16.0, floor_agl_m=30.0, s_ref_deg=8.0, win0=(col_of(az0) - 640) % W) - r = ridge.sweep(D, A, Hh); ridge.set_view(r['slots']); return r, ridge.bake(r['slots']) -def _owner(r, L, col, ang): - S = slot_at(r, col, ang); return S, next(Ly for Ly in L if Ly['dist'] == S['dist']) -CONE = dict(Dc=20000.0, Rb=6000.0, Hc=1800.0, az0=90.0) -def _cone(dk, az): - E = dk * np.sin(np.radians(az)) - CONE['Dc'] * math.sin(math.radians(CONE['az0'])); N = dk * np.cos(np.radians(az)) - CONE['Dc'] * math.cos(math.radians(CONE['az0'])) - return np.where(np.hypot(E, N) < CONE['Rb'], CONE['Hc'] * (1 - np.hypot(E, N) / CONE['Rb']), 0.0) -def _cone_pixels(sun): - """(analytic n.L, baked n.L) at pixels of the cone layer: the analytic one marches the ray finely to the first sample whose running-max angle reaches the pixel's elevation and takes the cone's own normal there.""" - r, L = _bake_view(_cone, CONE['az0']); S, Ly = _owner(r, L, col_of(CONE['az0']), theta(CONE['Hc'], CONE['Dc'])); nd, _ = ridge.ndl_field(Ly, sun); dd = np.arange(500.0, 60000.0, 4.0); m = CONE['Hc'] / CONE['Rb']; out = [] - for x in range(300, 1000, 7): - c = (ridge.P['win0'] + x) % W - if S['wt'][c] < 1: continue - az = (c + 0.5) / W * 360.0; h = _cone(dd, np.full_like(dd, az)); ang = np.degrees(np.arctan2(h - EYE - dd * dd / (2 * ridge.R_EARTH) * (1 - ridge.KREF), dd)); rm = np.maximum.accumulate(ang) - for f in (0.6, 0.7, 0.8, 0.9, 0.95): - row = int(round((ridge.TOP_DEG - (Ly['lo'][c] + f * (Ly['ang'][c] - Ly['lo'][c]))) * ridge.PXDEG_V - 0.5)); d = dd[min(np.searchsorted(rm, ridge.rowang[row]), len(dd) - 1)] - E = d * math.sin(math.radians(az)) - CONE['Dc'] * math.sin(math.radians(CONE['az0'])); N = d * math.cos(math.radians(az)) - CONE['Dc'] * math.cos(math.radians(CONE['az0'])); rr = math.hypot(E, N) - if rr >= 0.85 * CONE['Rb']: continue # on the cone proper, away from the foot kink (the +-2 node slope window rounds the kink) - n = np.array([m * E / rr, m * N / rr, 1.0]); n /= np.linalg.norm(n); out.append((float(n @ sun), float(nd[row, c]))) - return np.array(out), Ly, nd -def test_cone_is_shaded_per_pixel_with_the_analytic_normal(): - """Cone (apex 1800 m, base radius 6 km, 20 km away), sun from the west at 20 deg. Right answer: the shading of each pixel is the cone's own n.L at the point that pixel sees, so it changes down every - column (the radial direction turns as the ray climbs) and across the face; the round-2 baker stored ONE slope per peak and gave every pixel of a column the same value (within-column std 0).""" - sun = ridge.sun_vec(250.0, 20.0); v, Ly, nd = _cone_pixels(sun); assert len(v) > 100, len(v) - err = np.abs(v[:, 0] - v[:, 1]); assert np.median(err) < 0.05 and err.mean() < 0.07, ('baked n.L is not the analytic one', np.median(err), err.mean()) - assert np.ptp(v[:, 1]) > 0.8 * np.ptp(v[:, 0]), ('baked shading range is compressed', np.ptp(v[:, 1]), np.ptp(v[:, 0])) - cs = [nd[int((ridge.TOP_DEG - Ly['ang'][c]) * ridge.PXDEG_V) + 6:int((ridge.TOP_DEG - Ly['lo'][c]) * ridge.PXDEG_V) - 6, c].std() for c in (ridge.P['win0'] + np.arange(400, 900, 20)) % W] - assert np.mean(cs) > 0.03, ('shading is constant down a column', np.mean(cs)) - -def test_plane_face_has_the_planes_slope_and_no_lateral_tilt(): - """Planar ramp facing the viewer, az 60-120, rising 0.35 per metre of distance from 20 km to 24 km, then a drop. Right answer: every face pixel's front slope is 0.35 (19.3 deg) and its lateral tilt is 0, - whatever its row (one slope per peak could not tell a plane from a curved face, only the history can).""" - ramp = lambda dk, az: np.where(in_arc(az, 60, 120) & (dk >= 20000) & (dk < 24000), 50.0 + 0.35 * (dk - 20000), 0.0) - r, L = _bake_view(ramp, 90.0); S, Ly = _owner(r, L, col_of(90), theta(50.0 + 0.35 * 4000, 24000)); cols = (ridge.P['win0'] + np.arange(560, 720)) % W - rows = np.arange(int((ridge.TOP_DEG - Ly['ang'][col_of(90)]) * ridge.PXDEG_V) + 8, int((ridge.TOP_DEG - 1.0) * ridge.PXDEG_V)); sl = Ly['sl'][np.ix_(rows, cols)]; la = Ly['la'][np.ix_(rows, cols)] - assert len(rows) > 20 and abs(np.median(sl) - 0.35) < 0.03 and np.percentile(np.abs(sl - 0.35), 90) < 0.08, ('front slope is not the plane\'s', np.median(sl), np.percentile(np.abs(sl - 0.35), 90)) - assert np.abs(la).max() < 0.08, ('lateral tilt on a plane', np.abs(la).max()) - -def test_ridge_step_tilts_the_face_only_at_the_step(): - """Two plateaus (700 m / 1300 m) meeting at az 180. Right answer: the face is level laterally (la ~ 0) away from the step, and strongly tilted at it (the height changes by 600 m between two neighbouring columns).""" - r, L = _bake_view(lambda dk, az: np.where(in_arc(az, 60, 180) & (dk >= 20000) & (dk < 24000), 700.0, np.where(in_arc(az, 180, 300) & (dk >= 20000) & (dk < 24000), 1300.0, 0.0)), 180.0) - S, Ly = _owner(r, L, col_of(120), theta(700, 20000)); cols = lambda a, b: (ridge.P['win0'] + np.arange(a, b)) % W; rows = slice(int((ridge.TOP_DEG - theta(1300, 20000)) * ridge.PXDEG_V), int((ridge.TOP_DEG - 0.3) * ridge.PXDEG_V)) - far = np.abs(Ly['la'][rows, :][:, cols(200, 500)]); assert far.max() < 0.05, ('lateral tilt away from the step', far.max()) - near = Ly['la'][rows, :][:, cols(620, 660)]; assert np.abs(near).max() > 0.2, ('no tilt at the step', np.abs(near).max()) - -def test_roof_height_bump_in_the_foreground_is_not_a_ridge(): - """Flat ground with a 19 m x 40 m block 700 m away (a roof / tree in a DSM). Right answer: no layer: a peak needs PROM_M (25 m) of height over its valley, and the 4 km NEAR_CAP shoulder rule obeys the same - rule (round 2 committed the shoulder with no prominence test: an arch 66 columns wide stood in the foreground). A 60 m block IS a ridge.""" - bump = lambda h: (lambda dk, az: np.where(in_arc(az, 88, 92) & (dk >= 700) & (dk < 740), GROUND + h, 0.0)) - A, Hh = rays(terrain(bump(19.0))); r = ridge.sweep(D, A, Hh); assert not [l for l in r['keep'] if l['dist'] < 4000], ('a 19 m bump became a ridge', [(l['x0'], l['span'], round(l['dist'])) for l in r['keep']]) - A, Hh = rays(terrain(bump(60.0))); r = ridge.sweep(D, A, Hh); assert [l for l in r['keep'] if l['dist'] < 1000], 'a 60 m block must be a ridge' - -def test_contour_lines_are_never_denser_than_9_px_over_most_of_the_face(): - """Right answer (cone): the iso-height lines drawn on a layer are at least CONTOUR_MIN_PX apart over three quarters of its face (round 2 picked the interval from the SHALLOWEST columns, so the steep centre - of the mountain carried a line every 2-3 px: the fine horizontal banding).""" - r, L = _bake_view(_cone, CONE['az0']); S, Ly = _owner(r, L, col_of(CONE['az0']), theta(CONE['Hc'], CONE['Dc'])); sp = [] - for c in (ridge.P['win0'] + np.arange(450, 850, 10)) % W: - r0, r1 = int((ridge.TOP_DEG - Ly['ang'][c]) * ridge.PXDEG_V) + 4, int((ridge.TOP_DEG - Ly['lo'][c]) * ridge.PXDEG_V) - 4; line = np.where(Ly['dpx'][r0:r1, c] < 0.5)[0] - if len(line) > 1: g = np.diff(line); sp += list(g[g > 1]) - assert len(sp) > 20 and np.percentile(sp, 25) >= 8, ('contour lines too dense', np.percentile(sp, 25), len(sp)) - -def test_no_layer_paints_a_wall_down_the_frame(): - """Right answer: a layer's alpha is 0 further below its own crest than its foot allows (<= 210 px): its column boundary leaves no vertical edge through the floor (round 2 painted the body to the bottom of the - frame wherever the chain existed: hard walls below every chain end).""" - r, L = _bake_view(lambda dk, az: np.where(in_arc(az, 100, 140) & (dk >= 20000) & (dk < 24000), 900.0, 0.0), 120.0) - for Ly in L: below = np.arange(ridge.H)[:, None] - (ridge.TOP_DEG - Ly['ang'])[None, :] * ridge.PXDEG_V; assert Ly['a'][below > 215].max() == 0, ('layer painted below its foot', Ly['rank']) - S = max(r['slots'], key=lambda S: S['top'][col_of(120)]) - for Ly in L: - if Ly['dist'] != S['dist']: continue - end = [c for c in range(W) if 0 < S['wt'][c] < 0.12]; assert end, 'fixture has no taper end' - bad = [(c, int((Ly['a'][:, c] > 0.1).sum())) for c in end if (Ly['a'][:, c] > 0.1).sum() > 0.85 * max(Ly['ang'][c] - Ly['lo'][c], 0) * ridge.PXDEG_V + 4]; assert not bad, ('a chain end hangs a curtain thicker than the ridge is tall', bad[:4]) - -def test_slot_is_rotation_invariant_in_columns_where_no_chain_is_present(): - """Two plateaus in one distance slot (az 100-110 at 700 m, az 200-215 at 1100 m, both 20-24 km). Right answer: rotating the ring by 1600 columns changes nothing, in EVERY column of the slot including those where no chain is present - (round 3 first picked the first chain in list order there; list order depends on where column 0 is, and those values feed the blurs at every chain end: Half Dome's rotation test went from 0 to 97 px).""" - A, Hh = rays(terrain(block(100, 110, 20000, 24000, 700), block(200, 215, 20000, 24000, 1100))); r = ridge.sweep(D, A, Hh); R = 2800; r2 = ridge.sweep(D, np.roll(A, -R, axis=1), np.roll(Hh, -R, axis=1)) # 2800 columns carries the az 200-215 block through column 0: the chains swap places in list order - for S1 in r['slots']: - S2 = next(s for s in r2['slots'] if s['slot'] == S1['slot']) - for k in ('top', 'lo', 'wt', 'pd', 'ph', 'vh', 'slope'): assert np.abs(np.roll(S2[k], R) - S1[k]).max() < 1e-9, ('slot not rotation invariant', S1['slot'], k, np.abs(np.roll(S2[k], R) - S1[k]).max()) - for k in ('hd', 'hh'): assert np.abs(np.roll(S2[k], R, axis=1) - S1[k]).max() < 1e-6, ('slot history not rotation invariant', S1['slot'], k) - -def test_prominent_far_ridge_behind_a_basin_is_found(): - """Terrace (50 m, eye 100 m) ending at 10 km, a 0 m basin, a 60 m ridge at 30 km (az 80-100). Right answer: the ridge is a layer at 30 km: it stands 0.5 deg above its visible valley, which is 4 times the 25 m / 30 km bar of 0.048 deg. - (Round 3's first cut compared raw heights, ridge 60 m vs the valley's 0 m... and lost ridges whose valley sample is near and high.)""" - hf = lambda dk, az: np.where(dk < 10000, 50.0, np.where(in_arc(az, 80, 100) & (dk >= 30000) & (dk < 30500), 60.0, np.where(in_arc(az, 80, 100) & (dk >= 50000) & (dk < 55000), 300.0, 0.0))) # the 300 m wall at 50 km keeps the ridge from being committed as the ray's last peak - A, Hh = rays(hf); r = ridge.sweep(D, A, Hh); far = [l for l in r['keep'] if abs(l['dist'] - 30000) < 1500]; assert far and far[0]['span'] > 200, ('the 30 km ridge is missing', [(l['x0'], l['span'], round(l['dist'])) for l in r['keep']]) - -def test_lone_foreground_stub_is_kept_but_a_stub_inside_a_near_ridge_is_not(): - """(a) a 0.5 deg wide, 60 m block at 1.7 km with nothing else near: the nearest layer must cover it (a hole would show the far ridge's body). (b) the same stub in front of a long near ridge (az 100-160, 150 m, 1.5 km): - its columns are covered by that ridge, so it is dropped (round 2 kept every foreground stub: lone arches).""" - A, Hh = rays(terrain(block(90, 90.5, 1700, 1740, 110))); r = ridge.sweep(D, A, Hh); assert [l for l in r['keep'] if l['dist'] < 2500], 'lone foreground stub dropped: hole in the nearest layer' - A, Hh = rays(terrain(block(100, 160, 1500, 1600, 150), block(129, 129.5, 600, 640, 160))); r = ridge.sweep(D, A, Hh); assert not [l for l in r['keep'] if l['dist'] < 1000], ('a stub inside a near ridge became an arch', [(l['x0'], l['span'], round(l['dist'])) for l in r['keep']]) - -def test_tilted_plane_has_the_planes_front_slope_and_lateral_tilt(): - """Plane h = 50 + 0.3 (E - 20000) - 0.2 N over az 85-95, 20-24 km, then a drop. At az 90 the radial is +E and the tangent is -N, so n ~ (-0.3 r - la t, 1) with la = -(dh/dN) = +0.2. Right answer: front slope 0.3, lateral tilt 0.2, each within 0.04 - (a lateral scale of 2x, or no tilt, fails).""" - def plane(dk, az): - E = dk * np.sin(np.radians(az)); N = dk * np.cos(np.radians(az)); return np.where(in_arc(az, 85, 95) & (dk >= 20000) & (dk < 24000), 50.0 + 0.3 * (E - 20000.0) - 0.2 * N, 0.0) - r, L = _bake_view(plane, 90.0); S, Ly = _owner(r, L, col_of(90), theta(50.0 + 0.3 * 4000, 24000)); cols = (ridge.P['win0'] + np.arange(600, 680)) % W - rows = np.arange(int((ridge.TOP_DEG - Ly['ang'][col_of(90)]) * ridge.PXDEG_V) + 8, int((ridge.TOP_DEG - 1.0) * ridge.PXDEG_V)); sl = Ly['sl'][np.ix_(rows, cols)]; la = Ly['la'][np.ix_(rows, cols)] - assert len(rows) > 15 and abs(np.median(sl) - 0.3) < 0.04 and abs(np.median(la) - 0.2) < 0.04, ('plane slope / tilt wrong', np.median(sl), np.median(la)) - -def test_history_keeps_only_what_the_eye_sees(): - """Samples (angle, distance, height): (0, 100, 0) (2, 200, 10) (1, 300, 5) (1.5, 350, 8) (3, 400, 30). The third and fourth are hidden behind the second (the fourth RISES from the third, so only a running maximum drops it). Right answer: at elevation 2.5 the face is half way between the second and the fourth sample: distance 300, height 20 - (without the running maximum the non-monotone angles make the interpolation garbage).""" - d, h = ridge.strip_history([0.0, 2.0, 1.0, 1.5, 3.0], [100.0, 200.0, 300.0, 350.0, 400.0], [0.0, 10.0, 5.0, 8.0, 30.0], 0, 4); t = np.linspace(0, 3.0, ridge.NH); k = int(np.argmin(np.abs(t - 2.5))) - assert abs(d[k] - (200 + 200 * (t[k] - 2) / 1.0)) < 1e-6 and abs(h[k] - (10 + 20 * (t[k] - 2) / 1.0)) < 1e-6 and d[0] == 100 and d[-1] == 400, (d[k], h[k]) - -def test_no_lateral_tilt_across_an_occlusion_edge(): - """Block A (az 60-130, 15 km out, 700 m) and block B (az 110-180, 21 km out, 940 m): the same top angle, the same distance slot, so the slot owner switches where their tapers cross. Right answer: at every column where the owner's - distance jumps by more than 2 km the two sides are not one surface, so the lateral tilt there is 0 (it saturated at the 1.5 clamp over ~8 columns on Matterhorn).""" - r, L = _bake_view(lambda dk, az: np.where(in_arc(az, 60, 130) & (dk >= 15000) & (dk < 19000), 700.0, np.where(in_arc(az, 110, 180) & (dk >= 21000) & (dk < 25000), 940.0, 0.0)), 120.0) - S, Ly = _owner(r, L, col_of(80), theta(700, 15000)); assert S['dist'] == ridge.KNOTS[3] - sw = [x for x in range(W - 1) if S['wt'][x] > 0 and S['wt'][x + 1] > 0 and abs(S['pd'][x + 1] - S['pd'][x]) > 2000]; assert sw, 'fixture has no owner switch' - for x in sw: - rows = np.arange(max(int((ridge.TOP_DEG - Ly['ang'][x]) * ridge.PXDEG_V) + 4, 0), min(int((ridge.TOP_DEG - Ly['lo'][x]) * ridge.PXDEG_V) - 4, ridge.H)); la = Ly['la'][np.ix_(rows, [x - 1, x, x + 1, x + 2])] - assert np.abs(la).max() < 0.5, ('tilt across an occlusion edge at column', x, np.abs(la).max()) - -# ---------------------------------------------------------------------------------------- relief light (round 3): n.L uses an authored light, colour / haze / shadows keep the real sun -def _gully(dk, az): - """A ramp facing the viewer (0.35 slope, az 50-130, 20-24 km) cut by gullies that run down the slope: height +300 m sin(2 pi N / 3 km), so the lateral tilt swings between -0.63 and +0.63 across the face.""" - N = dk * np.cos(np.radians(az)); return np.where(in_arc(az, 50, 130) & (dk >= 20000) & (dk < 24000), 50.0 + 0.35 * (dk - 20000) + 300.0 * np.sin(2 * np.pi * N / 3000.0), 0.0) -def _relief_probe(az_s, el_s): - """Relight the gullied ramp seen from az 90 with spies on ndl_field (the L it shades with and the n.L it returns for the ramp's layer) and on cast_shadows (the sun it marches toward). Returns (image, Ls, n.L of the ramp layer, shadow suns, layer).""" - r, L = _bake_view(_gully, 90.0); pf = ridge.statics(); S = max(r['slots'], key=lambda S: S['top'][col_of(90)]); Ly = next(l for l in L if l['dist'] == S['dist']); real_nd, real_cs = ridge.ndl_field, ridge.cast_shadows; Ls, nds, suns = [], [], [] - def nd_spy(layer, Lv): out = real_nd(layer, Lv); Ls.append(np.array(Lv)); nds.append((layer is Ly, out[0])); return out - def cs_spy(layers, az, el): suns.append((az, el)); return [np.zeros((3, W)) for _ in layers] - ridge.ndl_field, ridge.cast_shadows = nd_spy, cs_spy - try: img = ridge.relight(L, az_s, el_s, *pf) - finally: ridge.ndl_field, ridge.cast_shadows = real_nd, real_cs - return img, Ls, next(n for own, n in nds if own), suns, Ly -def _face_std(nd, Ly): - """n.L std over the ramp's face pixels (6 px under the crest down to 1 deg elevation), columns 500-780 of the window""" - return float(np.concatenate([nd[int((ridge.TOP_DEG - Ly['ang'][c]) * ridge.PXDEG_V) + 6:int((ridge.TOP_DEG - 1.0) * ridge.PXDEG_V), c] for c in (ridge.P['win0'] + np.arange(500, 780, 5)) % W]).std()) -def test_sun_behind_the_viewer_still_models_the_face(): - """Gullied ramp seen from az 90, sun directly behind the viewer (az 270, 20 deg up). Lit head-on, a gully's two flanks shade alike (n.L std over the face 0.034 with the real sun as the shading light); - the relief light 45 deg off the axis lights one flank and leaves the other dim (0.23). Right answer: std over the face pixels > 0.12.""" - img, Ls, nd, suns, Ly = _relief_probe(270.0, 20.0); b = _face_std(nd, Ly); assert b > 0.12, ('face is flat with the sun behind the viewer', b) -def test_relief_light_is_45_deg_off_the_view_axis_on_the_suns_side(): - """View centre az 90 (the viewer looks east). The relief light is the view centre +/- 45 deg on the side of the view axis the real sun is on. Sun at az 300 (north of the axis, the viewer's left) -> relief az 90-45 = 45; - sun at az 240 (south, the viewer's right) -> relief az 90+45 = 135. Elevation is 25..40 deg (an absolute range, not the constant) both times, whatever the sun's elevation.""" - for sun_az, want in ((300.0, 45.0), (240.0, 135.0)): - _, Ls, _, _, _ = _relief_probe(sun_az, 25.0); assert Ls, 'no shading call'; L = Ls[0]; az = math.degrees(math.atan2(L[0], L[1])) % 360.0; el = math.degrees(math.asin(L[2])) - assert abs(az - want) < 0.5 and 25.0 <= el <= 40.0, ('relief direction wrong', sun_az, az, el) -def test_sun_elevation_changes_colour_but_not_the_relief_direction(): - """Same sun azimuth, elevation 10 deg vs 40 deg: the relief direction is identical, the picture is not (sun colour / haze / strength change), and at -10 deg (night) the face is darker than at 40.""" - i1, L1, nd1, _, Ly = _relief_probe(300.0, 10.0); i2, L2, nd2, _, _ = _relief_probe(300.0, 40.0); i3, _, _, _, _ = _relief_probe(300.0, -10.0) - assert np.abs(L1[0] - L2[0]).max() < 1e-12 and np.abs(nd1 - nd2).max() < 1e-12, 'the relief direction follows the sun elevation' - c = (ridge.P['win0'] + 640) % W; rows = slice(int((ridge.TOP_DEG - Ly['ang'][c]) * ridge.PXDEG_V) + 8, int((ridge.TOP_DEG - Ly['ang'][c]) * ridge.PXDEG_V) + 40) - assert np.abs(i1[rows, 640] - i2[rows, 640]).mean() > 0.01, 'colour does not follow the real sun'; assert i3[rows, 640].mean() < 0.6 * i2[rows, 640].mean(), ('night is not dark', i3[rows, 640].mean(), i2[rows, 640].mean()) -def test_cast_shadows_march_toward_the_real_sun_not_the_relief_light(): - """relight must hand cast_shadows the real sun's azimuth and elevation, not the relief light's.""" - _, _, _, suns, _ = _relief_probe(300.0, 17.0); assert suns == [(300.0, 17.0)], suns - -# ---------------------------------------------------------------------------------------- Soul round-3 survivors -def test_floor_band_has_no_column_wise_luminance_step(): - """Plateau az 100-140 (20-24 km) seen from az 120. Right answer: in the floor band (the frame's bottom 80 rows) no column differs from its neighbour by more than the grain (baseline 0.009, a painted wall 0.25): the compositor must use the layer's - alpha (body dissolved at the foot), not its mask (body to the bottom of the frame, a wall at every chain end).""" - r, L = _bake_view(lambda dk, az: np.where(in_arc(az, 100, 140) & (dk >= 20000) & (dk < 24000), 900.0, 0.0), 120.0); img = ridge.relight(L, 200.0, 20.0, *ridge.statics(), shadows=False) - lum = img @ np.array([0.2126, 0.7152, 0.0722]); st = np.abs(np.diff(lum[ridge.H - 80:], axis=1)).mean(0); assert st.max() < 0.03, ('wall: column-wise luminance step in the floor band', st.max(), int(np.argmax(st))) -def test_foot_fade_starts_at_60_percent_of_the_foot_and_the_body_is_opaque_above_it(): - """A layer's alpha is 1 down to 60% of its foot length, then smoothsteps to 0 at the foot. Right answer on a plateau column: rows with alpha > 0.99 / rows with alpha > 0.001 = 0.6 (+-0.04), and it has the full 210 px foot at most - (a fade that starts early makes the face translucent, one that starts late leaves a wall).""" - r, L = _bake_view(lambda dk, az: np.where(in_arc(az, 100, 140) & (dk >= 20000) & (dk < 24000), 900.0, 0.0), 120.0); S, Ly = _owner(r, L, col_of(120), theta(900, 20000)); a = Ly['a'][:, col_of(120)] - full, nz = int((a > 0.99).sum()), int((a > 0.001).sum()); assert nz > 100, nz; assert abs(full / nz - 0.6) < 0.04, ('fade does not start at 60% of the foot', full, nz) -def test_lateral_tilt_is_smoothed_across_columns(): - """Two plateaus (700 / 1300 m) meeting at az 180: the raw lateral tilt is a spike at the step. Right answer: the baked tilt is blurred over a few columns: no column-to-column change above 0.5 (unblurred: 0.9), and the tilt is still > 0.2 at the step.""" - r, L = _bake_view(lambda dk, az: np.where(in_arc(az, 60, 180) & (dk >= 20000) & (dk < 24000), 700.0, np.where(in_arc(az, 180, 300) & (dk >= 20000) & (dk < 24000), 1300.0, 0.0)), 180.0) - S, Ly = _owner(r, L, col_of(120), theta(700, 20000)); rows = slice(int((ridge.TOP_DEG - theta(1300, 20000)) * ridge.PXDEG_V), int((ridge.TOP_DEG - 0.3) * ridge.PXDEG_V)); la = np.abs(Ly['la'][rows, :][:, (ridge.P['win0'] + np.arange(600, 680)) % W]).max(0) - assert la.max() > 0.2 and np.abs(np.diff(la)).max() < 0.5, ('tilt not smoothed', la.max(), np.abs(np.diff(la)).max()) -def test_contour_interval_is_chosen_from_full_weight_columns_only(): - """Ramp (slope 1.0, az 110-130, 20-22 km) whose tapering ends carry coarser metres-per-pixel. Right answer: the contour spacing in the core is 9-10 px (interval from the chain's own face), not 13-14 (the taper columns pulled the percentile up). median <= 12 and p25 >= 8.""" - r, L = _bake_view(lambda dk, az: np.where(in_arc(az, 110, 130) & (dk >= 20000) & (dk < 22000), 50.0 + 1.0 * (dk - 20000), 0.0), 120.0); S, Ly = _owner(r, L, col_of(120), theta(2050, 22000)); sp = [] - for c in range(col_of(120) - 60, col_of(120) + 61, 6): - r0, r1 = int((ridge.TOP_DEG - Ly['ang'][c]) * ridge.PXDEG_V) + 4, int((ridge.TOP_DEG - Ly['lo'][c]) * ridge.PXDEG_V) - 4; line = np.where(Ly['dpx'][r0:r1, c] < 0.5)[0] - if len(line) > 1: g = np.diff(line); sp += list(g[g > 1]) - assert len(sp) > 100 and np.percentile(sp, 25) >= 8 and np.median(sp) <= 12, ('contour spacing', len(sp), np.percentile(sp, 25), np.median(sp)) -def test_spec_highlight_follows_the_half_vector_of_light_and_viewer(): - """spec_field against an independent evaluation: half vector H = normalise(L + V), V the unit vector back to the viewer (-r) in every column, n = (-sl r - la t + z)/|.|, GGX alpha 0.3. Two different lights, random faces: the field must match to 1e-9 - (a half vector that ignores the light, or any change of the lobe, shows).""" - rng = np.random.default_rng(5); sl = rng.uniform(-0.3, 1.2, (7, W)); la = rng.uniform(-0.6, 0.6, (7, W)); nrm = np.sqrt(1 + sl * sl + la * la); az = ridge.azrad(); r = np.stack([np.sin(az), np.cos(az), 0 * az], 1); t = np.stack([np.cos(az), -np.sin(az), 0 * az], 1) - for laz, lel in ((45.0, 32.0), (200.0, 15.0)): - Lv = ridge.sun_vec(laz, lel); ndl = (Lv[2] - sl * (r @ Lv)[None] - la * (t @ Lv)[None]) / nrm; got = ridge.spec_field(Lv, sl, la, nrm, ndl) - H = Lv[None, :] - r; H /= np.linalg.norm(H, axis=1, keepdims=True); nh = np.clip((H[:, 2][None] - sl * (r * H).sum(1)[None] - la * (t * H).sum(1)[None]) / nrm, 0, 1) - want = 0.30 * 0.0081 / (1 + nh * nh * (0.09 - 1)) ** 2 * np.clip(ndl, 0, 1) - assert np.abs(got - want).max() < 1e-9, ('spec does not match the half-vector model', laz, np.abs(got - want).max()) -def test_fresnel_rim_is_strongest_on_faces_that_lie_away_from_the_viewer(): - """fresnel(sl, nrm) = (1 - n.V)^3 with n.V = sl / |n|: 1 for a flat or back-leaning face (sl <= 0), falling monotonically to ~0 for a face that stands up at the viewer (sl = 5: 0.00001).""" - s = np.array([-1.0, 0.0, 0.2, 0.5, 1.0, 2.0, 5.0]); f = ridge.fresnel(s, np.sqrt(1 + s * s)) - assert f[0] == 1.0 and f[1] == 1.0 and (np.diff(f[1:]) < 0).all() and f[-1] < 1e-3, ('fresnel shape', f) -def test_cache_keys_follow_every_input_of_the_cached_array(): - """mosaic-/rays- cache files are keyed by a digest of their inputs. Right answer: identical inputs -> identical key (the preset id does not enter), a changed preset field (eye height, range, position), tile list or baker constant (refraction, detail noise) -> a different key.""" - saved = ridge.P - try: - base = dict(id='k1', lat=47.0, lon=11.0, rmax_km=40, eye_m=2000.0); ridge.P = dict(base); k0 = (ridge.rays_key(False), ridge.rays_key(True), ridge.mosaic_key(ridge.P)) - ridge.P = dict(base, id='other'); assert (ridge.rays_key(False), ridge.rays_key(True), ridge.mosaic_key(ridge.P)) == k0, 'the preset id changed the key' - for fld, v in (('eye_m', 2010.0), ('rmax_km', 41), ('lon', 11.001), ('lat', 47.001)): - ridge.P = dict(base, **{fld: v}); assert ridge.rays_key(False) != k0[0], ('rays key ignores', fld) - ridge.P = dict(base, lat=47.0, lon=13.5); assert ridge.mosaic_key(ridge.P) != k0[2], 'mosaic key ignores the tile list' - ridge.P = dict(base); old = ridge.KREF; ridge.KREF = 0.2 - try: assert ridge.rays_key(False) != k0[0], 'rays key ignores the refraction constant' - finally: ridge.KREF = old - old = ridge.DETAIL; ridge.DETAIL = dict(old, amp_m=30.0) - try: assert ridge.rays_key(True) != k0[1] and ridge.rays_key(False) == k0[0], 'detail key ignores the noise settings (or the plain key follows them)' - finally: ridge.DETAIL = old - finally: ridge.P = saved -def test_paths_come_from_the_environment_with_no_default(): - """Importing the baker without RIDGE_WORK / RIDGE_TILES fails with a message naming the variable (no location is assumed).""" - import subprocess; env = {k: v for k, v in os.environ.items() if not k.startswith('RIDGE_')} - for var in ('RIDGE_WORK', 'RIDGE_TILES'): - e = dict(env); e.update({'RIDGE_WORK': '.', 'RIDGE_TILES': '.'}); del e[var] - r = subprocess.run([sys.executable, '-I', '-c', 'import sys; sys.path.insert(0, %r); import ridge' % SRC], env=e, capture_output=True, text=True); assert r.returncode and var in r.stderr and 'not set' in r.stderr, (var, r.stderr[-200:]) - -# ---------------------------------------------------------------------------------------- planes export (export.py, ridge.relight_planes) -_before = {k for k in globals() if k.startswith('test_')} # the tests below are the planes/export tests tests/mutate_export.py runs against each mutant -import export, tempfile, json, shutil -def _three_slot_layers(): - """three blocks at 6, 20 and 50 km in the same azimuth range, each standing above the one before it in angle: three slots""" - blocks = [block(100, 140, 6000, 9000, 300), block(100, 140, 20000, 23000, 900), block(100, 140, 50000, 53000, 2500)] - r, L = _bake_view(terrain(*blocks), 120.0); ridge.P['floor_agl_m'] = 2.0; return r, L # a low floor plane: the nearest rows are solid ground -def _flat_layers(n): - """n slots, plane-grouping only needs the list length: the real layers of the 3-slot fixture, truncated / repeated""" - r, L = _three_slot_layers(); return [L[i % len(L)] for i in range(n)] -def _statics_flat_grain(): paperf, grain, fmot = ridge.statics(); return paperf, np.ones_like(grain), fmot -def _over(planes, sky): - img = np.broadcast_to(np.array(sky), planes[export.PLANES[0]].shape[:2] + (3,)).copy() - for n in export.PLANES: a = planes[n]; img = img * (1 - a[..., 3:]) + a[..., :3] * a[..., 3:] - return img -def _open_to(L, first): - """Pixels (window columns) that the layers from index `first` on leave open: no coverage within OCCLUDE_MARGIN_PX columns, so the plane's knock-out does not touch them.""" - if first >= len(L): return np.ones((ridge.H, ridge.WIN), bool) - cov = ridge.win(1 - np.prod([1 - Ly['a'] for Ly in L[first:]], axis=0)); m = ridge.OCCLUDE_MARGIN_PX; pad = np.pad(cov, ((0, 0), (m, m)), mode='edge') - return np.lib.stride_tricks.sliding_window_view(pad, 2 * m + 1, axis=1).max(-1) < 1e-6 -def test_the_far_plane_over_a_flat_sky_equals_the_opaque_relight_of_its_group(): - """The affine haze split is exact for one group: the far plane (floor + slot 0) over one flat colour equals relight of slot 0 alone with that colour as sky and haze (grain off, no shadows) within 1/255 mean and 4/255 max. - The planes carry no sky: above the ridge their alpha is 0.""" - r, L = _three_slot_layers(); f = _statics_flat_grain(); sun = (200.0, 40.0); sky = export.SHEET_SKY - planes = ridge.relight_planes(L, *sun, *f, shadows=False, ref=sky); img = _over(dict(far=planes['far'], mid=0 * planes['mid'], near=0 * planes['near']), sky) - want = ridge.win(ridge.relight(L[:1], *sun, *f, shadows=False, flat=sky)); d = np.abs(img - want) * 255; d = d[_open_to(L, 1)] # a nearer layer hides the far plane where it covers (test_a_farther_plane_is_hidden_behind_the_nearer_layers_that_cover_it) - assert d.mean() < 1.0 and d.max() < 4.0, ('the far plane over a flat sky differs from its opaque relight', d.mean(), d.max()) - assert planes['far'][0, :, 3].max() < 0.01 and planes['near'][0, :, 3].max() < 0.01, 'the top row (sky) is not transparent' -_VIENNA = {} -def _vienna_noon(): - """Real Vienna bake at noon: (planes over a flat sky as an error map in 1/255 per pixel, planes), cached for the tests that read it.""" - if not _VIENNA: - saved = ridge.P; ridge.configure(ridge.load_preset('vienna')); d, A, Hh, _ = ridge.stage0(False); r = ridge.sweep(d, A, Hh); ridge.set_view(r['slots']); L = ridge.bake(r['slots']); f = _statics_flat_grain() - sun = export.sun_set(ridge.P, ridge.P['date'])['noon']; sky = export.SHEET_SKY; planes = ridge.relight_planes(L, sun['az'], sun['el'], *f, shadows=False, ref=sky) - _VIENNA['err'] = np.abs(_over(planes, sky) - ridge.win(ridge.relight(L, sun['az'], sun['el'], *f, shadows=False, flat=sky))).mean(-1) * 255; _VIENNA['planes'] = planes; ridge.P = saved - return _VIENNA['err'], _VIENNA['planes'] -def test_stacked_planes_stay_close_to_the_opaque_relight_on_vienna_noon(): - """Real Vienna bake at noon, three planes over a flat sky vs relight(flat=that sky): the stacking loss (a nearer group's haze veils the farther plane instead of the sky) measured 4.9/255 mean over the frame before the farther planes were knocked out - behind the nearer layers, 1.8 after; the bound is 3, so a split that loses colour or alpha, or a knock-out that stops working, shows. The spec's 2/255 pin is met with 0.2 to spare; the loss is concentrated where planes overlap, see the next test.""" - err, _ = _vienna_noon(); assert err.mean() < 3.0, ('stacked planes drift from the opaque relight', err.mean()) -def test_stacked_planes_error_over_the_overlap_region_is_the_measured_leak(): - """Where a nearer plane covers a farther one (two or more planes with alpha above 1/100) the rest of the leak sits: measured 11.2/255 mean over 16.4% of the frame on Vienna noon (21.8 over 22.6% before the knock-out), bound 12.5. It is what the 48 px knock-out margin - and the easing leave visible (a soft darker blob beside the near plane's wedge), and the dissolving feet where a nearer layer is only partly opaque. Outside the overlap the planes are exact (0.005/255).""" - err, planes = _vienna_noon(); ov = sum((planes[n][..., 3] > 0.01).astype(int) for n in export.PLANES) >= 2 - assert 0.10 < ov.mean() < 0.25, ('the overlap region is not the 16-ish percent the bound was measured on', ov.mean()) - assert err[ov].mean() < 12.5, ('overlap-region error', err[ov].mean()); assert err[~ov].mean() < 0.1, ('error outside the overlap', err[~ov].mean()) -def test_a_farther_plane_is_hidden_behind_the_nearer_layers_that_cover_it(): - """The opaque frame never shows a farther ridge through a nearer layer, but the stacked planes did (the nearer plane's haze share became transparency and the farther ridge showed in it: a slanted seam through the near plane on Vienna). - Right answer on the 3-slot fixture: where the nearer layers are opaque for OCCLUDE_MARGIN_PX columns around a pixel, the mid and far planes are transparent there; where nothing nearer is within the margin, the planes are exactly what they were without the knock-out; - and the knock-out eases in (a pixel just inside the covered region keeps most of its alpha), so no edge of its own is drawn.""" - r, L = _three_slot_layers(); f = _statics_flat_grain(); on = ridge.relight_planes(L, 200.0, 40.0, *f, shadows=False); keep = ridge.most_open; ridge.most_open = lambda seen: np.ones_like(seen) - try: off = ridge.relight_planes(L, 200.0, 40.0, *f, shadows=False) - finally: ridge.most_open = keep - m = ridge.OCCLUDE_MARGIN_PX - for name, first in (('mid', 2), ('far', 1)): - cov = ridge.win(1 - np.prod([1 - Ly['a'] for Ly in L[first:]], axis=0)); reach = lambda w: np.lib.stride_tricks.sliding_window_view(np.pad(cov, ((0, 0), (w, w)), mode='edge'), 2 * w + 1, axis=1).min(-1) - deep = reach(3 * m) > 0.999 # the max filter looks m columns out and the easing another 1.5 m - assert deep.sum() > 2000 and (off[name][..., 3][deep] > 0.1).sum() > 500, (name, 'the fixture has no farther body under a nearer layer to hide') - assert on[name][..., 3][deep].max() < 0.03, (name, 'a farther plane shows through the layer in front of it', float(on[name][..., 3][deep].max())) - free = _open_to(L, first); assert np.array_equal(on[name][..., 3][free], off[name][..., 3][free]) and free.sum() > 2000, (name, 'the knock-out reached pixels no nearer layer covers') - edge = (cov > 0.999) & (reach(2) < 0.5) & (off[name][..., 3] > 0.1); assert edge.sum() > 50 and (on[name][..., 3][edge] / off[name][..., 3][edge]).mean() > 0.4, (name, 'the knock-out has a hard edge at the margin') -def test_the_nearest_layer_is_opaque_down_to_the_horizon_and_the_others_keep_their_dissolve(): - """Right answer: the nearest layer's alpha is 1 from its crest to the horizon row, whatever its strip's valley angle says (above the horizon a dissolved body shows the sky under a crest: the wedge of Vienna's near plane, whose valley angle jumps where the winning chain changes); - a farther layer keeps its dissolve (the far blue ridges fade into the haze above the horizon by design). Fixture: a 400 m block at 3 km (crest 5.7 deg, foot 210 px but 380 px to the horizon) and a 4000 m block at 20 km (a crest above the near one, so it is a ridge of its own).""" - r, L = _bake_view(lambda dk, az: np.where(in_arc(az, 100, 140) & (dk >= 3000) & (dk < 3600), 400.0, np.where(in_arc(az, 100, 140) & (dk >= 20000) & (dk < 24000), 4000.0, 0.0)), 120.0) - c = col_of(120); hz = int(np.argmax(ridge.rowang < 0)); assert len(L) == 2, [Ly['dist'] for Ly in L] - for Ly, solid in ((L[-1], True), (L[0], False)): - a = Ly['a'][:, c]; crest = int(np.argmax(a > 0.5)); body = a[crest + 3:hz]; assert body.size > 20, ('fixture crest is not above the horizon', crest, hz) - assert (body.min() > 0.999) if solid else (body.min() < 0.5), ('nearest' if solid else 'farther', 'layer alpha between crest and horizon', float(body.min())) -def test_near_plane_must_cover_every_column_and_leave_no_hole_under_its_crest(): - """check_near_cover: a near plane passes when every column has a crest and nothing between the crest and the horizon row is a hole; it refuses a fully transparent column, and a hole under a crest (the wedge: alpha 0 at the plane's right edge, rows 230-480 with a hard edge, in the committed Vienna set). - Holes below the horizon are the foot dissolving into the floor and pass.""" - ok = np.zeros((60, 40, 4), np.uint8); ok[10:, :, 3] = 200; export.check_near_cover(ok, 30.0); export.check_near_cover(ok, 59.0) - cols = ok.copy(); cols[:, 33:, 3] = 0; wedge = ok.copy(); wedge[20:50, 30:, 3] = 0; below = ok.copy(); below[35:, 30:, 3] = 0 - export.check_near_cover(below, 30.0) - for bad, why in ((cols, 'transparent'), (wedge, 'hole')): - try: export.check_near_cover(bad, 40.0) - except ValueError as e: assert why in str(e), (why, str(e)) - else: assert False, ('a near plane with a ' + why + ' was accepted') - export.check_near_cover(ok, 30.0) -def test_write_bake_refuses_a_near_plane_with_a_wedge_and_lets_the_other_planes_have_empty_columns(): - """The refusal is at the writer, on the decoded near plane of every frame; the far and mid planes may have transparent columns (a place with no ridge at that distance in part of the window).""" - d = tempfile.mkdtemp() - try: - place, view, suns, planes = _fixture_bake() - for k in export.KEYS: planes[k]['mid'][:, 200:260, 3] = 0 - export.write_bake(d + '/ok', place, view, suns, _encode_all(planes)) - for k, ys, xs in (('dawn', slice(20, 80), slice(1200, 1280)), ('night', slice(0, 600), slice(600, 700))): - bad = {kk: {n: np.array(v) for n, v in vv.items()} for kk, vv in planes.items()}; bad[k]['near'][ys, xs, 3] = 0 - _refuses(d + '/bad', place, view, suns, _encode_all(bad), 'a near plane with a transparent wedge in frame %s was written' % k) - finally: shutil.rmtree(d) -def test_plane_grouping_far_mid_near_and_refuses_fewer_than_three_slots(): - """far = slot 0, near = the last slot, mid = every slot between (the union); a 3-slot place fills all three planes; fewer than 3 slots are refused.""" - assert ridge.group_slots(3) == dict(far=[0], mid=[1], near=[2]) and ridge.group_slots(6) == dict(far=[0], mid=[1, 2, 3, 4], near=[5]) - for n in (0, 1, 2): - try: ridge.group_slots(n) - except ValueError: continue - assert False, ('a place with %d slots was accepted' % n) - r, L = _three_slot_layers(); f = _statics_flat_grain(); pl = ridge.relight_planes(L[:3], 200.0, 40.0, *f, shadows=False) - assert all(pl[n][..., 3].max() > 0.5 for n in export.PLANES), {n: float(pl[n][..., 3].max()) for n in export.PLANES} - try: ridge.relight_planes(L[:2], 200.0, 40.0, *f, shadows=False) - except ValueError: pass - else: assert False, 'a 2-slot place was relit into planes' -def test_straight_alpha_planes_unpremultiply_the_composite(): - """Planes carry straight (un-premultiplied) colour: the mid plane (slot 1, 16 km) and the near plane (slot 2, 5 km) each alone over a flat sky equal the opaque relight of that one slot over the same sky, on the rows - above the horizon (the floor belongs to the far plane). Their bodies are translucent (the haze share is alpha's deficit), so premultiplied colour would be darker by alpha and fail by tens of 1/255. u8() keeps colour and alpha apart.""" - a = np.zeros((2, 2, 4), np.float32); a[0, 0] = (0.5, 0.25, 0.75, 0.5); b = export.u8(a); assert tuple(b[0, 0]) == (128, 64, 191, 128) and tuple(b[1, 1]) == (0, 0, 0, 0) - r, L = _three_slot_layers(); f = _statics_flat_grain(); sun = (200.0, 40.0); sky = export.SHEET_SKY; pl = ridge.relight_planes(L, *sun, *f, shadows=False, ref=sky); top = int((ridge.rowang >= 0).sum()) - for name, i in (('mid', 1), ('near', 2)): - got = _over({n: (pl[n] if n == name else 0 * pl[n]) for n in export.PLANES}, sky)[:top]; want = ridge.win(ridge.relight(L[i:i + 1], *sun, *f, shadows=False, flat=sky))[:top] - body = pl[name][:top, :, 3] > 0.3; assert body.sum() > 1000 and pl[name][:top, :, 3][body].mean() < 0.97, (name, 'the fixture has no translucent body to test') - d = (np.abs(got - want) * 255)[_open_to(L, i + 1)[:top]]; assert d.mean() < 0.5 and d.max() < 4.0, (name, 'plane over a flat sky differs from its slot relight: colour is premultiplied or the haze split is wrong', d.mean(), d.max()) -def _encode_all(planes): return {k: {n: export.encode(planes[k][n]) for n in export.PLANES} for k in export.KEYS} -def _fixture_bake(): - r, L = _three_slot_layers(); f = _statics_flat_grain(); suns = {k: dict(az=100.0 + 20 * i, el=-10.0 + 15 * i, hour_utc=float(i)) for i, k in enumerate(export.KEYS)} - planes = {k: ridge.relight_planes(L, suns[k]['az'], max(suns[k]['el'], 5.0) if k != 'night' else 5.0, *f, shadows=False) for k in export.KEYS} - for k in export.KEYS: planes[k]['near'][..., 3] = np.maximum(planes[k]['near'][..., 3], 0.6) # the writer refuses a near plane with an empty column: the fixture's nearest ridge covers only 40 degrees, so its plane is lifted to cover the window - place = dict(id='synthetic', name='Synthetic', lat=48.0, lon=16.0, altitude_km=0.1, tz_h=2, tz_name='CEST', date='2026-09-23') - view = dict(look_az_deg=120.0, plane_h=ridge.H, px_per_deg_h=ridge.HPX_DEG, px_per_deg_v=ridge.PXDEG_V, horizon_row=100.0, sky_row=24.0, near_row=396.0) - return place, view, suns, planes -def _sized(h, w, seed=0, noise=False): - """the same plane for every key and name, h rows by w columns: a soft mid-grey ramp, or white noise when noise (a plane that does not compress)""" - rng = np.random.default_rng(seed); a = rng.random((h, w, 4)).astype(np.float32) if noise else np.dstack([np.full((h, w), 0.5), np.linspace(0, 1, w)[None].repeat(h, 0), np.full((h, w), 0.3), np.full((h, w), 0.4)]).astype(np.float32) - return {k: {n: export.encode(a) for n in export.PLANES} for k in export.KEYS} -def _refuses(out, place, view, suns, webp, why): - try: export.write_bake(out, place, view, suns, webp) - except ValueError: assert not os.path.exists(out), ('a refused bake left files', why) - else: assert False, why -def test_manifest_validates_bake_id_is_deterministic_and_luma_is_recorded(): - """Two independent bakes (the relight and the encode each run twice, not one plane set encoded twice) give the same bytes and bake_id; the manifest validates against the schema, plane w/h are what the written files decode to, frame pairs fit the budget, luma min <= max in 0..1 per frame, - the index points at the dir, a rebake replaces the place's old dir, and nothing but the dir and index.json is left in the output.""" - d = tempfile.mkdtemp() - try: - place, view, suns, planes = _fixture_bake(); webp = _encode_all(planes); again = _encode_all(_fixture_bake()[3]); assert webp == again, 'two independent bakes (relight and encode run twice) gave different plane bytes' - a = export.write_bake(d + '/a', place, view, suns, webp); b = export.write_bake(d + '/b', place, view, suns, again) - assert a.name == b.name and a.name == 'synthetic-' + export.bake_id(webp), (a.name, b.name); assert sorted(os.listdir(d + '/a')) == sorted(['index.json', a.name]), os.listdir(d + '/a') - m = json.load(open(a / 'manifest.json')); export.validate(m, export.SCHEMA['$defs']['manifest']); assert m['bake_id'] == a.name.split('-')[1] and [f['key'] for f in m['frames']] == list(export.KEYS) - assert m['view']['plane_w'] <= export.PLANE_W_MAX and m['view']['window_deg'] == 72 and m['view']['plane_w'] == ridge.WIN and m['view']['plane_h'] == ridge.H - for f in m['frames']: - assert 0 <= f['luma']['min'] <= f['luma']['max'] <= 1, f['luma']; assert f['bytes'] == sum(p['bytes'] for p in f['planes'].values()) - for p in f['planes'].values(): - assert (a / p['file']).stat().st_size == p['bytes'] and export.decode((a / p['file']).read_bytes()).shape[:2] == (p['h'], p['w']) == (ridge.H, ridge.WIN), p - assert max(m['frames'][i]['bytes'] + m['frames'][i + 1]['bytes'] for i in range(5)) <= export.PAIR_BYTES_MAX - idx = json.load(open(d + '/a/index.json')); export.validate(idx, export.SCHEMA['$defs']['index']); assert idx == dict(schema=1, default='synthetic', places={'synthetic': a.name}) - webp2 = {k: dict(v) for k, v in webp.items()}; webp2['noon']['far'] = export.encode(np.full((ridge.H, ridge.WIN, 4), 0.5, np.float32) * np.array([1, 1, 1, 2], np.float32)); c = export.write_bake(d + '/a', place, view, suns, webp2) - assert c.name != a.name and not a.exists() and json.load(open(d + '/a/index.json'))['places']['synthetic'] == c.name, 'the old dir of the place survives a rebake'; assert sorted(os.listdir(d + '/a')) == sorted(['index.json', c.name]) - bad = json.loads(json.dumps(m)); bad['frames'][0]['extra'] = 1 - try: export.validate(bad, export.SCHEMA['$defs']['manifest']) - except ValueError: pass - else: assert False, 'an extra key passed the schema' - committed = json.load(open(os.path.join(SRC, 'ridge-bake.schema.json'))); assert committed == json.loads(json.dumps(export.SCHEMA)), 'ridge-bake.schema.json is stale (export.py --write-schema)' - finally: shutil.rmtree(d) -def test_index_default_keeps_the_first_place_and_follows_an_explicit_default(): - """A second place leaves the index default at the first; an explicit default names the default; a default naming no baked place falls back to the place just baked.""" - d = tempfile.mkdtemp() - try: - place, view, suns, planes = _fixture_bake(); webp = _encode_all(planes); other = dict(place, id='other') - export.write_bake(d, place, view, suns, webp); export.write_bake(d, other, view, suns, webp); idx = json.load(open(d + '/index.json')); assert idx['default'] == 'synthetic' and sorted(idx['places']) == ['other', 'synthetic'], idx - export.write_bake(d, other, view, suns, webp, default='other'); assert json.load(open(d + '/index.json'))['default'] == 'other' - idx = json.load(open(d + '/index.json')); idx['default'] = 'gone'; open(d + '/index.json', 'w').write(json.dumps(idx)); export.write_bake(d, other, view, suns, webp); assert json.load(open(d + '/index.json'))['default'] == 'other' - finally: shutil.rmtree(d) -def test_bake_id_follows_the_plane_bytes_in_order(): - """bake_id = first 12 hex of sha256 over the plane bytes in key order, then far, mid, near: one changed byte in any plane changes it, swapping two planes changes it, and the value equals an independent hash.""" - import hashlib - w = {k: {n: bytes([i, j]) * 5 for j, n in enumerate(export.PLANES)} for i, k in enumerate(export.KEYS)}; want = hashlib.sha256(b''.join(w[k][n] for k in export.KEYS for n in ('far', 'mid', 'near'))).hexdigest()[:12] - assert export.bake_id(w) == want - for k in export.KEYS: - v = {kk: dict(vv) for kk, vv in w.items()}; v[k]['near'] = v[k]['near'] + b'x'; assert export.bake_id(v) != want, k - v = {kk: dict(vv) for kk, vv in w.items()}; v[k]['far'], v[k]['mid'] = v[k]['mid'], v[k]['far']; assert export.bake_id(v) != want, k -def test_budget_refuses_a_pair_above_the_cap_and_a_plane_above_the_width(): - """Any two ADJACENT frames above PAIR_BYTES_MAX are refused (a heavy frame next to a light one at the cap passes), as is a plane wider than the cap, and nothing is written.""" - cap = export.PAIR_BYTES_MAX; ok = [cap // 2] * 6; export.check_budget(ok, export.PLANE_W, ridge.H) - for i in range(6): - bad = [1000] * 6; bad[i] = cap // 2 + 1; bad[(i + 1) % 6] = cap // 2 + 1 - try: export.check_budget(bad, export.PLANE_W, ridge.H) - except ValueError: pass - else: assert False, ('adjacent pair over the cap accepted', i) - try: export.check_budget([400_001, 1, 1, 1, 1, 400_000], export.PLANE_W, ridge.H) - except ValueError as e: assert 'night + dawn' in str(e), str(e) - else: assert False, 'night + dawn summing 800001 were accepted' - export.check_budget([400_000, 1, 1, 1, 1, 400_000], export.PLANE_W, ridge.H) - export.check_budget([cap - 1000, 1000, cap - 1000, 1000, cap - 1000, 1000], export.PLANE_W, ridge.H) # non-adjacent heavy frames are fine - export.check_budget(ok, export.PLANE_W_MAX, 100) # exactly the cap is fine - try: export.check_budget(ok, export.PLANE_W_MAX + 1, ridge.H) - except ValueError: pass - else: assert False, 'a plane wider than the cap was accepted' - d = tempfile.mkdtemp() - try: - place, view, suns, planes = _fixture_bake(); webp = _encode_all(planes); fat = {k: dict(v) for k, v in webp.items()} - fat['dusk']['mid'] = export.encode(np.random.default_rng(1).random((ridge.H, ridge.WIN, 4)).astype(np.float32)); assert len(fat['dusk']['mid']) > cap // 2, 'the noise plane does not weigh enough' - _refuses(d + '/o', place, view, suns, fat, 'an over-budget bake was written') - finally: shutil.rmtree(d) -def test_size_refusals_read_the_written_planes_not_constants(): - """write_bake measures the planes it was handed: a 5000 px plane is refused (cap 4096) and so are planes that disagree on size or with the view's plane_h; a frame pair decoding above 20 MB is refused (700 rows) and at 650 rows is written; - the manifest carries the measured w and h (1000 px planes say w=1000), never a constant.""" - d = tempfile.mkdtemp() - try: - place, view, suns, _ = _fixture_bake(); v = lambda h: dict(view, plane_h=h) - _refuses(d + '/wide', place, v(100), suns, _sized(100, 5000), 'planes 5000 px wide were accepted') - mixed = _sized(100, 1000); mixed['noon']['mid'] = _sized(100, 900)['noon']['mid']; _refuses(d + '/mixed', place, v(100), suns, mixed, 'planes of two widths were accepted') - _refuses(d + '/rows', place, v(101), suns, _sized(100, 1000), 'planes whose height is not the view plane_h were accepted') - _refuses(d + '/heavy', place, v(700), suns, _sized(700, 1280), 'a frame pair decoding to 21.5 MB was accepted') - a = export.write_bake(d + '/ok', place, v(650), suns, _sized(650, 1280)); m = json.load(open(a / 'manifest.json')); assert m['view']['plane_w'] == 1280 and m['view']['plane_h'] == 650 - b = export.write_bake(d + '/narrow', place, v(100), suns, _sized(100, 1000)); m = json.load(open(b / 'manifest.json')) - assert m['view']['plane_w'] == 1000 and all(p['w'] == 1000 and p['h'] == 100 for f in m['frames'] for p in f['planes'].values()), m['view'] - finally: shutil.rmtree(d) -def test_the_shipped_manifest_carries_the_attribution_verbatim(): - """The Copernicus DEM licence obliges the credit line to ship with the planes: write_bake of the fixture bake (the shipped planes path) writes a manifest whose attribution is exactly the four pinned strings, each its own field: - credit_line, licence, dem_source and url (a blanked, shortened or swapped field fails by name).""" - d = tempfile.mkdtemp() - try: - place, view, suns, planes = _fixture_bake(); dest = export.write_bake(d, place, view, suns, _encode_all(planes)); a = json.load(open(dest / 'manifest.json'))['attribution'] - want = dict(credit_line='Contains modified Copernicus data: DEM © DLR e.V. 2010-2014 and © Airbus Defence and Space GmbH 2014-2018 provided under COPERNICUS by the European Union and ESA; all rights reserved.', - licence='Copernicus DEM licence: free, full and open access, with the credit line shown', dem_source='Copernicus DEM GLO-30 (30 m)', - url='https://spacedata.copernicus.eu/collections/copernicus-digital-elevation-model') - assert sorted(a) == sorted(want), sorted(a) - for k in want: assert a[k] == want[k], (k, a[k]) - finally: shutil.rmtree(d) -def _srgb_y(c): c = np.asarray(c, float); lin = np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4); return float(lin @ np.array([0.2126, 0.7152, 0.0722])) -def test_luma_range_is_the_far_mid_near_composite_over_a_grey_sky_where_coverage_is_at_least_half(): - """Hand-built 1x5 planes, none opaque anywhere (haze-as-alpha): px0 far white alpha 200 then near black alpha 128 (order matters, coverage .89); px1 far grey 128 alpha 128 (coverage .50, measured); px2 far white alpha 120 - (coverage .47, NOT measured: it would be the brightest); px3 far and mid black alpha 100 each (coverage .63, measured); px4 nothing. min and max equal the arithmetic composite over the 0.5 grey sky to 1e-6.""" - z = lambda: np.zeros((1, 5, 4), np.uint8); far, mid, near = z(), z(), z() - far[0, 0] = (255, 255, 255, 200); near[0, 0] = (0, 0, 0, 128); far[0, 1] = (128, 128, 128, 128); far[0, 2] = (255, 255, 255, 120); far[0, 3] = (0, 0, 0, 100); mid[0, 3] = (0, 0, 0, 100) - sky = np.full(3, 0.5); A, a, b = 200 / 255, 128 / 255, 100 / 255; p0 = (sky * (1 - A) + A) * (1 - a); p1 = sky * (1 - a) + 128 / 255 * a; p3 = sky * (1 - b) ** 2 - want = sorted(_srgb_y(p) for p in (p0, p1, p3)); lo, hi = export.luma_range([far, mid, near]); assert abs(lo - want[0]) < 1e-6 and abs(hi - want[-1]) < 1e-6, ((lo, hi), want) - try: export.luma_range([z(), z(), z()]) - except ValueError as e: assert 'coverage' in str(e), ('the refusal does not name the coverage', str(e)) - else: assert False, 'a frame with no covered pixel was measured' - z2 = export.decode(export.encode(np.full((8, 8, 4), 1.0, np.float32))); assert z2.shape == (8, 8, 4) and (z2[..., 3] == 255).all(), 'an everywhere-opaque plane must read back as RGBA' -def _rt_error(planes, back): - sky = export.SHEET_SKY; return np.abs(_over({n: planes[n] for n in export.PLANES}, sky) - _over({n: back[n].astype(np.float32) / 255 for n in export.PLANES}, sky)) * 255 -def test_encode_round_trips_the_shipped_planes_with_real_grain_and_the_default_reference(): - """The planes as shipped (default reference haze, the real grain, WebP quality 80) decode to the float planes: composited over a flat sky the round trip costs under 1.7/255 mean and 32/255 max (measured 1.47 and 28.6 on this hard-edged fixture; quality 50 measures 1.64 and 34, quality 5 measures 2.16 and 73), straight - colour (not premultiplied), alpha kept, rows not flipped; the real grain multiplies the colour exactly (the ratio to a grain-free bake is win(grain)).""" - r, L = _three_slot_layers(); paperf, grain, fmot = ridge.statics(); sun = (200.0, 40.0) - shipped = ridge.relight_planes(L, *sun, paperf, grain, fmot, shadows=False); flat = ridge.relight_planes(L, *sun, paperf, np.ones_like(grain), fmot, shadows=False) - back = {n: export.decode(export.encode(shipped[n])) for n in export.PLANES}; assert all(back[n].shape == shipped[n].shape for n in export.PLANES), 'decode changed the plane shape' - e = _rt_error(shipped, back); assert e.mean() < 1.7 and e.max() < 32.0, ('encode/decode round trip', e.mean(), e.max()) - for n in export.PLANES: assert abs(back[n][..., 3].astype(float) / 255 - shipped[n][..., 3]).max() < 0.1, ('alpha lost in the round trip', n) - g = ridge.win(grain); ok = (flat['near'][..., 3] > 0.2) & (flat['near'][..., :3].max(-1) * 1.03 < 1) - assert ok.sum() > 1000 and np.abs(shipped['near'][..., :3][ok] / flat['near'][..., :3][ok] - np.broadcast_to(g, ok.shape)[ok][:, None]).max() < 1e-3, 'the grain does not multiply the plane colour' -def test_default_reference_is_the_frames_own_haze_colour_and_shadows_darken_the_planes(): - """With no reference given the far plane over the frame's own haze colour equals the opaque relight of its group over that colour (<1 mean, 4 max in 1/255), not over the sky's horizon colour or a grey; cast shadows reach the planes: - a forced shadow of 1 darkens the far-group colour, a forced 0 equals shadows off.""" - r, L = _three_slot_layers(); paperf, grain, fmot = ridge.statics(); f1 = (paperf, np.ones_like(grain), fmot); sun = (200.0, 40.0); st = ridge.sun_state(*sun); hz = ridge.haze_colour(ridge.sky_hz(st, paperf)[1]) - far = ridge.relight_planes(L, *sun, *f1, shadows=False)['far']; sky = ridge.win(hz[None].astype(np.float32))[0] - img = _over(dict(far=far, mid=0 * far, near=0 * far), sky); want = ridge.win(ridge.relight(L[:1], *sun, *f1, shadows=False, flat=hz)); d = np.abs(img - want) * 255 - assert d.mean() < 1.0 and d.max() < 4.0, ('the default reference is not the frame haze colour', d.mean(), d.max()) - real = ridge.cast_shadows - try: - ridge.cast_shadows = lambda layers, az, el: [np.ones((3, W)) for _ in layers]; dark = ridge.relight_planes(L, *sun, *f1) - ridge.cast_shadows = lambda layers, az, el: [np.zeros((3, W)) for _ in layers]; lit = ridge.relight_planes(L, *sun, *f1) - finally: ridge.cast_shadows = real - off = ridge.relight_planes(L, *sun, *f1, shadows=False); body = off['near'][..., 3] > 0.5 - assert body.sum() > 1000 and dark['near'][..., :3][body].mean() < off['near'][..., :3][body].mean() - 0.02, 'cast shadows do not reach the planes' - assert all(np.abs(lit[n] - off[n]).max() < 1e-4 for n in export.PLANES), 'a zero shadow differs from shadows off' -def test_sun_set_keys_sit_at_their_elevations_and_hours(): - """The six keys on Vienna's preset date: noon is the day's highest sun, golden at a geometric elevation of 6.0 deg after it, dusk -2.5 deg, night -12.0 deg (the schema's night frame), dawn just above the horizon a quarter hour after sunrise, morning at 9.5 h local; - hours increase dawn < morning < noon < golden < dusk < night. Elevations are re-read from sun.solar, not from the keys: the keys are geometric (solar()[2]) and the manifest's el is the apparent one (solar()[1], refraction added).""" - import sun; from datetime import datetime, timedelta, timezone - p = ridge.load_preset('vienna'); s = export.sun_set(p, p['date']); assert list(s) == list(export.KEYS) - day = datetime.fromisoformat(p['date']).replace(tzinfo=timezone.utc); el = lambda h: sun.solar(day + timedelta(hours=h), p['lat'], p['lon'])[1] - geo = lambda h: sun.solar(day + timedelta(hours=h), p['lat'], p['lon'])[2] - for k in s: assert abs(el(s[k]['hour_utc']) - s[k]['el']) < 1e-9, k - assert abs(geo(s['golden']['hour_utc']) - 6.0) < 0.01 and abs(geo(s['dusk']['hour_utc']) + 2.5) < 0.01 and abs(geo(s['night']['hour_utc']) + 12.0) < 0.01 and abs(s['night']['el'] + 12.0) < 0.01, {k: v['el'] for k, v in s.items()} - assert max(el(h / 100) for h in range(0, 2400, 5)) - s['noon']['el'] < 0.01 and abs(s['morning']['hour_utc'] - (9.5 - p['tz_h'])) < 1e-9 - assert -0.833 < s['dawn']['el'] < 3.0, s['dawn']; hs = [s[k]['hour_utc'] for k in export.KEYS]; assert hs == sorted(hs), hs -def test_bake_place_manifest_fields_on_vienna(): - """bake_place on the real Vienna preset: altitude is the ground (km) without the eye height, horizon_row is the row where the elevation angle is 0 (inside the frame, between sky_row and near_row), px_per_deg_h is the ring's; - write_bake of its encoded planes gives finite luma for every frame, index default 'vienna', the full Copernicus credit line, and no temp dir left behind.""" - saved = ridge.P - try: - place, view, suns, planes = export.bake_place('vienna', relight_keys=export.KEYS); P = ridge.P; rowang = ridge.rowang; pxv = ridge.PXDEG_V - finally: ridge.P = saved - assert abs(place['altitude_km'] * 1000 - P['ground_m']) < 1e-6 and P['eye_m'] > 0 and place['id'] == 'vienna' and place['date'] == P['date'] and place['lat'] == P['lat'] and place['lon'] == P['lon'] - assert abs(view['sky_row'] - 0.04 * ridge.H) < 1e-9 and abs(view['near_row'] - 0.66 * ridge.H) < 1e-9, 'sky_row / near_row are rows (set_view anchors 4% and 66% of the frame)' - assert 0 < view['sky_row'] < view['horizon_row'] < view['near_row'] < ridge.H and abs(rowang[int(view['horizon_row'])]) <= 1.0 / pxv, (view, rowang[int(view['horizon_row'])]) - assert view['look_az_deg'] == P['az_center'] and view['plane_h'] == ridge.H and view['px_per_deg_h'] == ridge.HPX_DEG and view['px_per_deg_v'] == pxv - d = tempfile.mkdtemp() - try: - dest = export.write_bake(d, place, view, suns, _encode_all(planes)); m = json.load(open(dest / 'manifest.json')) - assert all(math.isfinite(f['luma'][k]) and 0 <= f['luma']['min'] < f['luma']['max'] <= 1 for f in m['frames'] for k in ('min', 'max')), [f['luma'] for f in m['frames']] - assert json.load(open(d + '/index.json'))['default'] == 'vienna' and sorted(os.listdir(d)) == sorted(['index.json', dest.name]), os.listdir(d) - c = m['attribution']['credit_line']; assert c.startswith('Contains modified Copernicus data') and 'Airbus Defence and Space' in c and 'ESA' in c and m['attribution']['licence'] and m['attribution']['url'].startswith('https://'), m['attribution'] - finally: shutil.rmtree(d) - -PLANES_TESTS = [v for k, v in sorted(globals().items()) if k.startswith('test_') and k not in _before] -TESTS = [v for k, v in sorted(globals().items()) if k.startswith('test_')] -if __name__ == '__main__': - fails = 0 - for t in TESTS: - try: t(); print('PASS', t.__name__) - except Exception as e: fails += 1; print('FAIL', t.__name__, '|', repr(e)[:300]) - print('failures:', fails); sys.exit(1 if fails else 0) 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/Bubble.tsx b/src/app/components/message/layout/Bubble.tsx index 6f4e94b0..bd2056b9 100644 --- a/src/app/components/message/layout/Bubble.tsx +++ b/src/app/components/message/layout/Bubble.tsx @@ -60,13 +60,13 @@ export const BubbleLayout = as<'div', BubbleLayoutProps>( {header &&
{header}
} {hideBubble ? ( children ) : ( - + { - const filePath = `${process.cwd()}/src/app/components/message/layout/layout.css.ts`; - 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(); -}; - -const declarations = (css: string, className: string): string[] => { - const rule = new RegExp(`^\\.layout_${className}__[\\w-]+ \\{\\n([^}]*)\\}`, 'm').exec(css); - expect(rule, `no .layout_${className} rule emitted`).toBeTruthy(); - return (rule as RegExpExecArray)[1].split('\n').map((l) => l.trim().replace(/;$/, '')); -}; - -describe('bubble rows shrink to their column', () => { - it.each([ - ['BubbleColumn', ['min-width: 0']], - ['BubbleContentRow', ['min-width: 0', 'max-width: 100%']], - ['BubbleContent', ['min-width: 0']], - ])('%s', async (name, expected) => { - const css = await emitted(); - expect(declarations(css, name)).toEqual(expect.arrayContaining(expected)); - }); - - it('BubbleContent keeps its 800px cap inside the column width', async () => { - const css = await emitted(); - expect(declarations(css, 'BubbleContent').join('\n')).toMatch(/max-width: min\(100%, 50rem\)/); - }); - - it('Bubble.tsx puts BubbleColumn on the column and BubbleContentRow on the row', () => { - const tsx = readFileSync( - `${process.cwd()}/src/app/components/message/layout/Bubble.tsx`, - 'utf8' - ); - expect(tsx).toMatch(/classNames\(css\.BubbleColumn,/); - expect(tsx).toMatch(//); - }); -}); diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index 39a40aa8..3037e66b 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -139,22 +139,8 @@ export const BubbleBefore = style({ minWidth: toRem(36), }); -// minWidth 0 down the column: a flex item never shrinks below its content (a reply quote's unbroken text), -// so without it a long quote pushes the column past the row, and a right-aligned row pushes it off the left edge. -export const BubbleColumn = style({ - minWidth: 0, -}); - -// The row around a bubble sizes to its content in a right-aligned (own) column, and a quote's nowrap text -// makes that content as wide as the quote: cap the row at the column so the bubble can shrink inside it. -export const BubbleContentRow = style({ - minWidth: 0, - maxWidth: '100%', -}); - export const BubbleContent = style({ - minWidth: 0, - maxWidth: `min(100%, ${toRem(800)})`, + maxWidth: toRem(800), padding: config.space.S200, backgroundColor: color.SurfaceVariant.Container, color: color.SurfaceVariant.OnContainer, @@ -299,10 +285,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 +295,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/message/pose/sentiment.test.ts b/src/app/components/message/pose/sentiment.test.ts deleted file mode 100644 index bc4a8c28..00000000 --- a/src/app/components/message/pose/sentiment.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { classifyPose, messagePose } from './sentiment'; - -describe('classifyPose', () => { - it.each([ - ['annoyed', 'ugh, this again'], - ['surprised', 'wow, no way'], - ['amused', 'hahaha that is funny'], - ['happy', 'thanks, great work'], - ['thinking', 'should we ship it?'], - ['neutral', 'Merged the branch.'], - ['neutral', 'sorry, that failed with an error'], - ])('%s', (label, text) => expect(classifyPose(text)).toBe(label)); - it('is neutral for empty or non-string text', () => { - expect(classifyPose('')).toBe('neutral'); - expect(classifyPose(undefined)).toBe('neutral'); - expect(classifyPose(5)).toBe('neutral'); - }); - it('is deterministic', () => { - expect(classifyPose('thanks?')).toBe(classifyPose('thanks?')); - }); -}); - -describe('messagePose', () => { - it('lets an explicit label win over the text', () => { - expect(messagePose('aqua', { body: 'thanks!', 'org.gamecult.pose': 'annoyed' })).toBe( - 'annoyed' - ); - }); - it('keeps an unknown explicit label neutral instead of classifying', () => { - expect(messagePose('aqua', { body: 'thanks!', 'org.gamecult.pose': 'conduct' })).toBe( - 'neutral' - ); - }); - it('classifies when there is no label, and never outputs a signature label', () => { - expect(messagePose('aqua', { body: 'thanks!' })).toBe('happy'); - expect(messagePose('kiko', { body: 'sorry' })).toBe('neutral'); - expect(messagePose('aqua', {})).toBe('neutral'); - }); -}); diff --git a/src/app/components/message/pose/sentiment.ts b/src/app/components/message/pose/sentiment.ts deleted file mode 100644 index bd685a07..00000000 --- a/src/app/components/message/pose/sentiment.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { POSE_KEY, NEUTRAL, resolvePose } from './poses'; - -// catalogue.json beside this file is a vendored copy of /opt/commons/cast-art/poses/catalogue.json -// (npm run sync:poses); nothing else publishes it to the browser. -// Local sentiment -> pose label. A lexicon over the message text, no network, no model: the -// same text always gives the same pose. Rules are tried in order and the first hit wins, so a -// message that says "wow" and "thanks" reads as surprised. Only the shared labels (identical art -// for all eight Minds, catalogue.json) are ever output; signature labels are explicit-only. -const RULES: [string, RegExp][] = [ - ['annoyed', /\b(annoy\w*|ugh+|argh+|frustrat\w*|again\?|why won'?t|stop it)\b/i], - ['surprised', /\b(wow|whoa|woah|omg|no way|unexpected\w*|surpris\w*|wait,? what)\b|[?!]{2,}/i], - [ - 'determined', - /\b(let'?s (go|do|fix|ship)|on it|i'?ll (fix|do|handle)|will do|fixing|resolved?)\b/i, - ], - ['amused', /\b(ha(ha)+|lol|lmao|rofl|funny|hilarious)\b|😂|🤣/i], - [ - 'happy', - /\b(thanks?|thank you|thx|great|awesome|nice|love|perfect|excellent|yay|congrats)\b|🎉|😊/i, - ], - ['thinking', /\?\s*$|\b(hmm+|maybe|perhaps|i wonder|not sure|how (do|does|about)|what if)\b/i], -]; - -export const classifyPose = (text: unknown): string => { - if (typeof text !== 'string') return NEUTRAL; - const hit = RULES.find(([, re]) => re.test(text)); - return hit ? hit[0] : NEUTRAL; -}; - -// An explicit org.gamecult.pose label always wins, even when it is unknown (then neutral). -// Only events with no label are read for sentiment. -export const messagePose = (mind: string, content: Record): string => - content[POSE_KEY] !== undefined - ? resolvePose(mind, content[POSE_KEY]) - : resolvePose(mind, classifyPose(content.body)); diff --git a/src/app/components/nav/NavGutter.tsx b/src/app/components/nav/NavGutter.tsx deleted file mode 100644 index c0a6e50e..00000000 --- a/src/app/components/nav/NavGutter.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import React, { ReactNode } from 'react'; -import * as css from './styles.css'; - -/** The fixed-width first column of a navigator row or section header; its content is centred in it. */ -export function NavGutter({ children }: { children: ReactNode }) { - return {children}; -} - -/** One nesting step: everything inside is indented by the same constant. */ -export function NavLevel({ children }: { children: ReactNode }) { - return
{children}
; -} diff --git a/src/app/components/nav/index.ts b/src/app/components/nav/index.ts index 2ed29601..12267446 100644 --- a/src/app/components/nav/index.ts +++ b/src/app/components/nav/index.ts @@ -4,4 +4,3 @@ export * from './NavEmptyLayout'; export * from './NavItem'; export * from './NavItemContent'; export * from './NavItemOptions'; -export * from './NavGutter'; diff --git a/src/app/components/nav/styles.css.ts b/src/app/components/nav/styles.css.ts index 5156ed41..62e3928e 100644 --- a/src/app/components/nav/styles.css.ts +++ b/src/app/components/nav/styles.css.ts @@ -13,31 +13,6 @@ const SCRIM_HOVER = '14%'; const SCRIM_SELECTED = '22%'; const SCRIM_PRESSED = '48%'; -// The navigator's one column grid. Every row, section header and join row begins with a gutter of -// the same width, with its chevron, room icon or avatar centred in it, so every label starts at -// the same x for its depth; each nesting level (NavLevel) indents by one constant step. -const GUTTER = toRem(32); -const LEVEL_STEP = toRem(16); - -export const NavGutter = style({ - width: GUTTER, - minWidth: GUTTER, - flexShrink: 0, - display: 'flex', - alignItems: 'center', - justifyContent: 'center', -}); - -export const NavLevel = style({ - paddingLeft: LEVEL_STEP, -}); - -// Chevrons and room icons are matched in measured ink (a room glyph is about 1.7x a chevron's; unread rooms stay far above), so a foldout is not outweighed by its rooms. -export const NAV_ICON_REST_OPACITY = 0.9; -// A room's glyph is bigger and denser than the chevron, so it rests lighter to read as the same weight. -export const NAV_ROOM_ICON_REST_OPACITY = 0.26; -export const NAV_ICON_UNREAD_OPACITY = 0.6; - export const NavCategory = style([ DefaultReset, { @@ -47,16 +22,7 @@ export const NavCategory = style([ export const NavCategoryHeader = style({ gap: config.space.S100, - selectors: { - // The header's own side padding would shift its gutter off the rows': the section button - // carries the same left inset as NavItemContent instead. - '&&': { 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 +87,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: { @@ -272,18 +236,22 @@ globalStyle(`:root[data-scene] ${NavItemBase} svg, :root[data-scene] ${NavItemBa }, }); -// The Join action is a plain underlined word on every screen, not a pill. +// The Join action on a phone is a plain underlined word, not a pill. export const NavAction = style({ - selectors: { - '&&': { - backgroundColor: 'transparent', - boxShadow: 'none', - border: 'none', - borderRadius: 0, - minWidth: 0, - textDecoration: 'underline', - textUnderlineOffset: toRem(4), - flexShrink: 0, + '@media': { + [PHONE]: { + selectors: { + '&&': { + backgroundColor: 'transparent', + boxShadow: 'none', + border: 'none', + borderRadius: 0, + minWidth: 0, + textDecoration: 'underline', + textUnderlineOffset: toRem(4), + flexShrink: 0, + }, + }, }, }, }); 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/unread-badge/UnreadBadge.tsx b/src/app/components/unread-badge/UnreadBadge.tsx index 00c7fa2f..f8bfefa3 100644 --- a/src/app/components/unread-badge/UnreadBadge.tsx +++ b/src/app/components/unread-badge/UnreadBadge.tsx @@ -22,9 +22,7 @@ export function UnreadBadge({ highlight, count }: UnreadBadgeProps) { 0 ? '400' : '200'} - // A mention and the bare unread dot are solid (a soft dot measured 1.6:1 against the ground); a plain unread - // count is a soft tint, so a row's count is never its loudest part. - fill={highlight || count === 0 ? 'Solid' : 'Soft'} + fill="Solid" radii="Pill" outlined={false} > diff --git a/src/app/components/upload-card/CompactUploadCardRenderer.tsx b/src/app/components/upload-card/CompactUploadCardRenderer.tsx index 5fe73a10..b9bada71 100644 --- a/src/app/components/upload-card/CompactUploadCardRenderer.tsx +++ b/src/app/components/upload-card/CompactUploadCardRenderer.tsx @@ -6,8 +6,6 @@ import { useMatrixClient } from '../../hooks/useMatrixClient'; import { TUploadContent } from '../../utils/matrix'; import { bytesToSize, getFileTypeIcon } from '../../utils/common'; import { useMediaConfig } from '../../hooks/useMediaConfig'; -import { UploadFailureNote } from './UploadFailureNote'; -import { PhoneHitSpread28 } from '../../styles/PhoneHit.css'; type CompactUploadCardRendererProps = { isEncrypted?: boolean; @@ -65,7 +63,6 @@ export function CompactUploadCardRenderer({ )} - + {upload.error.message} )} {upload.status === UploadStatus.Idle && fileSizeExceeded && ( diff --git a/src/app/components/upload-card/UploadCardRenderer.tsx b/src/app/components/upload-card/UploadCardRenderer.tsx index e1d12843..f5bc68e0 100644 --- a/src/app/components/upload-card/UploadCardRenderer.tsx +++ b/src/app/components/upload-card/UploadCardRenderer.tsx @@ -12,8 +12,6 @@ import { } from '../../state/room/roomInputDrafts'; import { useObjectURL } from '../../hooks/useObjectURL'; import { useMediaConfig } from '../../hooks/useMediaConfig'; -import { UploadFailureNote } from './UploadFailureNote'; -import { PhoneHitSpread28 } from '../../styles/PhoneHit.css'; type PreviewImageProps = { fileItem: TUploadItem; @@ -162,7 +160,6 @@ export function UploadCardRenderer({ )} - + {upload.error.message} )} {upload.status === UploadStatus.Idle && fileSizeExceeded && ( diff --git a/src/app/components/upload-card/UploadFailureNote.test.tsx b/src/app/components/upload-card/UploadFailureNote.test.tsx deleted file mode 100644 index c2f917ec..00000000 --- a/src/app/components/upload-card/UploadFailureNote.test.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { describe, expect, it } from 'vitest'; -import { uploadFailure } from '../../utils/matrix'; -import { UploadFailureNote } from './UploadFailureNote'; - -const text = (html: string) => - html - .replace(/<[^>]*>/g, ' ') - .replace(/\s+/g, ' ') - .trim(); - -describe('the failed upload card', () => { - it('leads with the reason and the next step, and keeps the diagnostics behind Details', async () => { - const error = await uploadFailure(new DOMException('', 'AbortError'), new Blob(['abcd']), { - startedAt: Date.now(), - }); - const html = renderToStaticMarkup(); - const before = html.slice(0, html.indexOf(']*>.*Details.*<\/details>/s); - expect(text(html.slice(html.indexOf(' { - const html = renderToStaticMarkup(); - expect(text(html)).toContain('Upload failed. Send again'); - expect(text(html)).toContain('boom'); - }); -}); diff --git a/src/app/components/upload-card/UploadFailureNote.tsx b/src/app/components/upload-card/UploadFailureNote.tsx deleted file mode 100644 index df4498ba..00000000 --- a/src/app/components/upload-card/UploadFailureNote.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import React from 'react'; -import { Text } from 'folds'; -import { uploadFailureText } from '../../utils/uploadReason'; - -/** A failed upload as the card shows it: the reason and what to do, the diagnostic text one tap away. */ -export function UploadFailureNote({ error }: { error: Error }) { - return ( - <> - {uploadFailureText(error)} -
- - - Details - - - - {error.message} - -
- - ); -} diff --git a/src/app/components/url-preview/UrlPreviewCard.tsx b/src/app/components/url-preview/UrlPreviewCard.tsx index 5d40e10c..ee8967af 100644 --- a/src/app/components/url-preview/UrlPreviewCard.tsx +++ b/src/app/components/url-preview/UrlPreviewCard.tsx @@ -1,4 +1,3 @@ -import classNames from 'classnames'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { IPreviewUrlResponse } from 'matrix-js-sdk'; import { Box, Icon, IconButton, Icons, Scroll, Spinner, Text, as, color, config } from 'folds'; @@ -11,7 +10,6 @@ import { useIntersectionObserver, } from '../../hooks/useIntersectionObserver'; import * as css from './UrlPreviewCard.css'; -import { PhoneHitInPlace } from '../../styles/PhoneHit.css'; import { tryDecodeURIComponent } from '../../utils/dom'; import { mxcUrlToHttp } from '../../utils/matrix'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; @@ -183,10 +181,7 @@ export const UrlPreviewHolder = as<'div'>(({ 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/AlbumView.css.ts b/src/app/features/albums/AlbumView.css.ts index d60dba43..c4f4fc79 100644 --- a/src/app/features/albums/AlbumView.css.ts +++ b/src/app/features/albums/AlbumView.css.ts @@ -49,23 +49,3 @@ export const FilesWide = style({ }); export const Dim = style({ opacity: config.opacity.P300 }); - -/** The share panel: idle it is one row; the title, names and Send mount only once files are picked. */ -export const SharePanel = style({ - display: 'flex', - flexDirection: 'column', - gap: config.space.S200, -}); - -export const ShareTitle = style({ - all: 'unset', - boxSizing: 'border-box', - minHeight: 44, - width: '100%', - borderBottom: `1px solid ${color.Background.ContainerLine}`, - color: color.Background.OnContainer, - ':focus-visible': { borderBottomColor: color.Background.OnContainer }, -}); - -/** Picked names and send notes wrap, even a long unbroken name. */ -export const ShareNames = style({ overflowWrap: 'anywhere' }); diff --git a/src/app/features/albums/AlbumView.tsx b/src/app/features/albums/AlbumView.tsx index f1a7f01a..28e0c477 100644 --- a/src/app/features/albums/AlbumView.tsx +++ b/src/app/features/albums/AlbumView.tsx @@ -14,17 +14,7 @@ import { markAsRead } from '../../utils/notifications'; import { getBlobSafeMimeType } from '../../utils/mimeTypes'; import { timeDayMonYear } from '../../utils/time'; import { getMemberDisplayName } from '../../utils/room'; -import { - Album, - AlbumFile, - TimelineEvent, - lastEvent, - looseFiles, - looseNoteFor, -} from './buildAlbums'; -import { folderOf, listFolder, pathLabel } from './albumFolders'; -import { FileReadBy } from './FileReadBy'; -import { ShareSet, useShareEnabled } from './ShareSet'; +import { Album, AlbumFile } from './buildAlbums'; import { useAlbums } from './useAlbums'; import * as css from './AlbumView.css'; @@ -57,7 +47,6 @@ function FileRow({ room, file, author }: FileRowProps) { {file.name} - {audio ? ( } /> ) : ( @@ -75,26 +64,6 @@ function FileRow({ room, file, author }: FileRowProps) { ); } -/** A file, and under it a row that unfolds the versions it supersedes. */ -function VersionedFileRow({ room, file, author }: FileRowProps) { - const [shown, setShown] = useState(false); - const n = file.earlier.length; - return ( - <> - - {n > 0 && ( - - )} - {shown && - file.earlier.map((e) => )} - - ); -} - const countLabel = (n: number) => `${n} ${n === 1 ? 'file' : 'files'}`; /** The zone never mounts RoomTimeline, so it sends the read receipt the timeline would. */ @@ -113,51 +82,15 @@ function useMarkZoneRead(room: Room) { }, [mx, room, hideActivity]); } -/** The file events in the room's live timeline that no album thread holds, kept current as it syncs. */ -function useLooseFiles(room: Room): AlbumFile[] { - const [, bump] = useState(0); - useEffect(() => { - const on = () => bump((n) => n + 1); - room.on(RoomEvent.Timeline, on); - room.on(RoomEvent.Redaction, on); - return () => { - room.off(RoomEvent.Timeline, on); - room.off(RoomEvent.Redaction, on); - }; - }, [room]); - const events: TimelineEvent[] = room - .getLiveTimeline() - .getEvents() - .map((e) => ({ - event_id: e.getId(), - type: e.getType(), - sender: e.getSender(), - origin_server_ts: e.getTs(), - content: e.getContent(), - unsigned: e.getUnsigned() as Record, - threadRoot: e.threadRootId, - })); - return looseFiles(events); -} - type AlbumViewProps = { room: Room; onTimeline: () => void }; export function AlbumView({ room, onTimeline }: AlbumViewProps) { useMarkZoneRead(room); - const { loading, error, albums, truncated } = useAlbums(room); + const { loading, error, albums } = useAlbums(room); const [dateFormat] = useSetting(settingsAtom, 'dateFormatString'); const [openId, setOpenId] = useState(); - const [path, setPath] = useState([]); const open: Album | undefined = albums.find((a) => a.id === openId); const authorOf = (a: Album) => getMemberDisplayName(room, a.author) ?? a.author; const encrypted = room.hasEncryptionStateEvent(); - const canShare = useShareEnabled(room); - const loose = useLooseFiles(room); - const looseNote = looseNoteFor(loose, { - open: !!open, - encrypted, - loading, - error: !!error, - }); let body: React.ReactNode; if (loading) body = Loading albums…; @@ -166,98 +99,39 @@ export function AlbumView({ room, onTimeline }: AlbumViewProps) { const images = open.files.length > 0 && open.files.every((f) => f.kind === 'image'); body = ( <> - {open.title} {authorOf(open)} · {countLabel(open.count)} · {timeDayMonYear(open.ts, dateFormat)} {open.count === 0 && This album has no files.} - {canShare && ( - - )}
{open.files.map((f) => ( - + ))}
); } else if (encrypted) body = This zone is encrypted, so its albums cannot be listed here.; - else if (albums.length === 0 && path.length === 0) body = No albums yet.; + else if (albums.length === 0) body = No albums yet.; else { - const here = listFolder(albums, path); - body = ( - <> - {path.length > 0 && ( - - )} - {path.length > 0 && {pathLabel(path)}} - {here.folders.map((f) => ( - - ))} - {here.albums.map(({ album: a, leaf }) => ( - - ))} - {here.folders.length === 0 && here.albums.length === 0 && Nothing here.} - {truncated && ( - - The list is cut short: some albums or files are not loaded. - - )} - - ); + body = albums.map((a) => ( + + )); } return (
{body} - {looseNote && ( - - {looseNote} - - )} - {/* Stays mounted while an album is open, so a running send keeps its progress. */} - {canShare && ( - - )} diff --git a/src/app/features/albums/FileReadBy.tsx b/src/app/features/albums/FileReadBy.tsx deleted file mode 100644 index 28f9205c..00000000 --- a/src/app/features/albums/FileReadBy.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import React from 'react'; -import { MatrixEvent, Room } from 'matrix-js-sdk'; -import { Text } from 'folds'; -import { getMemberDisplayName } from '../../utils/room'; -import { FileReader, readLine } from './fileReceipt'; -import { useFileReaders } from './useFileReaders'; -import * as css from './AlbumView.css'; - -type FileReadByProps = { - room: Room; - fileId: string; - content: Record; - /** Receipts already read elsewhere (an album's thread replies): merged with the timeline's, a Mind once. */ - also?: FileReader[]; -}; -/** "fetched for " under a file: the Minds whose verified receipts are in the room's timeline. Nothing when none. */ -export function FileReadBy({ room, fileId, content, also = [] }: FileReadByProps) { - const seen = useFileReaders(room, fileId, content); - const readers = [...new Map([...also, ...seen].map((r) => [r.sender, r])).values()].sort( - (a, b) => a.ts - b.ts - ); - if (readers.length === 0) return null; - return ( - - {readLine(readers, (id) => getMemberDisplayName(room, id) ?? id)} - - ); -} - -/** A timeline message that is a file: audio, image, video or file. */ -export const isFileMessage = (mEvent: MatrixEvent): boolean => - ['m.audio', 'm.image', 'm.video', 'm.file'].includes(mEvent.getContent().msgtype); diff --git a/src/app/features/albums/ShareSet.folder.test.tsx b/src/app/features/albums/ShareSet.folder.test.tsx deleted file mode 100644 index abb8f2bb..00000000 --- a/src/app/features/albums/ShareSet.folder.test.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import React from 'react'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { MatrixClientProvider } from '../../hooks/useMatrixClient'; -import { MediaConfig, MediaConfigProvider } from '../../hooks/useMediaConfig'; - -// ShareSet hands its folder to the title the send lands under. The controller is held still and its -// `run` captured, and sendShareSet records the title it was given, so the path from the `folder` -// prop to the album root's title is read without a homeserver or a DOM. -const captured = vi.hoisted(() => ({ - run: undefined as undefined | ((files: unknown[], title: string, onFile: () => void) => unknown), - maxBytes: undefined as undefined | (() => number | undefined), - titles: [] as string[], -})); - -vi.mock('./shareController', () => ({ - createShareController: (deps: { - run: NonNullable; - maxBytes: NonNullable; - }) => { - captured.run = deps.run; - captured.maxBytes = deps.maxBytes; - return { - subscribe: () => () => undefined, - getState: () => ({ files: [], title: '', phase: { kind: 'idle' }, epoch: 0 }), - pick: () => undefined, - setTitle: () => undefined, - send: () => undefined, - cancel: () => undefined, - }; - }, -})); -vi.mock('./shareSet', async (importOriginal) => ({ - ...(await importOriginal()), - sendShareSet: async (_port: unknown, into: string | { albumId: string; lastId: string }) => { - captured.titles.push(typeof into === 'string' ? into : `into:${into.albumId}@${into.lastId}`); - return { albumId: '$a', sent: 0, failed: [], failedAt: [] }; - }, -})); -vi.mock('react', async (importOriginal) => { - const actual = await importOriginal(); - // The server renderer has no snapshot for an external store; read the client one. - const useSyncExternalStore = (_sub: unknown, get: () => unknown) => get(); - return { ...actual, default: { ...actual, useSyncExternalStore }, useSyncExternalStore }; -}); - -// eslint-disable-next-line import/first -import { ShareSet } from './ShareSet'; - -const timeline: unknown[] = []; -const ev = (id: string, ts: number, threadRootId?: string, status: string | null = null) => ({ - getId: () => id, - getTs: () => ts, - threadRootId, - status, -}); -const mount = ( - folder?: string[], - into?: { albumId: string; lastId: string; lastTs: number }, - mediaConfig: MediaConfig = {} -) => { - const room = { - roomId: '!r:x', - hasEncryptionStateEvent: () => false, - getLiveTimeline: () => ({ getEvents: () => timeline }), - } as never; - renderToStaticMarkup( - - ({ event_id: '$e' }) } as never}> - - - - - - ); -}; -const send = async (typed: string) => { - await captured.run?.([], typed, () => undefined); - return captured.titles.at(-1); -}; - -describe('ShareSet: the folder reaches the album title', () => { - beforeEach(() => { - captured.run = undefined; - captured.titles.length = 0; - timeline.length = 0; - }); - - it('at the top, the typed title goes as typed', async () => { - mount(); - expect(await send('Poses')).toBe('Poses'); - }); - - it('inside a folder, the path prefixes the typed title', async () => { - mount(['cast-art', 'poses']); - expect(await send('Set 2')).toBe('cast-art/poses/Set 2'); - }); - - it('an empty typed title falls back to the default, still under the folder', async () => { - mount(['handoff']); - expect(await send(' ')).toBe('handoff/Shared files'); - }); - - it('a typed title containing a slash goes deeper', async () => { - mount(['a']); - expect(await send('b/c')).toBe('a/b/c'); - }); - - it('an existing album takes the files instead of a title, whatever the folder', async () => { - mount(['a'], { albumId: '$alb', lastId: '$last', lastTs: 5 }); - expect(await send('ignored')).toBe('into:$alb@$last'); - }); - - it('the freshest sent event of that thread in the room timeline is where the files thread after', async () => { - timeline.push( - ev('$other', 99, '$else'), - ev('$local', 98, '$alb', 'sending'), - ev('$new', 50, '$alb'), - ev('$sentNotEchoed', 60, '$alb', 'sent'), - ev('$failed', 70, '$alb', 'not_sent') - ); - mount(['a'], { albumId: '$alb', lastId: '$last', lastTs: 5 }); - expect(await send('x')).toBe('into:$alb@$sentNotEchoed'); - }); -}); - -describe('ShareSet: the homeserver upload cap reaches the controller', () => { - it('is the server-stated m.upload.size, and none when the server states none', () => { - mount([], undefined, { 'm.upload.size': 52428800 }); - expect(captured.maxBytes?.()).toBe(52428800); - mount([], undefined, {}); - expect(captured.maxBytes?.()).toBeUndefined(); - // A cap that is not a positive number is no cap, never "refuse everything". - [0, -1, Number.NaN, '50' as unknown as number].forEach((bad) => { - mount([], undefined, { 'm.upload.size': bad }); - expect(captured.maxBytes?.()).toBeUndefined(); - }); - }); -}); diff --git a/src/app/features/albums/ShareSet.test.tsx b/src/app/features/albums/ShareSet.test.tsx deleted file mode 100644 index b3da7a3d..00000000 --- a/src/app/features/albums/ShareSet.test.tsx +++ /dev/null @@ -1,123 +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> = {}, adding = false) => - 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)).toContain('Stop'); - 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('adding to an existing album', () => { - it('the idle row says Add files', () => { - expect(text(view({}, true))).toBe('Add files ›'); - }); - - it('picked: no title field, the names, Send and Cancel', () => { - const html = view({ files: [file('a.ogg')] }, true); - expect(html).not.toContain('aria-label="Album title"'); - expect(text(html)).toBe('a.ogg Send 1 file Cancel'); - }); -}); - -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 deleted file mode 100644 index b7f26e7e..00000000 --- a/src/app/features/albums/ShareSet.tsx +++ /dev/null @@ -1,210 +0,0 @@ -import React, { useRef, useState, useSyncExternalStore } from 'react'; -import { Text } from 'folds'; -import { useQueryClient } from '@tanstack/react-query'; -import { EventStatus, Room } from 'matrix-js-sdk'; -import { useMatrixClient } from '../../hooks/useMatrixClient'; -import { useClientConfig } from '../../hooks/useClientConfig'; -import { useMediaConfig } from '../../hooks/useMediaConfig'; -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 { titleIn } from './albumFolders'; -import { DEFAULT_TITLE, ExistingAlbum, freshest, sendShareSet, ThreadEvent } 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()); - -type ShareViewProps = { - state: ShareState; - onPickClick: () => void; - onPicked: (files: File[]) => void; - onTitle: (title: string) => void; - onSend: () => void; - onCancel: () => void; - onStop: () => void; - /** Adding to an existing album: the files join it, so there is no title to ask for. */ - adding?: boolean; - inputRef?: React.Ref; -}; - -/** - * 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. - */ -export function ShareSetView({ - state, - onPickClick, - onPicked, - onTitle, - onSend, - onCancel, - onStop, - adding, - inputRef, -}: ShareViewProps) { - const { files, title, phase, epoch } = state; - return ( -
- onPicked(Array.from(e.target.files ?? []))} - /> - {files.length === 0 && phase.kind !== 'sending' && ( - - )} - {phase.kind === 'done' && ( - - {phase.text} - - )} - {files.length > 0 && ( - <> - {!adding && ( - onTitle(e.target.value)} - /> - )} - - {files.map((f) => f.name).join(' · ')} - - {phase.kind === 'sending' ? ( - <> - - Sending {phase.done} of {files.length}… - - - - ) : ( - <> - - - - )} - - )} -
- ); -} - -/** The sent events in the room's live timeline, as the thread view of them `freshest` reads. */ -const threadEvents = (room: Room): ThreadEvent[] => - room - .getLiveTimeline() - .getEvents() - .filter((e) => (!e.status || e.status === EventStatus.SENT) && !!e.getId()) - .map((e) => ({ id: e.getId() as string, ts: e.getTs(), threadRoot: e.threadRootId })); - -type ShareSetProps = { - room: Room; - /** The path a new album lands in: its title is prefixed by it (docs/commons-files-desk.md). */ - folder?: string[]; - /** Add the files to this album instead of making one. */ - into?: ExistingAlbum; -}; -export function ShareSet({ room, folder = [], into }: ShareSetProps) { - const mx = useMatrixClient(); - const queryClient = useQueryClient(); - const mediaConfig = useMediaConfig(); - 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, folder, into, mediaConfig }); - live.current = { mx, room, queryClient, folder, into, mediaConfig }; - const [controller]: [ShareController, unknown] = useState(() => - createShareController({ - roomId: room.roomId, - isEncrypted: () => live.current.room.hasEncryptionStateEvent(), - maxBytes: () => { - const cap = live.current.mediaConfig['m.upload.size']; - return typeof cap === 'number' && Number.isFinite(cap) && cap > 0 ? cap : undefined; - }, - onSent: () => - live.current.queryClient.invalidateQueries({ queryKey: ['albums', room.roomId] }), - run: (files, title, onFile, signal) => - sendShareSet( - { - signal, - 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) => { - const { mx: client } = live.current; - let upload: Promise | undefined; - const abort = () => upload && client.cancelUpload(upload as never); - signal.addEventListener('abort', abort, { once: true }); - uploadContent(client, file, { - onPromise: (p) => { - upload = p; - if (signal.aborted) abort(); - }, - 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, - }, - live.current.into - ? freshest(live.current.into, threadEvents(live.current.room)) - : titleIn(live.current.folder, title, DEFAULT_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} - onStop={controller.stop} - adding={!!into} - /> - ); -} diff --git a/src/app/features/albums/albumFolders.test.ts b/src/app/features/albums/albumFolders.test.ts deleted file mode 100644 index 9a9be53e..00000000 --- a/src/app/features/albums/albumFolders.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { buildAlbums } from './buildAlbums'; -import type { Album, ZoneEvent } from './buildAlbums'; -import { folderOf, listFolder, splitPath, titleIn } from './albumFolders'; -import { DEFAULT_TITLE, sendShareSet } from './shareSet'; - -const album = (id: string, title: string, ts = 1): Album => ({ - id, - title, - author: '@a:x', - ts, - files: [], - count: 0, -}); -const names = (l: ReturnType) => ({ - folders: l.folders.map((f) => `${f.name}:${f.albums}`), - albums: l.albums.map((a) => `${a.album.id}=${a.leaf}`), -}); - -describe('splitPath', () => { - it('drops empty segments and padding; dots are names, not navigation', () => { - expect(splitPath(' a // b /')).toEqual(['a', 'b']); - expect(splitPath('../x')).toEqual(['..', 'x']); - expect(splitPath('///')).toEqual([]); - }); -}); - -describe('listFolder', () => { - const all = [ - album('$1', 'handoff/2026-11/notes', 10), - album('$2', 'handoff/2026-11/plan', 30), - album('$3', 'handoff/readme', 20), - album('$4', 'cast-art', 5), - album('$5', 'Plain album', 40), - ]; - - it('root: first segments become folders (counted below, newest first); single-segment titles are albums', () => { - expect(names(listFolder(all, []))).toEqual({ - folders: ['handoff:3'], - albums: ['$5=Plain album', '$4=cast-art'], - }); - }); - - it('nested: a folder shows its sub-folders and its own albums under their leaf names', () => { - expect(names(listFolder(all, ['handoff']))).toEqual({ - folders: ['2026-11:2'], - albums: ['$3=readme'], - }); - expect(names(listFolder(all, ['handoff', '2026-11']))).toEqual({ - folders: [], - albums: ['$2=plan', '$1=notes'], - }); - }); - - it('an album titled exactly like a folder is not hidden by it', () => { - const l = listFolder([album('$a', 'x'), album('$b', 'x/y')], []); - expect(names(l)).toEqual({ folders: ['x:1'], albums: ['$a=x'] }); - expect(names(listFolder([album('$a', 'x'), album('$b', 'x/y')], ['x']))).toEqual({ - folders: [], - albums: ['$b=y'], - }); - }); - - it('a path that exists nowhere lists nothing; the prefix match is by whole segment', () => { - expect(names(listFolder(all, ['hand']))).toEqual({ folders: [], albums: [] }); - }); - - it('an untitled or all-slash album stays at the root, nowhere else', () => { - const odd = [album('$u', '///')]; - expect(names(listFolder(odd, []))).toEqual({ folders: [], albums: ['$u=///'] }); - expect(names(listFolder(odd, ['x']))).toEqual({ folders: [], albums: [] }); - expect(names(listFolder([album('$w', ' ')], []))).toEqual({ - folders: [], - albums: ['$w=Untitled album'], - }); - }); - - it('server-supplied names like "constructor" are folders, not prototype hits', () => { - expect(names(listFolder([album('$c', 'constructor/a')], []))).toEqual({ - folders: ['constructor:1'], - albums: [], - }); - }); -}); - -describe('titleIn and folderOf', () => { - it('prefixes the folder, keeps a typed sub-path, falls back to the default title', () => { - expect(titleIn([], '', DEFAULT_TITLE)).toBe(DEFAULT_TITLE); - expect(titleIn([], ' Mine ', DEFAULT_TITLE)).toBe('Mine'); - expect(titleIn(['uploads', 'me'], '', DEFAULT_TITLE)).toBe(`uploads/me/${DEFAULT_TITLE}`); - expect(titleIn(['uploads'], 'a/b', DEFAULT_TITLE)).toBe('uploads/a/b'); - }); - - it('folderOf is the title path without its leaf', () => { - expect(folderOf(album('$', 'a/b/c'))).toEqual(['a', 'b']); - expect(folderOf(album('$', 'c'))).toEqual([]); - }); -}); - -describe('a share into a folder, read back as the viewer reads it', () => { - it('posts a root titled under the folder, and buildAlbums + listFolder put it in that folder', async () => { - const sent: Record[] = []; - const res = await sendShareSet( - { - send: async (c) => { - sent.push(c); - return `$e${sent.length}`; - }, - contentOf: async (f) => ({ msgtype: 'm.file', body: f.name, filename: f.name }), - }, - titleIn(['uploads', 'meta'], 'Photos', DEFAULT_TITLE), - [{ name: 'a.png', mimeType: 'image/png' }] - ); - expect(res.sent).toBe(1); - expect(sent[0]).toEqual({ msgtype: 'm.text', body: 'uploads/meta/Photos' }); - - const asEvent = (c: Record, id: string): ZoneEvent => ({ - event_id: id, - type: 'm.room.message', - sender: '@a:x', - origin_server_ts: 1, - content: c, - }); - const albums = buildAlbums([ - { root: asEvent(sent[0], '$e1'), replies: [asEvent(sent[1], '$e2')] }, - ]); - expect(names(listFolder(albums, []))).toEqual({ folders: ['uploads:1'], albums: [] }); - expect(names(listFolder(albums, ['uploads', 'meta']))).toEqual({ - folders: [], - albums: ['$e1=Photos'], - }); - }); -}); diff --git a/src/app/features/albums/albumFolders.ts b/src/app/features/albums/albumFolders.ts deleted file mode 100644 index a74d363f..00000000 --- a/src/app/features/albums/albumFolders.ts +++ /dev/null @@ -1,73 +0,0 @@ -// Pure: folders are albums whose title is a `/`-separated path (docs/commons-files-desk.md, "Folders"). -// No wire change: the album is still one thread root; the folder tree is only how the list is grouped. -import { Album, UNTITLED } from './buildAlbums'; - -/** A path as segments. Empty segments and surrounding space drop out; `.` and `..` are ordinary names, never navigation. */ -export const splitPath = (title: string): string[] => - title - .split('/') - .map((s) => s.trim()) - .filter(Boolean); - -export type FolderEntry = { - name: string; - /** Segments from the root to this folder. */ - path: string[]; - /** Albums at any depth below. */ - albums: number; - /** The newest album ts below, for ordering. */ - ts: number; -}; - -export type AlbumEntry = { album: Album; leaf: string }; - -export type FolderListing = { folders: FolderEntry[]; albums: AlbumEntry[] }; - -const startsWith = (path: string[], prefix: string[]) => prefix.every((seg, i) => path[i] === seg); - -/** - * What one folder shows: its direct sub-folders and the albums titled exactly at its path plus a leaf. - * An album titled `x` and a folder `x/` coexist at the same level; neither hides the other. Newest first, both lists. - */ -export const listFolder = (albums: readonly Album[], at: string[]): FolderListing => { - const folders = new Map(); - const direct: AlbumEntry[] = []; - albums.forEach((album) => { - const segs = splitPath(album.title); - if (segs.length === 0) { - // An untitled or all-slash title lives at the root under the title as written. - if (at.length === 0) direct.push({ album, leaf: album.title.trim() || UNTITLED }); - return; - } - if (!startsWith(segs, at)) return; - // An album titled exactly at this path sits in the parent, not inside the folder it shares a name with. - if (segs.length <= at.length) return; - if (segs.length === at.length + 1) { - direct.push({ album, leaf: segs[segs.length - 1] }); - return; - } - const name = segs[at.length]; - const f = folders.get(name) ?? { name, path: [...at, name], albums: 0, ts: 0 }; - f.albums += 1; - f.ts = Math.max(f.ts, album.ts); - folders.set(name, f); - }); - return { - folders: [...folders.values()].sort((a, b) => b.ts - a.ts), - albums: direct.sort((a, b) => b.album.ts - a.album.ts), - }; -}; - -/** The folder an album sits in: its title path without the leaf. */ -export const folderOf = (album: Album): string[] => splitPath(album.title).slice(0, -1); - -/** - * The title a share sends into `folder`: the folder path then what the sender typed. The typed title - * may itself contain `/`, which just goes deeper. An empty typed title falls back to `fallback`. - */ -export const titleIn = (folder: string[], typed: string, fallback: string): string => { - const leaf = splitPath(typed).join('/') || fallback; - return [...folder, leaf].join('/'); -}; - -export const pathLabel = (path: string[]): string => path.join(' / '); diff --git a/src/app/features/albums/buildAlbums.test.ts b/src/app/features/albums/buildAlbums.test.ts index e1e7964b..3a27bed6 100644 --- a/src/app/features/albums/buildAlbums.test.ts +++ b/src/app/features/albums/buildAlbums.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { AlbumInput, ZoneEvent, buildAlbums, lastEvent } from './buildAlbums'; +import { AlbumInput, ZoneEvent, buildAlbums } from './buildAlbums'; const root = (id: string, ts: number, over: Partial = {}): ZoneEvent => ({ event_id: id, @@ -214,123 +214,3 @@ describe('buildAlbums', () => { expect(one(root('$q', 1), [threaded]).count).toBe(1); }); }); - -describe('versions', () => { - const v = (id: string, ts: number, replaces?: string) => - reply(id, ts, 'm.file', id, replaces ? { 'org.gamecult.publish': { replaces } } : {}); - - it('lists the latest of a chain and folds the earlier versions under it', () => { - const a = one(root('$r', 1), [v('$v1', 10), v('$v2', 20, '$v1'), reply('$x', 30, 'm.file')]); - expect(a.files.map((f) => [f.id, f.earlier.map((e) => e.id)])).toEqual([ - ['$v2', ['$v1']], - ['$x', []], - ]); - expect(a.count).toBe(2); - }); -}); - -describe('versions: cover and sender', () => { - it('the cover comes from the shown version, not a folded image', () => { - const img = (id: string, ts: number, type: string, replaces?: string) => - reply(id, ts, type, id, replaces ? { 'org.gamecult.publish': { replaces } } : {}); - const a = one(root('$r', 1), [img('$v1', 10, 'm.image'), img('$v2', 20, 'm.file', '$v1')]); - expect(a.files.map((f) => f.id)).toEqual(['$v2']); - expect(a.cover).toBeUndefined(); - }); -}); - -describe('lastEvent', () => { - it('is the newest event of the thread, a folded version included; the root when empty', () => { - const v = (id: string, ts: number, replaces?: string) => - reply(id, ts, 'm.file', id, replaces ? { 'org.gamecult.publish': { replaces } } : {}); - expect(lastEvent(one(root('$r', 1), [v('$a', 10), v('$b', 20, '$a'), v('$c', 15)]))).toEqual({ - id: '$b', - ts: 20, - }); - expect(lastEvent(one(root('$r', 1), []))).toEqual({ id: '$r', ts: 1 }); - }); -}); - -describe('Mind receipts', () => { - const HASH = 'a'.repeat(64); - const file = (id: string, sha?: string) => - reply(id, 10, 'm.audio', id, { info: sha ? { sha256: sha } : {} }); - const receipt = ( - id: string, - ts: number, - sender: string, - fileId: unknown, - sha?: unknown, - extra: Record = {} - ) => - ({ - event_id: id, - type: 'org.gamecult.file_received', - sender, - origin_server_ts: ts, - content: { 'org.gamecult.file_received': { event_id: fileId, sha256: sha, ...extra } }, - } as ZoneEvent); - - const oneX = (r: ZoneEvent, replies: ZoneEvent[]) => buildAlbums([{ root: r, replies }], 'x')[0]; - - it('shows who read a file when the receipt carries the file event hash', () => { - const a = oneX(root('$r', 1), [file('$f', HASH), receipt('$x', 20, '@void:x', '$f', HASH)]); - expect(a.files[0].readBy).toEqual([{ sender: '@void:x', ts: 20 }]); - expect(a.count).toBe(1); - }); - it('counts nothing for a hash that differs, a missing hash on either side, or a bad hash', () => { - const a = oneX(root('$r', 1), [ - file('$f', HASH), - file('$g'), - receipt('$x', 20, '@void:x', '$f', 'b'.repeat(64)), - receipt('$y', 21, '@void:x', '$g', HASH), - receipt('$z', 22, '@void:x', '$f', undefined), - receipt('$w', 23, '@void:x', '$f', 7), - ]); - expect(a.files.every((f) => f.readBy.length === 0)).toBe(true); - }); - it('trusts the event sender, never a Mind field inside the content', () => { - const a = oneX(root('$r', 1), [ - file('$f', HASH), - receipt('$x', 20, '@mallory:x', '$f', HASH, { mind: 'void', sender: '@void:x' }), - ]); - expect(a.files[0].readBy).toEqual([]); - }); - it('counts only a Mind of this homeserver: not another server, a lookalike, or a bare name', () => { - const a = oneX(root('$r', 1), [ - file('$f', HASH), - receipt('$x', 20, '@void-fan:x', '$f', HASH), - receipt('$y', 21, '@x:void', '$f', HASH), - receipt('$z', 22, '@aqua:x', '$f', HASH), - receipt('$e', 23, '@void:evil.example', '$f', HASH), - receipt('$b', 24, 'void:x', '$f', HASH), - ]); - expect(a.files[0].readBy.map((r) => r.sender)).toEqual(['@aqua:x']); - }); - it('keeps one receipt per Mind, the latest, and a second Mind beside it', () => { - const a = oneX(root('$r', 1), [ - file('$f', HASH), - receipt('$1', 20, '@void:x', '$f', HASH), - receipt('$2', 30, '@void:x', '$f', HASH), - receipt('$3', 25, '@eureka:x', '$f', HASH), - ]); - expect(a.files[0].readBy).toEqual([ - { sender: '@eureka:x', ts: 25 }, - { sender: '@void:x', ts: 30 }, - ]); - }); - it('ignores a receipt for a file not in the album, a malformed one, and a redacted one', () => { - const redacted = { - ...receipt('$d', 20, '@void:x', '$f', HASH), - unsigned: { redacted_because: {} }, - }; - const a = oneX(root('$r', 1), [ - file('$f', HASH), - receipt('$a', 20, '@void:x', '$elsewhere', HASH), - receipt('$b', 21, '@void:x', 42, HASH), - redacted, - ]); - expect(a.files[0].readBy).toEqual([]); - expect(a.count).toBe(1); - }); -}); diff --git a/src/app/features/albums/buildAlbums.ts b/src/app/features/albums/buildAlbums.ts index 5d3662e1..8e57262b 100644 --- a/src/app/features/albums/buildAlbums.ts +++ b/src/app/features/albums/buildAlbums.ts @@ -1,9 +1,6 @@ // Pure: turns the thread roots of a file-zone room and each root's thread replies, in the JSON // Synapse returns from GET /rooms/{roomId}/threads and GET /rooms/{roomId}/relations/{root}/m.thread, // into albums. See docs/file-zone.md for the rulings this encodes. -import { readersOf } from './fileReceipt'; -import type { FileReader } from './fileReceipt'; -import { foldVersions } from './versions'; /** The slice of a Matrix event this module reads. Every field may be missing. */ export type ZoneEvent = { @@ -23,14 +20,9 @@ export type AlbumFile = { id: string; kind: FileKind; name: string; - sender: string; ts: number; /** The event content, as RenderMessageContent reads it. */ content: Record; - /** Receipts in this album that name this file, one per sender (the latest), oldest first. */ - readBy: FileReader[]; - /** The versions this file supersedes, nearest first (`org.gamecult.publish.replaces`). */ - earlier: AlbumFile[]; }; export type Album = { @@ -68,7 +60,7 @@ const tsOf = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v const asString = (v: unknown): string => (typeof v === 'string' ? v : ''); -export const toFile = (e: ZoneEvent): AlbumFile | undefined => { +const toFile = (e: ZoneEvent): AlbumFile | undefined => { if (!live(e)) return undefined; const kind = KIND_OF_MSGTYPE.get(e.content.msgtype as string); if (!kind) return undefined; @@ -76,11 +68,8 @@ export const toFile = (e: ZoneEvent): AlbumFile | undefined => { id: e.event_id, kind, name: asString(e.content.filename) || asString(e.content.body), - sender: e.sender ?? '', ts: tsOf(e.origin_server_ts), content: e.content, - readBy: [], - earlier: [], }; }; @@ -95,38 +84,34 @@ const titleOf = (root: ZoneEvent & { content: Record }): string return asString(edited) || asString(root.content.body) || UNTITLED; }; -const toAlbum = (input: AlbumInput, server: string): Album | undefined => { +const toAlbum = (input: AlbumInput): Album | undefined => { const { root, replies } = input ?? {}; if (!live(root)) return undefined; const seen = new Set(); - const list = Array.isArray(replies) ? replies : []; - const files = list + const files = (Array.isArray(replies) ? replies : []) .map(toFile) .filter((f): f is AlbumFile => { if (!f || seen.has(f.id)) return false; seen.add(f.id); return true; }) - .sort((a, b) => a.ts - b.ts) - .map((f) => ({ ...f, readBy: readersOf(list, f.id, f.content, server) })); - const heads = foldVersions(files); + .sort((a, b) => a.ts - b.ts); return { id: root.event_id, title: titleOf(root), author: root.sender ?? '', ts: tsOf(root.origin_server_ts), - cover: heads.find((f) => f.kind === 'image'), - files: heads, - count: heads.length, + cover: files.find((f) => f.kind === 'image'), + files, + count: files.length, }; }; /** Newest album first; files oldest first. Loose room messages never reach this function. */ -/** `server` is the homeserver's own domain: only its Minds' receipts count. */ -export const buildAlbums = (inputs: AlbumInput[], server = ''): Album[] => { +export const buildAlbums = (inputs: AlbumInput[]): Album[] => { const seen = new Set(); return inputs - .map((i) => toAlbum(i, server)) + .map(toAlbum) .filter((a): a is Album => { if (!a || seen.has(a.id)) return false; seen.add(a.id); @@ -134,51 +119,3 @@ export const buildAlbums = (inputs: AlbumInput[], server = ''): Album[] => { }) .sort((a, b) => b.ts - a.ts); }; - -/** The newest event in an album's thread (shown or folded), else its root: where a new file threads after. */ -export const lastEvent = (album: Album): { id: string; ts: number } => - album.files - .flatMap((f) => [f, ...f.earlier]) - .reduce<{ id: string; ts: number }>((a, f) => (f.ts >= a.ts ? { id: f.id, ts: f.ts } : a), { - id: album.id, - ts: album.ts, - }); - -/** A room event with the thread root the SDK resolved for it, when it has one. */ -export type TimelineEvent = ZoneEvent & { threadRoot?: string }; - -/** - * File events that sit in the room outside any album thread: what a poster that does not thread - * its files leaves behind. The album list never reads them (it lists threads), so they are named - * to the sender instead of left out silently. - */ -export const looseFiles = (events: TimelineEvent[]): AlbumFile[] => - events - .filter((e) => { - const rel = e?.content?.['m.relates_to'] as { rel_type?: unknown } | undefined; - return !e?.threadRoot && rel?.rel_type !== 'm.thread'; - }) - .map(toFile) - .filter((f): f is AlbumFile => !!f); - -const NAMED = 3; - -/** The line that says loose files exist and are not listed; empty when there are none. */ -export const looseNote = (files: AlbumFile[]): string => { - if (files.length === 0) return ''; - const names = files.slice(0, NAMED).map((f) => f.name || 'unnamed'); - const more = files.length > NAMED ? ` and ${files.length - NAMED} more` : ''; - const what = files.length === 1 ? '1 file was' : `${files.length} files were`; - return `${what} posted outside an album and ${ - files.length === 1 ? 'is' : 'are' - } not listed here: ${names.join(', ')}${more}. ${ - files.length === 1 ? 'It is' : 'They are' - } in the Timeline.`; -}; - -/** Where the album view stands when it decides whether to show the loose-files note. */ -export type ViewState = { open: boolean; encrypted: boolean; loading: boolean; error: boolean }; - -/** The note for the list view only: never inside an open album, an encrypted zone, or a load that is not finished. */ -export const looseNoteFor = (files: AlbumFile[], view: ViewState): string => - view.open || view.encrypted || view.loading || view.error ? '' : looseNote(files); diff --git a/src/app/features/albums/fileReceipt.test.ts b/src/app/features/albums/fileReceipt.test.ts deleted file mode 100644 index c3c85f5e..00000000 --- a/src/app/features/albums/fileReceipt.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { ReceiptEvent, hashForSend, readLine, readersOf, sha256Hex } from './fileReceipt'; - -describe('sha256Hex', () => { - it('hashes the bytes: the FIPS 180 vector for "abc"', async () => { - expect(await sha256Hex(new Blob(['abc']))).toBe( - 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad' - ); - }); - it('hashes empty input', async () => { - expect(await sha256Hex(new Blob([]))).toBe( - 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' - ); - }); -}); - -describe('hashForSend', () => { - it('gives no hash, and does not throw, when the blob cannot be read', async () => { - const broken = { arrayBuffer: () => Promise.reject(new Error('gone')) } as unknown as Blob; - expect(await hashForSend(broken)).toBeUndefined(); - }); -}); - -describe('readLine', () => { - it('names each reader', () => { - const name = (s: string) => s.slice(1, s.indexOf(':')); - expect( - readLine( - [ - { sender: '@void:x', ts: 1 }, - { sender: '@eureka:x', ts: 2 }, - ], - name - ) - ).toBe('fetched for void, eureka'); - }); -}); - -describe('readersOf: receipts anywhere in the room, thread reply or plain event', () => { - const HASH = 'a'.repeat(64); - const file = { info: { sha256: HASH } }; - const receipt = ( - id: string, - ts: number, - sender: string, - sha: unknown | undefined, - fileId: string | undefined, - relates?: object - ): ReceiptEvent => ({ - event_id: id, - type: 'org.gamecult.file_received', - sender, - origin_server_ts: ts, - content: { - ...(relates ? { 'm.relates_to': relates } : {}), - 'org.gamecult.file_received': { - event_id: fileId ?? '$f', - sha256: sha === undefined ? HASH : sha, - }, - }, - }); - const readers = (events: ReceiptEvent[], content: Record | undefined) => - readersOf(events, '$f', content, 'gamecult.org').map((r) => r.sender); - - it('counts a threadless receipt, as a composer file with no album root gets', () => { - expect(readers([receipt('$1', 5, '@void:gamecult.org', undefined, undefined)], file)).toEqual([ - '@void:gamecult.org', - ]); - }); - it('counts a thread reply the same way', () => { - const t = { rel_type: 'm.thread', event_id: '$root' }; - expect(readers([receipt('$1', 5, '@eureka:gamecult.org', HASH, '$f', t)], file)).toEqual([ - '@eureka:gamecult.org', - ]); - }); - it('refuses lookalike localparts and foreign servers', () => { - expect( - readers( - [ - receipt('$1', 1, '@void2:gamecult.org', undefined, undefined), - receipt('$2', 2, '@voidd:gamecult.org', undefined, undefined), - receipt('$3', 3, '@Void:gamecult.org', undefined, undefined), - receipt('$4', 4, '@void:evil.example', undefined, undefined), - receipt('$5', 5, '@void:gamecult.org.evil.example', undefined, undefined), - receipt('$6', 6, '@mallory:gamecult.org', undefined, undefined), - ], - file - ) - ).toEqual([]); - }); - it('shows nothing for a mismatched hash, a file with no hash, or another file', () => { - expect( - readers([receipt('$1', 1, '@void:gamecult.org', 'b'.repeat(64), undefined)], file) - ).toEqual([]); - expect( - readers([receipt('$1', 1, '@void:gamecult.org', undefined, undefined)], { info: {} }) - ).toEqual([]); - expect( - readers([receipt('$1', 1, '@void:gamecult.org', undefined, undefined)], undefined) - ).toEqual([]); - expect(readers([receipt('$1', 1, '@void:gamecult.org', HASH, '$other')], file)).toEqual([]); - }); - it('keeps one entry per Mind, the latest, oldest first', () => { - expect( - readersOf( - [ - receipt('$1', 30, '@void:gamecult.org', undefined, undefined), - receipt('$2', 10, '@void:gamecult.org', undefined, undefined), - receipt('$3', 20, '@aqua:gamecult.org', undefined, undefined), - ], - '$f', - file, - 'gamecult.org' - ) - ).toEqual([ - { sender: '@aqua:gamecult.org', ts: 20 }, - { sender: '@void:gamecult.org', ts: 30 }, - ]); - }); - it('ignores a redacted receipt and a malformed one', () => { - const redacted = { - ...receipt('$1', 1, '@void:gamecult.org', undefined, undefined), - unsigned: { redacted_because: {} }, - }; - expect( - readers( - [ - redacted, - receipt('$2', 2, '@void:gamecult.org', 7, undefined), - { - ...receipt('$3', 3, '@void:gamecult.org', undefined, undefined), - type: 'm.room.message', - }, - ], - file - ) - ).toEqual([]); - }); - - describe('an m.reference to the file', () => { - const ref = (id: string) => ({ rel_type: 'm.reference', event_id: id }); - it('counts when it names the same event as the content', () => { - expect( - readers([receipt('$1', 1, '@void:gamecult.org', HASH, '$f', ref('$f'))], file) - ).toEqual(['@void:gamecult.org']); - }); - it('counts for nothing when it names another event', () => { - expect( - readers([receipt('$1', 1, '@void:gamecult.org', HASH, '$f', ref('$other'))], file) - ).toEqual([]); - }); - it('draws one entry per Mind however many receipts exist', () => { - expect( - readers( - [ - receipt('$1', 1, '@void:gamecult.org', HASH, '$f', ref('$f')), - receipt('$2', 2, '@void:gamecult.org', HASH, '$f'), - receipt('$3', 3, '@void:gamecult.org', HASH, '$f', ref('$f')), - ], - file - ) - ).toEqual(['@void:gamecult.org']); - }); - }); -}); diff --git a/src/app/features/albums/fileReceipt.ts b/src/app/features/albums/fileReceipt.ts deleted file mode 100644 index 93fa73f0..00000000 --- a/src/app/features/albums/fileReceipt.ts +++ /dev/null @@ -1,89 +0,0 @@ -// The file-zone Mind contract (docs/file-zone-mind-contract.md): the plaintext hash a sender writes -// into a file event, and the receipt a Mind answers with. - -import { MINDS } from '../../components/message/pose/poses'; - -/** A Mind's verified receipt for one file: its sender, who the Matrix server says posted it. */ -export type FileReader = { sender: string; ts: number }; - -/** The slice of a Matrix event a receipt is read from. Every field may be missing. */ -export type ReceiptEvent = { - event_id?: string; - type?: string; - sender?: string; - origin_server_ts?: number; - content?: Record; - unsigned?: Record; -}; - -/** Hex sha256 of the plaintext bytes, written at `content.info[FILE_HASH_KEY]` in unencrypted rooms. */ -export const FILE_HASH_KEY = 'sha256'; - -/** A Mind's receipt: event type, and the content key holding { event_id, sha256, size }. Inert: nothing in it is fetched or trusted but the sender and the hash. */ -export const FILE_RECEIVED = 'org.gamecult.file_received'; - -export const sha256Hex = async (data: Blob): Promise => { - const digest = await crypto.subtle.digest('SHA-256', await data.arrayBuffer()); - return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join(''); -}; - -/** The hash to write for a file about to be sent in an unencrypted room; none when it cannot be computed. */ -export const hashForSend = async (data: Blob): Promise => { - try { - return await sha256Hex(data); - } catch { - return undefined; - } -}; - -export const readLine = (readBy: FileReader[], name: (sender: string) => string): string => - `fetched for ${readBy.map((r) => name(r.sender)).join(', ')}`; - -const receiptOf = (e: ReceiptEvent) => { - if (!e || typeof e !== 'object' || e.type !== FILE_RECEIVED || !e.event_id) return undefined; - if (e.unsigned?.redacted_because) return undefined; - const body = e.content?.[FILE_RECEIVED] as { event_id?: unknown; sha256?: unknown } | undefined; - if (typeof body?.event_id !== 'string' || typeof body.sha256 !== 'string') return undefined; - if (typeof e.sender !== 'string') return undefined; - // A reference to the file (Eureka's dedup, via /relations) must name the same event as the content, or the - // receipt counts for nothing. Matching is by content.event_id; other relations (a thread) are not read. - const rel = e.content?.['m.relates_to'] as { rel_type?: unknown; event_id?: unknown } | undefined; - if (rel?.rel_type === 'm.reference' && rel.event_id !== body.event_id) return undefined; - const ts = e.origin_server_ts; - return { - file: body.event_id, - sender: e.sender, - ts: typeof ts === 'number' && Number.isFinite(ts) ? ts : 0, - sha256: body.sha256, - }; -}; - -// Only the event's sender counts, never a name inside the content, and only a Mind's own Matrix -// identity on this homeserver (`@:`, a mind named in the Persona catalogue). -const isMind = (sender: string, server: string) => MINDS.some((m) => sender === `@${m}:${server}`); - -/** - * Who has read the file `fileId`, from receipts found anywhere in `events` (an album's thread replies, or the room's - * timeline: a receipt is a thread reply when the file has an album root, otherwise a plain room event; both count). - * "Read" means verified: the receipt's sha256 must equal the file event's `info.sha256`, so a file with no hash, or a - * mismatch, shows nothing. One entry per Mind (its latest), oldest first. - */ -export const readersOf = ( - events: ReceiptEvent[], - fileId: string, - fileContent: Record | undefined, - server: string -): FileReader[] => { - const sent = (fileContent?.info as Record | undefined)?.[FILE_HASH_KEY]; - if (typeof sent !== 'string') return []; - const latest = new Map(); - events - .map(receiptOf) - .filter( - (r): r is NonNullable => - !!r && r.file === fileId && r.sha256 === sent && isMind(r.sender, server) - ) - .sort((a, b) => a.ts - b.ts) - .forEach((r) => latest.set(r.sender, { sender: r.sender, ts: r.ts })); - return [...latest.values()].sort((a, b) => a.ts - b.ts); -}; diff --git a/src/app/features/albums/publisherWire.test.ts b/src/app/features/albums/publisherWire.test.ts deleted file mode 100644 index 83bddf3a..00000000 --- a/src/app/features/albums/publisherWire.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { buildAlbums, looseFiles, looseNote, looseNoteFor, TimelineEvent } from './buildAlbums'; - -// The Commons publisher as it posted before gamecult-ops #121: loose files (no album thread), a -// flat `org.gamecult.publish.replaces` key, no info.sha256. The sender must see that, not a -// silently empty or silently unfolded zone. -const MIND = '@eureka:gamecult.org'; -const file = ( - id: string, - name: string, - extra: Record = {}, - ts = 1 -): TimelineEvent => ({ - event_id: id, - type: 'm.room.message', - sender: MIND, - origin_server_ts: ts, - content: { msgtype: 'm.file', body: name, filename: name, url: `mxc://x/${id}`, ...extra }, -}); -const text = (id: string, body: string): TimelineEvent => ({ - event_id: id, - type: 'm.room.message', - sender: MIND, - origin_server_ts: 1, - content: { msgtype: 'm.text', body }, -}); - -describe('loose files from a poster that does not thread', () => { - it('are found, with the names the sender sees', () => { - const loose = looseFiles([file('$a', 'notes.md'), file('$b', 'plan.pdf')]); - expect(loose.map((f) => f.name)).toEqual(['notes.md', 'plan.pdf']); - }); - - it('are not the album roots, thread replies (by SDK root or by relation), edits or redactions', () => { - const events: TimelineEvent[] = [ - text('$root', 'handoff/eureka'), - { ...file('$r1', 'a.md'), threadRoot: '$root' }, - file('$r2', 'b.md', { - 'm.relates_to': { rel_type: 'm.thread', event_id: '$root' }, - }), - file('$edit', 'c.md', { 'm.relates_to': { rel_type: 'm.replace', event_id: '$r1' } }), - { ...file('$gone', 'd.md'), unsigned: { redacted_because: {} } }, - file('$loose', 'e.md'), - ]; - expect(looseFiles(events).map((f) => f.id)).toEqual(['$loose']); - }); - - it('a zone holding only loose files still has no albums, and the note says why', () => { - const events = [file('$a', 'notes.md')]; - expect(buildAlbums([])).toEqual([]); - expect(looseNote(looseFiles(events))).toBe( - '1 file was posted outside an album and is not listed here: notes.md. It is in the Timeline.' - ); - }); - - it('the note names three and counts the rest', () => { - const many = looseFiles(['a', 'b', 'c', 'd', 'e'].map((n) => file(`$${n}`, `${n}.md`))); - expect(looseNote(many)).toBe( - '5 files were posted outside an album and are not listed here: a.md, b.md, c.md and 2 more. They are in the Timeline.' - ); - expect(looseNote([])).toBe(''); - }); -}); - -describe('where the loose-files note shows', () => { - const loose = looseFiles([file('$a', 'notes.md')]); - const list = { open: false, encrypted: false, loading: false, error: false }; - - it('on the album list, and only there', () => { - expect(looseNoteFor(loose, list)).toContain('notes.md'); - expect(looseNoteFor(loose, { ...list, open: true })).toBe(''); - expect(looseNoteFor(loose, { ...list, encrypted: true })).toBe(''); - expect(looseNoteFor(loose, { ...list, loading: true })).toBe(''); - expect(looseNoteFor(loose, { ...list, error: true })).toBe(''); - }); - - it('says nothing when there are no loose files', () => { - expect(looseNoteFor([], list)).toBe(''); - }); - - it('names m.notice and m.sticker posts as not files', () => { - const notice = { ...text('$n', 'hi'), content: { msgtype: 'm.notice', body: 'hi' } }; - expect(looseFiles([notice])).toEqual([]); - }); - - it('a file with no name reads as unnamed', () => { - const bare: TimelineEvent = { ...file('$b', ''), content: { msgtype: 'm.file', body: '' } }; - expect(looseNote(looseFiles([bare]))).toContain('unnamed'); - }); -}); - -describe('the old wire shape inside an album', () => { - const root = text('$root', 'handoff/eureka'); - const albumOf = (replies: TimelineEvent[]) => buildAlbums([{ root, replies }], 'gamecult.org')[0]; - - it('a flat replaces key folds nothing: both versions show, none is hidden', () => { - const album = albumOf([ - file('$v1', 'plan.md', { 'org.gamecult.publish.sha256': 'aa' }, 1), - file('$v2', 'plan.md', { 'org.gamecult.publish.replaces': '$v1' }, 2), - ]); - expect(album.files.map((f) => f.id)).toEqual(['$v1', '$v2']); - expect(album.files.every((f) => f.earlier.length === 0)).toBe(true); - }); - - it('the nested shape from the fixed publisher folds as before', () => { - const album = albumOf([ - file('$v1', 'plan.md', {}, 1), - file('$v2', 'plan.md', { 'org.gamecult.publish': { replaces: '$v1' } }, 2), - ]); - expect(album.files.map((f) => f.id)).toEqual(['$v2']); - expect(album.files[0].earlier.map((f) => f.id)).toEqual(['$v1']); - }); - - it('a file with no info.sha256 lists fine, with no readers even when a receipt names it', () => { - const receipt: TimelineEvent = { - event_id: '$rc', - type: 'org.gamecult.file_received', - sender: '@void:gamecult.org', - origin_server_ts: 5, - content: { 'org.gamecult.file_received': { event_id: '$v1', sha256: 'aa', size: 1 } }, - }; - const album = albumOf([file('$v1', 'plan.md'), receipt]); - expect(album.count).toBe(1); - expect(album.files[0].readBy).toEqual([]); - }); -}); diff --git a/src/app/features/albums/readPages.test.ts b/src/app/features/albums/readPages.test.ts deleted file mode 100644 index 124a32a6..00000000 --- a/src/app/features/albums/readPages.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { Page, readPages } from './readPages'; - -const pager = (pages: Page[]) => { - const froms: (string | undefined)[] = []; - return { - froms, - fetchPage: async (from: string | undefined) => { - froms.push(from); - return pages[froms.length - 1] ?? {}; - }, - }; -}; - -describe('readPages', () => { - it('one page without a token is the whole read', async () => { - const p = pager([{ chunk: [1, 2] }]); - expect(await readPages(p.fetchPage)).toEqual({ items: [1, 2], truncated: false }); - expect(p.froms).toEqual([undefined]); - }); - - it('follows next_batch in order, passing each token back', async () => { - const p = pager([ - { chunk: [1], next_batch: 'a' }, - { chunk: [2], next_batch: 'b' }, - { chunk: [3] }, - ]); - expect(await readPages(p.fetchPage)).toEqual({ items: [1, 2, 3], truncated: false }); - expect(p.froms).toEqual([undefined, 'a', 'b']); - }); - - it('a missing chunk reads as empty', async () => { - expect(await readPages(async () => ({}))).toEqual({ items: [], truncated: false }); - }); - - it('an empty page ends the read even when it carries a token', async () => { - const p = pager([ - { chunk: [1], next_batch: 'a' }, - { chunk: [], next_batch: 'b' }, - ]); - expect(await readPages(p.fetchPage)).toEqual({ items: [1], truncated: false }); - expect(p.froms).toHaveLength(2); - }); - - it('the cap stops the read and says it was cut short', async () => { - const p = pager(Array.from({ length: 10 }, (_, i) => ({ chunk: [i], next_batch: `t${i}` }))); - expect(await readPages(p.fetchPage, 3)).toEqual({ items: [0, 1, 2], truncated: true }); - expect(p.froms).toHaveLength(3); - }); - - it('a last page exactly at the cap is not truncated', async () => { - const p = pager([{ chunk: [1], next_batch: 'a' }, { chunk: [2] }]); - expect((await readPages(p.fetchPage, 2)).truncated).toBe(false); - }); -}); diff --git a/src/app/features/albums/readPages.ts b/src/app/features/albums/readPages.ts deleted file mode 100644 index a2b888e5..00000000 --- a/src/app/features/albums/readPages.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** One page of a Matrix paginated endpoint: its events and the token for the next page, if any. */ -export type Page = { chunk?: T[]; next_batch?: string }; - -/** Pages read per endpoint at most, so a runaway zone cannot hold the phone in a loop. */ -export const MAX_PAGES = 20; - -/** - * Reads every page of an endpoint by following `next_batch`, up to `maxPages`. `truncated` is true - * when the cap stopped the read with more still to come, so a caller can say the list is partial. - */ -export const readPages = async ( - fetchPage: (from: string | undefined) => Promise>, - maxPages = MAX_PAGES -): Promise<{ items: T[]; truncated: boolean }> => { - const items: T[] = []; - let from: string | undefined; - for (let n = 0; n < maxPages; n += 1) { - // eslint-disable-next-line no-await-in-loop - const page = await fetchPage(from); - items.push(...(page.chunk ?? [])); - // An empty page ends the read even if a token is returned: a server that keeps handing out - // tokens with nothing behind them would otherwise spend every page of the cap. - if (!page.next_batch || !page.chunk?.length) return { items, truncated: false }; - from = page.next_batch; - } - return { items, truncated: true }; -}; diff --git a/src/app/features/albums/receiptStore.test.ts b/src/app/features/albums/receiptStore.test.ts deleted file mode 100644 index c97202e5..00000000 --- a/src/app/features/albums/receiptStore.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { EventEmitter } from 'events'; -import { describe, expect, it, vi } from 'vitest'; -import { MatrixEvent, Room, RoomEvent } from 'matrix-js-sdk'; -import { receiptStoreOf } from './receiptStore'; -import { readersOf } from './fileReceipt'; - -const HASH = 'a'.repeat(64); - -type Fake = { - id: string; - type: string; - sender: string; - ts: number; - content: object; - redacted?: boolean; -}; -const event = (f: Fake) => - ({ - getId: () => f.id, - getType: () => f.type, - getSender: () => f.sender, - getTs: () => f.ts, - getContent: () => f.content, - isRedacted: () => !!f.redacted, - } as unknown as MatrixEvent); -const receipt = (id: string, sender: string, fileId = '$f', over: Partial = {}): Fake => ({ - id, - type: 'org.gamecult.file_received', - sender, - ts: 1, - content: { 'org.gamecult.file_received': { event_id: fileId, sha256: HASH } }, - ...over, -}); - -const fakeRoom = (initial: Fake[] = []) => { - const emitter = new EventEmitter(); - emitter.setMaxListeners(0); - let events = initial; - const room = Object.assign(emitter, { - getUnfilteredTimelineSet: () => ({ - getLiveTimeline: () => ({ getEvents: () => events.map(event) }), - }), - }) as unknown as Room; - return { - room, - emitter, - set: (next: Fake[]) => { - events = next; - }, - }; -}; -const listeners = (e: EventEmitter) => - [RoomEvent.Timeline, RoomEvent.Redaction, RoomEvent.TimelineReset].map((n) => e.listenerCount(n)); - -describe('receiptStoreOf: one subscription per room', () => { - it('registers one listener per event for 200 file rows, and none once they are gone', () => { - const { room, emitter } = fakeRoom(); - const store = receiptStoreOf(room); - const off = Array.from({ length: 200 }, () => store.subscribe(() => undefined)); - expect(listeners(emitter)).toEqual([1, 1, 1]); - off.forEach((o) => o()); - expect(listeners(emitter)).toEqual([0, 0, 0]); - }); - - it('is one store per room', () => { - const a = fakeRoom(); - const b = fakeRoom(); - expect(receiptStoreOf(a.room)).toBe(receiptStoreOf(a.room)); - expect(receiptStoreOf(a.room)).not.toBe(receiptStoreOf(b.room)); - }); - - it('notifies rows when a receipt arrives, not for unrelated timeline events, and gives readersOf what it needs', () => { - const { room, emitter, set } = fakeRoom(); - const store = receiptStoreOf(room); - const cb = vi.fn(); - store.subscribe(cb); - expect(store.getSnapshot()).toEqual([]); - set([{ id: '$m', type: 'm.room.message', sender: '@x:gamecult.org', ts: 1, content: {} }]); - emitter.emit(RoomEvent.Timeline); - expect(cb).not.toHaveBeenCalled(); - set([receipt('$r', '@void:gamecult.org')]); - emitter.emit(RoomEvent.Timeline); - expect(cb).toHaveBeenCalledTimes(1); - const snap = store.getSnapshot(); - expect( - readersOf(snap, '$f', { info: { sha256: HASH } }, 'gamecult.org').map((r) => r.sender) - ).toEqual(['@void:gamecult.org']); - emitter.emit(RoomEvent.Timeline); - expect(store.getSnapshot()).toBe(snap); - }); - - it('rescans on a redaction and on a timeline reset', () => { - const { room, emitter, set } = fakeRoom([receipt('$r', '@void:gamecult.org')]); - const store = receiptStoreOf(room); - const cb = vi.fn(); - store.subscribe(cb); - expect(store.getSnapshot()).toHaveLength(1); - set([receipt('$r', '@void:gamecult.org', '$f', { redacted: true })]); - emitter.emit(RoomEvent.Redaction); - expect(cb).toHaveBeenCalledTimes(1); - expect( - readersOf(store.getSnapshot(), '$f', { info: { sha256: HASH } }, 'gamecult.org') - ).toEqual([]); - set([]); - emitter.emit(RoomEvent.TimelineReset); - expect(cb).toHaveBeenCalledTimes(2); - expect(store.getSnapshot()).toEqual([]); - }); - - it('picks up what is already loaded when the first row subscribes', () => { - const { room } = fakeRoom([receipt('$r', '@aqua:gamecult.org')]); - const store = receiptStoreOf(room); - store.subscribe(() => undefined); - expect(store.getSnapshot()).toHaveLength(1); - }); -}); diff --git a/src/app/features/albums/receiptStore.ts b/src/app/features/albums/receiptStore.ts deleted file mode 100644 index 1f69aba9..00000000 --- a/src/app/features/albums/receiptStore.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { MatrixEvent, Room } from 'matrix-js-sdk'; -import { RoomEvent } from 'matrix-js-sdk'; -import { FILE_RECEIVED, ReceiptEvent } from './fileReceipt'; - -/** - * One subscription per room to the file receipts in its loaded timeline, shared by every file row: a row costs a - * callback in a Set, not a Room listener (matrix-js-sdk warns past 100 listeners). The snapshot is the receipt events - * themselves; each row derives its own readers from it with `readersOf`, so the rules (sender, hash, reference, one per - * Mind) live in one place and the store holds no verdicts. The snapshot changes identity only when the set of receipts - * or one's redaction state does, so rows re-render for a receipt, not for every timeline event. - */ -export type ReceiptStore = { - subscribe: (onChange: () => void) => () => void; - getSnapshot: () => ReceiptEvent[]; -}; - -const asReceiptEvent = (e: MatrixEvent): ReceiptEvent => ({ - event_id: e.getId(), - type: e.getType(), - sender: e.getSender(), - origin_server_ts: e.getTs(), - content: e.getContent(), - unsigned: e.isRedacted() ? { redacted_because: true } : undefined, -}); - -const same = (a: ReceiptEvent[], b: ReceiptEvent[]): boolean => - a.length === b.length && - a.every( - (r, i) => - r.event_id === b[i].event_id && - !r.unsigned?.redacted_because === !b[i].unsigned?.redacted_because - ); - -// A redaction removes a tick and a reset drops the window: both rescan, not only a new event. -const EVENTS = [RoomEvent.Timeline, RoomEvent.Redaction, RoomEvent.TimelineReset] as const; - -const make = (room: Room): ReceiptStore => { - const subscribers = new Set<() => void>(); - let snapshot: ReceiptEvent[] = []; - const read = (): boolean => { - const next = room - .getUnfilteredTimelineSet() - .getLiveTimeline() - .getEvents() - .filter((e) => e.getType() === FILE_RECEIVED) - .map(asReceiptEvent); - if (same(snapshot, next)) return false; - snapshot = next; - return true; - }; - const scan = () => { - if (read()) subscribers.forEach((cb) => cb()); - }; - return { - subscribe: (onChange) => { - subscribers.add(onChange); - if (subscribers.size === 1) { - EVENTS.forEach((ev) => room.on(ev, scan)); - read(); // the first row sees what is already loaded; it reads the snapshot after subscribing - } - return () => { - subscribers.delete(onChange); - if (subscribers.size === 0) EVENTS.forEach((ev) => room.off(ev, scan)); - }; - }, - getSnapshot: () => snapshot, - }; -}; - -const stores = new WeakMap(); - -export const receiptStoreOf = (room: Room): ReceiptStore => { - let store = stores.get(room); - if (!store) { - store = make(room); - stores.set(room, store); - } - return store; -}; diff --git a/src/app/features/albums/shareController.test.ts b/src/app/features/albums/shareController.test.ts deleted file mode 100644 index b163b736..00000000 --- a/src/app/features/albums/shareController.test.ts +++ /dev/null @@ -1,380 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { Picked, ShareDeps, createShareController } from './shareController'; -import { ShareResult } from './shareSet'; - -const file = (name: string, size?: number): Picked => ({ name, type: 'audio/ogg', size }); -const MB = 1024 * 1024; -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; signal: AbortSignal }[] = []; - let release: (r: ShareResult) => void = () => undefined; - let fail: (e: Error) => void = () => undefined; - const run: ShareDeps['run'] = (files, title, onFile, signal) => { - calls.push({ files, title, onFile, signal }); - 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.', - }); - }); -}); - -describe('Stop', () => { - it('aborts the signal of the send in flight, and only that send', async () => { - const { c, h } = make(); - c.pick([file('a'), file('b')]); - c.stop(); // nothing running: nothing to abort - const sending = c.send(); - expect(h.calls[0].signal.aborted).toBe(false); - c.stop(); - expect(h.calls[0].signal.aborted).toBe(true); - h.release({ albumId: '$r', sent: 0, failed: ['a', 'b'], failedAt: [0, 1] }); - await sending; - expect(c.getState().files.map((f) => f.name)).toEqual(['a', 'b']); - // the next send gets a fresh signal - const again = c.send(); - expect(h.calls[1].signal.aborted).toBe(false); - h.release(ok(2)); - await again; - }); -}); - -describe('files over the homeserver cap', () => { - const cap = 50 * MB; - const capped = () => make({ maxBytes: () => cap }); - - it('are named in the note and never sent; the rest go', async () => { - const { c, h } = capped(); - c.pick([file('small.ogg', 3 * MB), file('film.mp4', 80 * MB), file('also.ogg', 1)]); - const sent = c.send(); - expect(h.calls[0].files.map((f) => f.name)).toEqual(['small.ogg', 'also.ogg']); - expect(c.getState().files.map((f) => f.name)).toEqual(['small.ogg', 'also.ogg']); - h.release(ok(2)); - await sent; - expect(c.getState().phase).toEqual({ - kind: 'done', - text: 'Shared 2 files. Too large for this server (limit 50 MB): film.mp4', - }); - expect(c.getState().files).toEqual([]); - }); - - it('are not kept for a retry that cannot help them, while a failed small file is', async () => { - const { c, h } = capped(); - c.pick([file('a.ogg', 1), file('big.wav', 60 * MB)]); - const sent = c.send(); - h.release({ albumId: '$r', sent: 0, failed: ['a.ogg'], failedAt: [0] }); - await sent; - expect(c.getState().files.map((f) => f.name)).toEqual(['a.ogg']); - }); - - it('a pick that is all over the cap sends nothing: no run, so no album root, and the pick clears', async () => { - const { c, h, sentTo } = capped(); - c.pick([file('a.mp4', 51 * MB), file('b.mp4', 99 * MB)]); - const { epoch } = c.getState(); - await c.send(); - expect(h.calls).toHaveLength(0); - expect(sentTo).toEqual([]); - expect(c.getState()).toMatchObject({ files: [], epoch: epoch + 1 }); - expect(c.getState().phase).toEqual({ - kind: 'done', - text: 'Nothing was shared. Too large for this server (limit 50 MB): a.mp4, b.mp4', - }); - }); - - it('a file of exactly the cap goes: the server refuses only what is larger', async () => { - const { c, h } = capped(); - c.pick([file('edge.bin', cap)]); - const sent = c.send(); - expect(h.calls[0].files).toHaveLength(1); - h.release(ok(1)); - await sent; - }); - - it('the room is not held busy by an all-refused pick, so the next pick sends', async () => { - const { c, h } = capped(); - c.pick([file('a.mp4', 99 * MB)]); - await c.send(); - c.pick([file('b.ogg', 1)]); - const sent = c.send(); - expect(h.calls).toHaveLength(1); - h.release(ok(1)); - await sent; - }); - - it('a server that states no cap, or a file with no size, refuses nothing', async () => { - const none = make(); - none.c.pick([file('a', 10 ** 12)]); - const s1 = none.c.send(); - expect(none.h.calls[0].files).toHaveLength(1); - none.h.release(ok(1)); - await s1; - const unsized = make({ maxBytes: () => cap }, '!other:x'); - unsized.c.pick([file('b')]); - const s2 = unsized.c.send(); - expect(unsized.h.calls[0].files).toHaveLength(1); - unsized.h.release(ok(1)); - await s2; - }); - - it('a fractional cap reads with one decimal', async () => { - const { c } = make({ maxBytes: () => 1.5 * MB }); - c.pick([file('a', 2 * MB)]); - await c.send(); - expect(c.getState().phase).toMatchObject({ text: expect.stringContaining('limit 1.5 MB') }); - }); - - it('a run that throws still names the refused files, which are not kept', async () => { - const { c, h } = capped(); - c.pick([file('a.ogg', 1), file('big.wav', 60 * MB)]); - const sent = c.send(); - h.fail(new Error('network down')); - await sent; - expect(c.getState().phase).toEqual({ - kind: 'done', - text: 'Could not share: network down. Too large for this server (limit 50 MB): big.wav', - }); - expect(c.getState().files.map((f) => f.name)).toEqual(['a.ogg']); - }); -}); diff --git a/src/app/features/albums/shareController.ts b/src/app/features/albums/shareController.ts deleted file mode 100644 index 72f786c9..00000000 --- a/src/app/features/albums/shareController.ts +++ /dev/null @@ -1,135 +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; size?: number }; - -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.'; - -const MIB = 1024 * 1024; - -/** - * Files the homeserver would refuse for size (its `m.upload.size`, which Synapse enforces as a - * limit, so a file of exactly that size goes), split from the rest before anything is sent. A - * missing cap or size refuses nothing. - */ -export const splitBySize = (files: F[], cap: number | undefined) => { - const over = (f: F) => cap !== undefined && f.size !== undefined && f.size > cap; - return { sendable: files.filter((f) => !over(f)), tooLarge: files.filter(over) }; -}; - -const tooLargeNote = (names: string[], cap: number): string => - `Too large for this server (limit ${Math.round((cap / MIB) * 10) / 10} MB): ${names.join(', ')}`; - -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, signal: AbortSignal) => Promise; - /** The homeserver's upload cap in bytes (`m.upload.size`), when it states one. */ - maxBytes?: () => number | undefined; - /** 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>(); - let running: AbortController | undefined; - 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; - } - // Files over the cap are named and dropped from the pick before any upload or album root: a - // retry cannot help them, and a root with nothing under it is a lone message nobody lists. - const cap = deps.maxBytes?.(); - const { sendable, tooLarge } = splitBySize(state.files, cap); - const sizeNote = tooLarge.length - ? tooLargeNote( - tooLarge.map((f) => f.name), - cap as number - ) - : ''; - if (sendable.length === 0) { - const phase: SharePhase = { kind: 'done', text: `Nothing was shared. ${sizeNote}` }; - set({ files: [], title: '', epoch: state.epoch + 1, phase }); - return; - } - sending.add(deps.roomId); - const abort = new AbortController(); - running = abort; - const { title } = state; - const files = sendable; - set({ files, phase: { kind: 'sending', done: 0 } }); - let settled = 0; - try { - const res = await deps.run( - files, - title, - () => { - settled += 1; - set({ phase: { kind: 'sending', done: settled } }); - }, - abort.signal - ); - deps.onSent?.(); - const left = remainingAfter(files, res); - const phase: SharePhase = { - kind: 'done', - text: sizeNote ? `${resultNote(res)} ${sizeNote}` : 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) { - const failure = `Could not share: ${e?.message ?? 'send failed'}`; - set({ phase: { kind: 'done', text: sizeNote ? `${failure}. ${sizeNote}` : failure } }); - } finally { - running = undefined; - 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, - /** Stops a send in flight: the running upload is aborted and the rest are left picked. */ - stop: () => running?.abort(), - send, - }; -}; - -export type ShareController = ReturnType>; diff --git a/src/app/features/albums/shareSet.test.ts b/src/app/features/albums/shareSet.test.ts deleted file mode 100644 index 8fc4a8b8..00000000 --- a/src/app/features/albums/shareSet.test.ts +++ /dev/null @@ -1,328 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import config from '../../../../config.json'; -import { ZoneEvent, buildAlbums } from './buildAlbums'; -import { - DEFAULT_TITLE, - ShareFile, - SharePort, - CANCELLED, - freshest, - refusalOf, - remainingAfter, - resultNote, - sendShareSet, - threadRelation, -} from './shareSet'; - -const kindOf = (f: ShareFile) => (f.mimeType.startsWith('audio') ? 'm.audio' : 'm.file'); - -/** A fake room: records what is sent, as the events Synapse would return from /relations. */ -const room = (failOn: string[] = []) => { - const events: ZoneEvent[] = []; - const port: SharePort = { - send: async (content) => { - const id = `$e${events.length + 1}`; - events.push({ - event_id: id, - type: 'm.room.message', - sender: '@aqua:chat.gamecult.org', - origin_server_ts: 1000 + events.length, - content, - }); - return id; - }, - contentOf: async (f) => { - if (failOn.includes(f.name)) throw new Error('upload failed'); - return { - msgtype: kindOf(f), - body: f.name, - filename: f.name, - url: `mxc://x/${f.name}`, - info: { mimetype: f.mimeType }, - }; - }, - }; - const album = () => { - const [root, ...replies] = events; - return buildAlbums([{ root, replies }], 'chat.gamecult.org')[0]; - }; - return { events, port, album }; -}; - -const clips: ShareFile[] = [ - { name: 'a.ogg', mimeType: 'audio/ogg' }, - { name: 'b.ogg', mimeType: 'audio/ogg' }, - { name: 'c.txt', mimeType: 'text/plain' }, -]; - -describe('sendShareSet', () => { - it('reads back as one album of the files in pick order', async () => { - const r = room(); - const res = await sendShareSet(r.port, 'Take 4', clips); - expect(res).toEqual({ albumId: '$e1', sent: 3, failed: [], failedAt: [], reasons: [] }); - const album = r.album(); - expect(album.title).toBe('Take 4'); - expect(album.files.map((f) => [f.name, f.kind])).toEqual([ - ['a.ogg', 'audio'], - ['b.ogg', 'audio'], - ['c.txt', 'file'], - ]); - }); - - it('threads every file under the root, each falling back to the previous event', async () => { - const r = room(); - await sendShareSet(r.port, 't', clips); - const rels = r.events.slice(1).map((e) => e.content?.['m.relates_to']); - expect(rels).toEqual([ - threadRelation('$e1', '$e1'), - threadRelation('$e1', '$e2'), - threadRelation('$e1', '$e3'), - ]); - expect(r.events[0].content?.['m.relates_to']).toBeUndefined(); - }); - - it('titles an untitled share', async () => { - const r = room(); - await sendShareSet(r.port, ' ', clips.slice(0, 1)); - expect(r.album().title).toBe(DEFAULT_TITLE); - }); - - it('names a failed file and still sends the rest', async () => { - const r = room(['b.ogg']); - const res = await sendShareSet(r.port, 't', clips); - expect(res.failed).toEqual(['b.ogg']); - expect(res.sent).toBe(2); - expect(r.album().files.map((f) => f.name)).toEqual(['a.ogg', 'c.txt']); - // the next file falls back to the last event that exists, not to the failed one - expect(r.events[2].content?.['m.relates_to']).toEqual(threadRelation('$e1', '$e2')); - }); - - it('throws when the root cannot be sent, uploading nothing', async () => { - let uploads = 0; - const port: SharePort = { - send: async () => { - throw new Error('M_FORBIDDEN'); - }, - contentOf: async () => { - uploads += 1; - return {}; - }, - }; - await expect(sendShareSet(port, 't', clips)).rejects.toThrow('M_FORBIDDEN'); - expect(uploads).toBe(0); - }); -}); - -describe('the share flag', () => { - it('ships off', () => { - expect(config.fileZoneShare).toEqual({ enabled: false }); - }); -}); - -describe('after a send', () => { - it('keeps the failed one of two same-named files, not both', async () => { - const twins: ShareFile[] = [ - { name: 'a.ogg', mimeType: 'audio/ogg' }, - { name: 'a.ogg', mimeType: 'audio/ogg' }, - ]; - let n = 0; - const r = room(); - const port: SharePort = { - ...r.port, - contentOf: async (f) => { - n += 1; - if (n === 2) throw new Error('second upload failed'); - return r.port.contentOf(f); - }, - }; - const res = await sendShareSet(port, 't', twins); - expect(res.failedAt).toEqual([1]); - expect(remainingAfter(twins, res)).toEqual([twins[1]]); - }); - - it('keeps only the failed files picked, so a retry is one tap', async () => { - const r = room(['b.ogg']); - const res = await sendShareSet(r.port, 't', clips); - expect(remainingAfter(clips, res).map((f) => f.name)).toEqual(['b.ogg']); - expect(resultNote(res)).toBe('Shared 2. Not sent: b.ogg'); - }); - - it('keeps everything and says nothing was shared when every upload failed', async () => { - const r = room(clips.map((c) => c.name)); - const res = await sendShareSet(r.port, 't', clips); - expect(remainingAfter(clips, res)).toEqual(clips); - expect(resultNote(res)).toBe('Nothing was shared. Not sent: a.ogg, b.ogg, c.txt'); - }); - - it('clears the pick on full success', async () => { - const r = room(); - const res = await sendShareSet(r.port, 't', clips); - expect(remainingAfter(clips, res)).toEqual([]); - expect(resultNote(res)).toBe('Shared 3 files.'); - }); -}); - -describe('adding to an existing album', () => { - it('sends no root; each file threads under the album, the first after `lastId`', async () => { - const r = room(); - const sent: Record[] = []; - const port = { - ...r.port, - send: async (c: Record) => { - sent.push(c); - return `$n${sent.length}`; - }, - }; - const res = await sendShareSet( - port, - { albumId: '$root', lastId: '$last', lastTs: 1 }, - clips.slice(0, 2) - ); - expect(res.albumId).toBe('$root'); - expect(res.sent).toBe(2); - expect(sent.map((c) => c.msgtype)).toEqual(['m.audio', 'm.audio']); - expect(sent.map((c) => c['m.relates_to'])).toEqual([ - threadRelation('$root', '$last'), - threadRelation('$root', '$n1'), - ]); - }); -}); - -describe('refusals', () => { - const refuse = (errcode: string): SharePort => ({ - send: async () => '$e', - contentOf: async () => { - throw Object.assign(new Error('raw server text'), { errcode }); - }, - }); - - it('names the three upload refusals and nothing else', () => { - expect(refusalOf({ errcode: 'M_TOO_LARGE' })).toBe('too large for this server'); - expect(refusalOf({ errcode: 'M_FORBIDDEN' })).toBe('not allowed to upload here'); - expect(refusalOf({ errcode: 'M_LIMIT_EXCEEDED' })).toBe( - 'uploading too fast, try again shortly' - ); - expect(refusalOf({ errcode: 'M_UNKNOWN' })).toBeUndefined(); - expect(refusalOf({ errcode: 'constructor' })).toBeUndefined(); - expect(refusalOf(new Error('x'))).toBeUndefined(); - expect(refusalOf(null)).toBeUndefined(); - }); - - it('the note carries the reason beside each refused file', async () => { - const res = await sendShareSet(refuse('M_TOO_LARGE'), 't', clips.slice(0, 2)); - expect(resultNote(res)).toBe( - 'Nothing was shared. Not sent: a.ogg (too large for this server), b.ogg (too large for this server)' - ); - }); - - it('a failure the client saw, not the server, is named too', async () => { - const dropped: SharePort = { - send: async () => '$e', - contentOf: async () => { - throw Object.assign(new Error('x'), { - errcode: 'AbortError', - info: { aborted: true, progressed: true }, - }); - }, - }; - const res = await sendShareSet(dropped, 't', clips.slice(0, 1)); - expect(resultNote(res)).toBe( - 'Nothing was shared. Not sent: a.ogg (the connection dropped partway through)' - ); - }); - - it('an unnamed failure lists the bare name', async () => { - const res = await sendShareSet(refuse('M_UNKNOWN'), 't', clips.slice(0, 1)); - expect(resultNote(res)).toBe('Nothing was shared. Not sent: a.ogg'); - }); -}); - -describe('stopping a send', () => { - /** A port whose upload of `stopAt` aborts the signal as it runs, as the Stop button does mid-upload. */ - const stopping = (stopAt: string, abortAfterUpload = false) => { - const ac = new AbortController(); - const r = room(); - const port: SharePort = { - ...r.port, - signal: ac.signal, - contentOf: async (f) => { - if (f.name !== stopAt) return r.port.contentOf(f); - ac.abort(); - if (abortAfterUpload) return r.port.contentOf(f); - throw Object.assign(new Error('aborted'), { name: 'AbortError' }); - }, - }; - return { port, r }; - }; - - it('an upload aborted mid-file leaves that file and the rest unsent, named as stopped', async () => { - const { port, r } = stopping('b.ogg'); - const res = await sendShareSet(port, 't', clips); - expect(res.sent).toBe(1); - expect(res.failed).toEqual(['b.ogg', 'c.txt']); - expect(res.failedAt).toEqual([1, 2]); - expect(res.reasons).toEqual([CANCELLED, CANCELLED]); - expect(r.album().files.map((f) => f.name)).toEqual(['a.ogg']); - expect(remainingAfter(clips, res).map((f) => f.name)).toEqual(['b.ogg', 'c.txt']); - expect(resultNote(res)).toBe('Shared 1. Not sent: b.ogg (stopped), c.txt (stopped)'); - }); - - it('a file whose upload finished after the stop is not sent', async () => { - const { port, r } = stopping('b.ogg', true); - const res = await sendShareSet(port, 't', clips); - expect(res.sent).toBe(1); - expect(res.failedAt).toEqual([1, 2]); - expect(r.album().files.map((f) => f.name)).toEqual(['a.ogg']); - }); - - it('a signal already aborted starts nothing and sends everything back as unsent', async () => { - const r = room(); - const ac = new AbortController(); - ac.abort(); - let uploads = 0; - const res = await sendShareSet( - { - ...r.port, - signal: ac.signal, - contentOf: async (f) => { - uploads += 1; - return r.port.contentOf(f); - }, - }, - { albumId: '$a', lastId: '$l', lastTs: 1 }, - clips - ); - expect(uploads).toBe(0); - expect(res.sent).toBe(0); - expect(res.failedAt).toEqual([0, 1, 2]); - expect(r.events).toEqual([]); - }); - - it('an abort that is not a stop is an ordinary failure', async () => { - const r = room(['b.ogg']); - const res = await sendShareSet({ ...r.port, signal: new AbortController().signal }, 't', clips); - expect(res.failedAt).toEqual([1]); - expect(res.reasons).toEqual([undefined]); - }); -}); - -describe('freshest', () => { - const album = { albumId: '$alb', lastId: '$old', lastTs: 100 }; - it('moves lastId to a newer event of the same thread', () => { - expect( - freshest(album, [ - { id: '$mid', ts: 150, threadRoot: '$alb' }, - { id: '$new', ts: 200, threadRoot: '$alb' }, - ]) - ).toEqual({ albumId: '$alb', lastId: '$new', lastTs: 200 }); - }); - it('ignores older events and events of other threads or none', () => { - expect( - freshest(album, [ - { id: '$older', ts: 50, threadRoot: '$alb' }, - { id: '$other', ts: 900, threadRoot: '$else' }, - { id: '$loose', ts: 900 }, - ]) - ).toEqual(album); - }); -}); diff --git a/src/app/features/albums/shareSet.ts b/src/app/features/albums/shareSet.ts deleted file mode 100644 index 007d7e91..00000000 --- a/src/app/features/albums/shareSet.ts +++ /dev/null @@ -1,138 +0,0 @@ -// Share a set of files at once: one album root (a text message) and one thread reply per file. -// That is exactly the shape buildAlbums reads, so a set shared here and a set a Mind's mouth posts -// are the same thing to every viewer. Ports are injected so the order and the failure rules are -// testable without a homeserver. See docs/file-zone.md, "Share a set". - -import { uploadReason } from '../../utils/uploadReason'; - -export type ShareFile = { name: string; mimeType: string }; - -/** What sendShareSet needs from the outside world. */ -export type SharePort = { - /** Send an event and return its id. */ - send: (content: Record) => Promise; - /** Upload one file and build its message content (msgtype, url, info incl. sha256). */ - contentOf: (file: F) => Promise>; - /** Called after each file settles, for progress. */ - onFile?: (index: number, result: 'sent' | 'failed') => void; - /** Aborted to stop the send: the running upload is the port's to cancel, the rest are not started. */ - signal?: AbortSignal; -}; - -export type ShareResult = { - /** The album root's event id; present as soon as the root is sent. */ - albumId: string; - sent: number; - failed: string[]; - /** Positions in the files array of those that failed; names can repeat, positions cannot. */ - failedAt: number[]; - /** Why each failed file failed, parallel to `failed`, when the homeserver's refusal is one we name. */ - reasons?: (string | undefined)[]; -}; - -/** An album that already exists: new files join its thread after `lastId` (sent at `lastTs`), with no new root. */ -export type ExistingAlbum = { albumId: string; lastId: string; lastTs: number }; - -/** An event as the room's live timeline holds it: its id, time, and the thread root it belongs to. */ -export type ThreadEvent = { id: string; ts: number; threadRoot?: string }; - -/** - * `album` with `lastId` moved to the newest event the room's timeline holds in that thread, when it - * is newer than the one the album list knew. The list refreshes every 30 s; the timeline does not. - */ -export const freshest = (album: ExistingAlbum, events: ThreadEvent[]): ExistingAlbum => - events - .filter((e) => e.threadRoot === album.albumId) - .reduce((a, e) => (e.ts > a.lastTs ? { ...a, lastId: e.id, lastTs: e.ts } : a), album); - -export const CANCELLED = 'stopped'; - -/** The short reason for a failure we can name (the homeserver's refusal or what the client saw), else undefined. */ -export const refusalOf = (e: unknown): string | undefined => uploadReason(e)?.cause; - -/** The reply relation Matrix clients expect for a thread message: root, with the last event as fallback reply. */ -export const threadRelation = (rootId: string, lastId: string) => ({ - rel_type: 'm.thread', - event_id: rootId, - is_falling_back: true, - 'm.in_reply_to': { event_id: lastId }, -}); - -export const DEFAULT_TITLE = 'Shared files'; - -export const titleOrDefault = (title: string): string => title.trim() || DEFAULT_TITLE; - -/** - * Files go one at a time, in the order given, so thread order is pick order. A file that fails - * to upload is reported by name and the rest still go; the root is already out, so stopping would - * leave an empty album and the sender nothing to retry from. A root that fails to send throws: - * nothing exists yet. - */ -export const sendShareSet = async ( - port: SharePort, - into: string | ExistingAlbum, - files: F[] -): Promise => { - const albumId = - typeof into === 'string' - ? await port.send({ msgtype: 'm.text', body: titleOrDefault(into) }) - : into.albumId; - const result: ShareResult = { albumId, sent: 0, failed: [], failedAt: [], reasons: [] }; - let lastId = typeof into === 'string' ? albumId : into.lastId; - // A stopped send leaves its file, and every file after it, unsent and picked, so one tap resumes. - const stopFrom = (from: number) => { - for (let j = from; j < files.length; j += 1) { - result.failed.push(files[j].name); - result.reasons?.push(CANCELLED); - result.failedAt.push(j); - } - }; - for (let i = 0; i < files.length; i += 1) { - if (port.signal?.aborted) { - stopFrom(i); - break; - } - try { - // eslint-disable-next-line no-await-in-loop - const content = await port.contentOf(files[i]); - if (port.signal?.aborted) { - stopFrom(i); - break; - } - // eslint-disable-next-line no-await-in-loop - lastId = await port.send({ ...content, 'm.relates_to': threadRelation(albumId, lastId) }); - result.sent += 1; - port.onFile?.(i, 'sent'); - } catch (e) { - if (port.signal?.aborted) { - stopFrom(i); - break; - } - result.failed.push(files[i].name); - result.reasons?.push(refusalOf(e)); - result.failedAt.push(i); - port.onFile?.(i, 'failed'); - } - } - return result; -}; - -/** - * What stays picked after a send: only the files that did not go, so a retry is one tap. A retry - * of a new album makes a second album, 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[] => - files.filter((_, i) => result.failedAt.includes(i)); - -const failedList = (result: ShareResult): string => - result.failed - .map((name, i) => (result.reasons?.[i] ? `${name} (${result.reasons[i]})` : name)) - .join(', '); - -export const resultNote = (result: ShareResult): string => { - if (result.failed.length === 0) - return `Shared ${result.sent} ${result.sent === 1 ? 'file' : 'files'}.`; - if (result.sent === 0) return `Nothing was shared. Not sent: ${failedList(result)}`; - return `Shared ${result.sent}. Not sent: ${failedList(result)}`; -}; diff --git a/src/app/features/albums/useAlbums.ts b/src/app/features/albums/useAlbums.ts index 13c199ec..a01f1efb 100644 --- a/src/app/features/albums/useAlbums.ts +++ b/src/app/features/albums/useAlbums.ts @@ -7,11 +7,12 @@ import { useClientConfig } from '../../hooks/useClientConfig'; import { Album, ZoneEvent, buildAlbums } from './buildAlbums'; import { isFileZone } from './isFileZone'; import { mapLimit } from './mapLimit'; -import { Page, readPages } from './readPages'; const REPLY_FETCHES_IN_FLIGHT = 6; const REFRESH_MS = 30_000; +type Chunk = { chunk?: ZoneEvent[] }; + /** True when the room is the file zone: by its create type, or listed in the client config. */ export const useIsFileZone = (room: Room): boolean => { const config = useClientConfig(); @@ -28,49 +29,37 @@ export const useIsFileZone = (room: Room): boolean => { ); }; -export type AlbumsState = { - loading: boolean; - error: Error | null; - albums: Album[]; - /** A read hit the page cap (readPages), so older albums or files are not in the list. */ - truncated: boolean; -}; - -const PAGE_LIMIT = '100'; +export type AlbumsState = { loading: boolean; error: Error | null; albums: Album[] }; /** - * Reads every page of the zone's threads, then every page of each thread's replies, each capped at - * MAX_PAGES (readPages). Reads are made with the logged-in client, so the room must be - * unencrypted: encrypted events carry no msgtype and are skipped. + * Slice 1 reads ONE page of each endpoint (threads: 100 roots; replies per root: 100). A zone with + * more albums or files than that is truncated; pagination is a later slice. Reads are made with + * the logged-in client, so the room must be unencrypted: encrypted events carry no msgtype and + * are skipped. */ export const useAlbums = (room: Room): AlbumsState => { const mx = useMatrixClient(); const { roomId } = room; const query = useQuery({ queryKey: ['albums', roomId], - queryFn: async (): Promise<{ albums: Album[]; truncated: boolean }> => { + queryFn: async (): Promise => { const get = (path: string, params: Record) => - mx.http.authedRequest>(Method.Get, path, params, undefined, { + mx.http.authedRequest(Method.Get, path, params, undefined, { prefix: ClientPrefix.V1, }); - const paged = (path: string, params: Record) => - readPages((from) => get(path, from ? { ...params, from } : params)); const room_ = encodeURIComponent(roomId); - const roots = await paged(`/rooms/${room_}/threads`, { limit: PAGE_LIMIT }); - let { truncated } = roots; - const inputs = await mapLimit( - roots.items, - REPLY_FETCHES_IN_FLIGHT, - async (root: ZoneEvent) => { - const replies = await paged( - `/rooms/${room_}/relations/${encodeURIComponent(root.event_id ?? '')}/m.thread`, - { limit: PAGE_LIMIT, dir: 'f' } - ); - truncated = truncated || replies.truncated; - return { root, replies: replies.items }; - } - ); - return { albums: buildAlbums(inputs, mx.getDomain() ?? ''), truncated }; + const roots = (await get(`/rooms/${room_}/threads`, { limit: '100' })).chunk ?? []; + const inputs = await mapLimit(roots, REPLY_FETCHES_IN_FLIGHT, async (root: ZoneEvent) => ({ + root, + replies: + ( + await get( + `/rooms/${room_}/relations/${encodeURIComponent(root.event_id ?? '')}/m.thread`, + { limit: '100', dir: 'f' } + ) + ).chunk ?? [], + })); + return buildAlbums(inputs); }, retry: 1, refetchInterval: REFRESH_MS, @@ -79,7 +68,6 @@ export const useAlbums = (room: Room): AlbumsState => { loading: query.isLoading, // A failed refetch keeps the albums already loaded; the error shows only when there are none. error: query.data ? null : (query.error as Error | null), - albums: query.data?.albums ?? [], - truncated: query.data?.truncated ?? false, + albums: query.data ?? [], }; }; diff --git a/src/app/features/albums/useFileReaders.ts b/src/app/features/albums/useFileReaders.ts deleted file mode 100644 index 5ade3827..00000000 --- a/src/app/features/albums/useFileReaders.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { useMemo, useSyncExternalStore } from 'react'; -import { Room } from 'matrix-js-sdk'; -import { useMatrixClient } from '../../hooks/useMatrixClient'; -import { FileReader, readersOf } from './fileReceipt'; -import { receiptStoreOf } from './receiptStore'; - -/** - * Who has read a file, from the receipts in the room's loaded timeline: a plain room event or a thread reply, both - * count (docs/file-zone-mind-contract.md). `content` is the file event's content, which carries its `info.sha256`. - * The room's one shared subscription (receiptStore.ts) feeds every file row. - */ -export const useFileReaders = ( - room: Room, - fileId: string, - content: Record -): FileReader[] => { - const mx = useMatrixClient(); - const server = mx.getDomain() ?? ''; - const store = receiptStoreOf(room); - const receipts = useSyncExternalStore(store.subscribe, store.getSnapshot); - return useMemo( - () => readersOf(receipts, fileId, content, server), - [receipts, fileId, content, server] - ); -}; diff --git a/src/app/features/albums/versions.test.ts b/src/app/features/albums/versions.test.ts deleted file mode 100644 index 5179e945..00000000 --- a/src/app/features/albums/versions.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { PUBLISH_KEY, foldVersions, replacesOf } from './versions'; - -type F = { id: string; sender: string; content: Record; earlier: F[] }; -const f = (id: string, replaces?: string, sender = '@m:x'): F => ({ - id, - sender, - content: replaces ? { [PUBLISH_KEY]: { replaces } } : {}, - earlier: [], -}); -const shape = (files: F[]): unknown => files.map((x) => [x.id, x.earlier.map((e) => e.id)]); - -describe('replacesOf', () => { - it('reads the id the publisher key names', () => { - expect(replacesOf({ [PUBLISH_KEY]: { replaces: '$a' } })).toBe('$a'); - }); - it('anything else says nothing', () => { - expect(replacesOf({})).toBeUndefined(); - expect(replacesOf({ [PUBLISH_KEY]: {} })).toBeUndefined(); - expect(replacesOf({ [PUBLISH_KEY]: { replaces: 3 } })).toBeUndefined(); - expect(replacesOf({ [PUBLISH_KEY]: { replaces: '' } })).toBeUndefined(); - expect(replacesOf({ [PUBLISH_KEY]: 'x' })).toBeUndefined(); - }); -}); - -describe('foldVersions', () => { - it('files that replace nothing pass through in order, with nothing folded', () => { - expect(shape(foldVersions([f('$a'), f('$b')]))).toEqual([ - ['$a', []], - ['$b', []], - ]); - }); - - it('shows the latest of a chain with earlier versions nearest first', () => { - expect(shape(foldVersions([f('$a'), f('$b', '$a'), f('$c', '$b')]))).toEqual([ - ['$c', ['$b', '$a']], - ]); - }); - - it('keeps unrelated files beside a chain, in order', () => { - expect(shape(foldVersions([f('$a'), f('$x'), f('$b', '$a')]))).toEqual([ - ['$x', []], - ['$b', ['$a']], - ]); - }); - - it('a replacement whose target is not in the list stands alone', () => { - expect(shape(foldVersions([f('$b', '$gone')]))).toEqual([['$b', []]]); - }); - - it('a file two others replace sits under both', () => { - expect(shape(foldVersions([f('$a'), f('$b', '$a'), f('$c', '$a')]))).toEqual([ - ['$b', ['$a']], - ['$c', ['$a']], - ]); - }); - - it('files in a loop with no head outside it are all shown, none hidden', () => { - const out = foldVersions([f('$a', '$b'), f('$b', '$a'), f('$s', '$s')]); - expect(shape(out)).toEqual([ - ['$a', []], - ['$b', []], - ['$s', []], - ]); - }); - - it('a loop reached from a head is walked once and ends', () => { - const out = foldVersions([f('$a', '$b'), f('$b', '$a'), f('$h', '$a')]); - expect(shape(out)).toEqual([['$h', ['$a', '$b']]]); - }); - - it('a replacement by another sender folds nothing', () => { - const out = foldVersions([f('$victim', undefined, '@m:x'), f('$evil', '$victim', '@e:x')]); - expect(shape(out)).toEqual([ - ['$victim', []], - ['$evil', []], - ]); - }); - - it('an empty list is empty', () => { - expect(foldVersions([])).toEqual([]); - }); -}); diff --git a/src/app/features/albums/versions.ts b/src/app/features/albums/versions.ts deleted file mode 100644 index e27ff6d0..00000000 --- a/src/app/features/albums/versions.ts +++ /dev/null @@ -1,56 +0,0 @@ -// Pure: a file event may name the event it supersedes in `org.gamecult.publish.replaces` (the -// publisher writes it on a later final version; docs/commons-files-desk.md, "replaces, not -// m.replace"). Both events stay ordinary files for every other client; this view shows the latest -// of a chain with the earlier ones folded under it. A chain is read from the events alone. - -/** Key the publisher writes its provenance under. */ -export const PUBLISH_KEY = 'org.gamecult.publish'; - -/** The event id a file says it replaces, if it says one. */ -export const replacesOf = (content: Record): string | undefined => { - const id = (content[PUBLISH_KEY] as { replaces?: unknown } | undefined)?.replaces; - return typeof id === 'string' && id ? id : undefined; -}; - -type Versioned = F & { earlier: F[] }; - -/** - * Files not replaced by another file in the list, oldest first as given, each carrying the files it - * supersedes (nearest first) in `earlier`. A replacement whose target is missing from the list (a - * redacted event, another thread) replaces nothing and stands as a head. A replacement by another sender is ignored. A file two others claim to - * replace sits under both. The walk refuses to revisit a file, so a malformed chain cannot loop (a real event id hashes its content and cannot cycle); files caught in a loop with no head outside it are all shown, none folded. - */ -export const foldVersions = < - F extends { id: string; sender: string; content: Record } ->( - files: Versioned[] -): Versioned[] => { - const byId = new Map(files.map((f) => [f.id, f])); - const target = (f: F): F | undefined => { - const id = replacesOf(f.content); - const t = id && id !== f.id ? byId.get(id) : undefined; - // Only the sender of a file can supersede it, so no one else in the thread can fold it away. - return t && t.sender === f.sender ? t : undefined; - }; - const replaced = new Set(); - files.forEach((f) => { - const t = target(f); - if (t) replaced.add(t.id); - }); - const chainOf = (head: Versioned): Versioned[] => { - const out: Versioned[] = []; - const seen = new Set([head.id]); - for (let t = target(head); t && !seen.has(t.id); t = target(t)) { - seen.add(t.id); - out.push({ ...t, earlier: [] }); - } - return out; - }; - const heads = files.filter((f) => !replaced.has(f.id)); - const covered = new Set(heads.flatMap((h) => [h.id, ...chainOf(h).map((e) => e.id)])); - // Files that replace one another with no head outside the loop are never reached from a head; show - // each of them as it is rather than let the loop hide itself. - return files - .filter((f) => !replaced.has(f.id) || !covered.has(f.id)) - .map((f) => ({ ...f, earlier: replaced.has(f.id) ? [] : chainOf(f) })); -}; 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 d6795ac4..f1695716 100644 --- a/src/app/features/desk/Desk.tsx +++ b/src/app/features/desk/Desk.tsx @@ -1,12 +1,11 @@ -import React, { useMemo } from 'react'; +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'; import { DeskView } from './DeskView'; -import { useUnverified } from './useUnverified'; +import { unverifiedForks } from './optionsHash'; import { deskFor } from './source'; const labelOf = (mind: string) => mind.charAt(0).toUpperCase() + mind.slice(1); @@ -19,7 +18,14 @@ export function Desk() { const label = labelOf(mind); const list = useMemo(() => deskFor(mind), [mind]); // Until the recompute finishes nothing is flagged; a card that fails is withdrawn when it lands. - const unverified = useUnverified(list); + const [unverified, setUnverified] = useState>(new Set()); + useEffect(() => { + let live = true; + unverifiedForks(list?.forks ?? []).then((bad) => live && setUnverified(bad)); + return () => { + live = false; + }; + }, [list]); return ( @@ -27,11 +33,7 @@ export function Desk() { {phone && ( - navigate(getHomePath())} - > + navigate(getHomePath())}> )} diff --git a/src/app/features/desk/DeskEntry.tsx b/src/app/features/desk/DeskEntry.tsx index 511b0783..a36ec8e4 100644 --- a/src/app/features/desk/DeskEntry.tsx +++ b/src/app/features/desk/DeskEntry.tsx @@ -1,46 +1,16 @@ -import React, { useMemo } from 'react'; -import { Box, Icon, Icons, Text } from 'folds'; -import { NavGutter, NavItem, NavItemContent, NavLink } from '../../components/nav'; -import { NAV_ROOM_ICON_REST_OPACITY, NavLabel } from '../../components/nav/styles.css'; +import React from 'react'; +import { Link } from 'react-router-dom'; +import { Text } from 'folds'; import { getDeskPath } from '../../pages/pathUtils'; -import { buildDesk } from './buildDesk'; -import { ForkList } from './forkContract'; import { deskFor } from './source'; -import { useUnverified } from './useUnverified'; -export function DeskLink({ mind, open }: { mind: string; open: number }) { +/** The way into a Mind's desk, set in its navigator group header; nothing at all when nothing is open. */ +export function DeskEntry({ mind }: { mind: string }) { + const open = deskFor(mind)?.open_count ?? 0; + if (open === 0) return null; return ( - - - - - - - - - {open > 0 ? `Desk · ${open} open` : 'Desk'} - - - - - + + {`Desk · ${open} open`} + ); } - -/** The navigator's count: the desk page's own, and none until the hash check has landed. */ -export const openCount = ( - list: ForkList | undefined, - unverified: ReadonlySet | undefined -): number => (unverified ? buildDesk(list, unverified).counts.open : 0); - -/** A Mind's desk, pinned at the top of its navigator group: always there, with the open count when there is one. */ -export function DeskEntry({ mind }: { mind: string }) { - const list = useMemo(() => deskFor(mind), [mind]); - const unverified = useUnverified(list); - // The same count the desk page shows; until the hash check lands there is no count to show. - return ; -} 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 9fde039b..ede59229 100644 --- a/src/app/features/desk/DeskView.test.tsx +++ b/src/app/features/desk/DeskView.test.tsx @@ -1,19 +1,13 @@ 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, DeskLink, openCount } from './DeskEntry'; -import { DeskView, SIGN_IN_NOTE, rowOpen } from './DeskView'; -import { ForkList } from './forkContract'; -import { STATES, entry as forkEntry, list as forkList } from './fixtures'; +import { DeskEntry } from './DeskEntry'; +import { DeskView, SIGN_IN_NOTE } from './DeskView'; +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 +65,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( @@ -119,8 +100,8 @@ describe('DeskView', () => { const html = view('noAnswers', ScreenSize.Desktop, 'void', new Set(['c:question:plain'])); expect(html).toContain('does not match the hash it was published with'); expect(html).not.toContain('First way'); - expect(html).toContain('Nothing is waiting on you from Void.'); - expect(html).not.toContain('1 open.'); + expect(html).toContain('1 open.'); + expect(html).not.toContain('Nothing is waiting'); }); it('never claims an empty desk while the list says forks are open, whatever rows it could show', () => { setDeskSource(() => undefined); @@ -132,29 +113,6 @@ describe('DeskView', () => { expect(html).toContain('3 open.'); expect(html).not.toContain('Nothing is waiting'); }); - it('says every row is read-only for a signed-out viewer, in every state the fixtures hold', () => { - (['noAnswers', 'blocked', 'answered', 'settled'] as const).forEach((state) => { - const html = view(state, ScreenSize.Desktop); - const rows = html.match(/