Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 39c9924042 | |||
|
|
c6fb57d9f9 | ||
|
|
5310f606cd | ||
| 934f82386d | |||
|
|
a61252e664 | ||
|
|
1b101c2889 | ||
| b13080ebd2 | |||
|
|
ed2205d6d6 | ||
|
|
33e31a6102 | ||
| d5b9e33536 | |||
|
|
6b40809f9d | ||
| 38b2c064b2 | |||
|
|
ec2637a2bb | ||
|
|
c36b6e2cd9 | ||
| bc097d0490 | |||
| 398b172e21 | |||
|
|
907635917e | ||
|
|
d3894f5b51 | ||
|
|
65d7e7a46a | ||
| da822e9e4e | |||
|
|
0271d5f692 | ||
| 08be0a792d | |||
|
|
6a209649dc | ||
|
|
b8741f9dcc | ||
| 0b3e2294ca | |||
|
|
d42584cd72 | ||
| f3d0ebdb06 | |||
|
|
cf1ecd64da | ||
| ca3046e662 | |||
|
|
817f2e46d6 | ||
| a078346ecc | |||
|
|
c98d0175ff | ||
|
|
e406ec7dc4 | ||
| d12e7bd578 | |||
|
|
90c6d5e278 | ||
|
|
0622d7d65d | ||
| f491df04f9 | |||
|
|
5835e565cd | ||
|
|
796bc161a9 | ||
|
|
ac6018b57c | ||
| 143bcc2dc6 | |||
|
|
6aaee8badd | ||
|
|
01dfb155c5 | ||
| cf8b16ff5d | |||
|
|
036146ce40 | ||
| bb6155709a | |||
|
|
b8eed50bd7 | ||
|
|
53a724ae27 | ||
| 229591e22c | |||
|
|
1455988a83 | ||
|
|
a8e3acbde0 | ||
|
|
0ab185bb57 | ||
|
|
a8fc78ff55 | ||
|
|
88fd0a889a | ||
| 08bf515997 | |||
| 683d3b92f8 |
47 changed files with 2752 additions and 201 deletions
34
deployment/idunn/recipe.toml
Normal file
34
deployment/idunn/recipe.toml
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
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 934f82386d612c3e056f851555c7c9dfd2bdf5db: 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-934f8238/ratatoskr-934f8238-dist.tar.gz"
|
||||
sha256 = "55e7be0622a4eddb81008bc0132e3d476bdb3c453e278599d5a0259a76d00c5b"
|
||||
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"
|
||||
291
docs/commons-files-desk.md
Normal file
291
docs/commons-files-desk.md
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
# 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, `#<mind>-files:<server>`, 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-<mind>` 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 `<slug>` | 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-<slug>-outbox`, owned by `mind-<slug>-self`, 0750, ACL read for `mind-<slug>` 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=<p>` 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=<p>` 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 `<zone>/<relative path>`, 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 <token>`. 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=<p>` 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
|
||||
|
||||
`<audio src>`, `<video src>` and `<img src>` cannot attach `Authorization`. Options, none free:
|
||||
|
||||
1. **Fetch to a blob.** Works with the contract as written. No seeking before the whole file is in memory; bad for a 40 MB clip on a phone. Fine for images and small audio.
|
||||
2. **Short-lived path ticket.** `POST /ticket?path=` with the bearer token returns a URL with an unguessable token valid for a few minutes for that one path and its ranges. The player gets a normal URL and seeks. One more endpoint on the host and one more thing to expire and log.
|
||||
3. **Service worker adds the header** to `/files-host/*` requests. The PWA already has a worker (`src/sw.ts`); no host change, but the worker must carry a fresh OpenID token and range requests through it are fiddly.
|
||||
|
||||
Recommendation: 2 for audio and video, 1 for images and text. Confidence moderate: I have not tested a service worker against range requests on the target phone, and 2 is the usual answer for authenticated media (signed URLs). **Q-E.**
|
||||
|
||||
### Upload flow (host)
|
||||
|
||||
Design only. Every number and error code is a named placeholder until Eureka gives them on the PR: `<CAP_FILE>` (per-file size), `<CAP_BATCH>` (per-pick total, if any), `<TYPES>` (the allowlist), `<OVERWRITE_FLAG>` (the explicit query flag), and the codes `<E_AUTH>`, `<E_ROLE>`, `<E_PATH>`, `<E_TYPE>`, `<E_SIZE>`, `<E_EXISTS>`, `<E_PROTECTED>`, `<E_QUOTA>`, `<E_SERVER>`.
|
||||
|
||||
**Where.** The control exists only where an upload is allowed: inside a folder under a zone's `uploads/` subtree (or at the `uploads/` row itself), and only when the index `role` is member or mind. Elsewhere there is no button; the list does not carry a disabled one. A folder outside `uploads/` shows a one-line dim note, "Uploads go in uploads/", which links to that folder. Creating subfolders under `uploads/` is not in this design (**Q-E8**).
|
||||
|
||||
**Pick or drop.** One `Upload` row at the top of an `uploads/` folder opens the native picker (any number of files; on a phone this is the OS file sheet, camera roll included). On wide screens the list is also a drop target; the drop highlight appears only while a drag is over it. Nothing starts on pick: the picked files appear as a staging list (name, size, a type icon) with `Upload` and `Cancel` as full-width rows, as `ShareSet` does for a Matrix set. Files that already fail the client's cheap checks (over `<CAP_FILE>`, a type outside `<TYPES>`) are marked in the staging list with the reason and left out of the send; the client mirrors the cap and allowlist as a courtesy only, the host is the authority, and the list is read from the host (**Q-E9**: an `/index` field or a `/limits` call) rather than hard-coded.
|
||||
|
||||
**Send.** Files go one at a time, in pick order, each a `PUT /file?path=<folder>/<name>` with the bearer token. One at a time keeps a phone on mobile data from starving every transfer, and a refusal names exactly one file. The transport is `XMLHttpRequest`, not `fetch`: `fetch` has no upload progress and the row needs it. The body is the `File` itself (streamed by the browser, not read into memory; unlike the Matrix path, the client computes no hash first, the host returns one). The name is the file's own name, with path separators and control characters replaced, never a path the user typed.
|
||||
|
||||
**Progress.** The staging list becomes the progress list: per file a thin bar and "412 KB of 3.1 MB", per batch "2 of 5". Finished files show a tick and the stored name; refused ones show the refusal in place; waiting ones are dim. Only the active file animates. The list stays on screen after the last file until dismissed, so a refusal is not lost to a refresh of the folder underneath it. The folder listing refetches after each success, so the file appears in the list as it lands.
|
||||
|
||||
**Cancel.** `Cancel` on the active file aborts the request (`xhr.abort()`) and goes on to the next; `Cancel all` on the batch aborts the active file and drops the rest. The host must discard an aborted partial write and never leave a half file in the listing (**Q-E10**, written to a temporary name and renamed on completion). Leaving the page aborts whatever runs, as the Matrix share does; the client does not resume.
|
||||
|
||||
**Overwrite.** A name that already exists is refused with `<E_EXISTS>` and shown as "<name> is already there". A `Replace` row appears under that refusal only for a file the caller wrote themselves, and sends the same `PUT` with `<OVERWRITE_FLAG>`; a Mind-written handoff file refuses with `<E_PROTECTED>` and offers only "Keep both", which re-sends under a suffixed name (`name (2).ext`) chosen by the client. Whether the client can tell "mine" from the index (an `owner` field with the Matrix id) or learns it only from the refusal is **Q-E11**; absent an owner field, `Replace` is simply offered after `<E_EXISTS>` and the host's answer decides.
|
||||
|
||||
**Refusal messages.** One plain sentence per code, shown in the file's row; nothing says "error" without saying why, and none names a server path.
|
||||
|
||||
| Code | Message (draft) |
|
||||
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `<E_AUTH>` | Sign-in expired. Reload to upload again. (The client fetches a fresh OpenID token once and retries before showing this.) |
|
||||
| `<E_ROLE>` | Uploads are for members and Minds. |
|
||||
| `<E_PATH>` | That folder does not take uploads. |
|
||||
| `<E_TYPE>` | "<name>" is a <type> file; allowed types are <TYPES>. |
|
||||
| `<E_SIZE>` | "<name>" is <size>; the limit is <CAP_FILE>. |
|
||||
| `<E_EXISTS>` | "<name>" is already there. |
|
||||
| `<E_PROTECTED>` | "<name>" was written by a Mind and cannot be replaced. |
|
||||
| `<E_QUOTA>` | The shared space is full. Tell a steward. |
|
||||
| `<E_SERVER>` / network / timeout | Could not reach the file host. The file was not saved. |
|
||||
|
||||
Retry is one tap on a failed row; the batch never retries on its own. An unknown code shows the host's status text as it is, still without a path.
|
||||
|
||||
**Honest end state.** Done means the host answered `201` and the stored name, size and hash are shown. "Saved" is not said earlier; the hash the host returns is displayed so a sender can compare it with their own.
|
||||
|
||||
**Reuse and not.** `ShareSet`'s state machine (staging, one-send-per-zone guard, failed files kept by position) is the model, but the Matrix share path and its `createUploadAtom` are tied to the SDK's media upload and are not reused; the new controller sits in `features/files/uploadController.ts` with the same injected-port shape, so order, refusal and cancel rules are tested against a fake `PUT` without a browser. Real transfer progress and a real range of a phone's picker stay a phone check.
|
||||
|
||||
### Client shape (host)
|
||||
|
||||
Under `src/app/features/files`, mirroring `features/albums`:
|
||||
|
||||
- `filesHost.ts`: ports, `list(path)`, `url(path)`, `ticket(path)`, `put(path, file, onProgress, signal)`; the only module that knows the contract. Injected, so the UI is tested against a fake and the real client never runs in a test.
|
||||
- `useFilesZone.ts`: react-query on `['files', zone, path]`, `staleTime` 30 s; the role comes with every index.
|
||||
- `FilesDesk.tsx`, `FileRow.tsx`, `FilePreview.tsx`: pure rendering from the above; the preview picks its renderer from the host's `mime`, not the extension.
|
||||
- `config.json`: `filesDesk: { enabled: false, host: "https://…" }`, dark by default as `fileZoneShare.enabled` is. With it off, no row mounts and no request leaves the client.
|
||||
- Test host: `src/preview-demo` already answers for a pretend homeserver through a fetch guard (`install.ts`); it gains a fake `/index` and `/file` with ranges so the previews are checked in a real browser before the real host exists.
|
||||
|
||||
### Cuts (host)
|
||||
|
||||
1. **Contract and fake host** (no UI): `filesHost.ts`, types, `preview-demo` fake, path tests (traversal strings from the host side are Eureka's; the client must only build `path` from entries it was given).
|
||||
2. **Read-only Commons desk**: route, row, list, breadcrumb, role display, flagged off.
|
||||
3. **Preview and player**: image, audio with ranges, text, video; the ticket if Q-E settles on 2.
|
||||
4. **Per-Persona rows** in each Mind's group, once the outbox `shared/` convention and the zone provisioning exist.
|
||||
5. **Uploads**: the flow above; needs Eureka's codes and caps and the host's `PUT`.
|
||||
|
||||
Each is its own PR. Slices 1 to 3 need no host to build and test; slice 2 needs the real host to be useful, not to land.
|
||||
|
||||
### Not in scope (host)
|
||||
|
||||
The Matrix file zone (marks, receipts, `ShareSet`); any Mind-side fetch (the mouth's); encrypted rooms; deletion and rename (a read surface first; the files belong to whoever wrote them on disk); search across zones; a permission model of our own beyond what the host derives from Matrix identity.
|
||||
|
||||
### Open questions (host)
|
||||
|
||||
**For Eureka (host):**
|
||||
|
||||
- **Q-E1.** Which roots form Commons: the allowlist, and whether `handoff` is ever served (it holds custody receipts and ops notes; recommendation: no, a reviewed `commons/` directory instead).
|
||||
- **Q-E2.** Where the host runs and what it is: Rust by default per doctrine; nginx with `auth_request` to a verifier is smaller if the sniff, range and role logic fit there. Which user; read-only mounts; ACLs on the outboxes.
|
||||
- **Q-E3.** Media-element auth: blob, ticket or service worker (above).
|
||||
- **Q-E4.** Role source: how the host learns membership and which Minds are Minds; token verification pinned to `gamecult.org`.
|
||||
- **Q-E5.** Per-Persona publishing: a `shared/` subdirectory versus a marker, and who populates it when the Persona decides.
|
||||
- **Q-E6.** Hash cost: compute lazily and cache by (path, mtime, size), or at publish time.
|
||||
- **Q-E7.** CORS for `chat.gamecult.org` and the dev previews, rate limits, and the `Content-Disposition` list.
|
||||
- **Q-E8.** Subfolders under `uploads/`: can a client create them (by a `PUT` path that implies them, or a `MKCOL`-like call), or is `uploads/` flat?
|
||||
- **Q-E9.** Cap and allowlist discovery: fields in the index response, or a `/limits` endpoint; and the exact codes and cap numbers behind every placeholder in the upload flow.
|
||||
- **Q-E10.** Partial writes: temporary name then rename, so an aborted or failed upload never appears in a listing.
|
||||
- **Q-E11.** Does the index carry an `owner` Matrix id per file (so `Replace` is offered only on one's own files), and is the audit log readable by stewards from the client or only on the host?
|
||||
|
||||
**For Meta:**
|
||||
|
||||
- **Q-M1.** Audience: Commons for members and Minds as proposed; are Users and Patrons ever in (the Matrix zone's audience question, `ratatoskr:question:file-zone-audience`, is the same fork)?
|
||||
- **Q-M2.** Is a Persona's zone visible to all members or to her stewards and Minds?
|
||||
- **Q-M3.** ~~Uploads~~ approved for members and Minds (2026-11-13 14:30Z); open only: may Users or Patrons upload if they are ever let in?
|
||||
- **Q-M4.** Name: "Files" for the row, "Commons" for the shared zone.
|
||||
- **Q-M5.** Does the Matrix Media row stay beside the Files row on each Mind, or does one absorb the other once the Files desk exists (recommendation: both stay; marks and receipts are Matrix's).
|
||||
|
|
@ -120,4 +120,4 @@ Client, `src/app/features/albums`:
|
|||
|
||||
Flag: `fileZoneShare.enabled` in `config.json`, default `false`. With it off nothing mounts.
|
||||
|
||||
Not built: a Mind's own path into the zone (outbox, Persona decision, mouth post) belongs to gamecult-ops / Huginn / the mouth, not this client; sharing into an existing album ("add files"); upload cancel; pagination beyond 100 albums/files.
|
||||
Not built: a Mind's own path into the zone (outbox, Persona decision, mouth post) belongs to gamecult-ops / Huginn / the mouth, not this client; Built since: Stop for a send in flight (aborts the running upload, leaves it and the rest picked as "stopped"), pagination to 20 pages of 100 per endpoint (PR #148), adding files to an open album with named refusals (`M_TOO_LARGE`, `M_FORBIDDEN`, `M_LIMIT_EXCEEDED`).
|
||||
|
|
|
|||
|
|
@ -97,9 +97,11 @@ Probes run by Imagination on 2026-10-31, session `self-2026-10-29-eureka`.
|
|||
events from the Minds' stewardship documents; each project room's alias is
|
||||
`#<repo lowercased>` (runbook 146-150). This is the only place the client
|
||||
can learn a not-yet-joined room's steward today: hierarchy `canonical_alias`
|
||||
joined to the root `org.gamecult.stewardships`. Follow-up
|
||||
`ratatoskr:follow_up:stewardships-carry-room-ids` asks the projection to
|
||||
name room ids so the client stops leaning on the alias convention.
|
||||
joined to the root `org.gamecult.stewardships`. The
|
||||
projection now also names room ids under `@rooms.projects` and
|
||||
`@rooms.file_zones` (gamecult-ops #111); the client reads `file_zones` for
|
||||
the Media row (`mediaRoom.ts`) and still joins projects by alias, since
|
||||
nothing yet consumes `@rooms.projects`.
|
||||
5. **Sync and storage** (`src/client/initMatrix.ts`). The client uses
|
||||
matrix-js-sdk 41.7.0 with `IndexedDBStore` (`web-sync-store`, 15-19) and the
|
||||
Rust crypto store, and `startClient({ lazyLoadMembers: true })` (43-47): v2
|
||||
|
|
|
|||
101
docs/gamecult/symphony-grammar-harvest.md
Normal file
101
docs/gamecult/symphony-grammar-harvest.md
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
# Symphony grammar harvest: Mimir's bioacoustic research
|
||||
|
||||
Follow-up: `ratatoskr:follow_up:symphony-grammar-harvest-mimir`. Source: GameCult/Mimir at
|
||||
commit c6f4541eea61 (exists; read at that ref, not main), read-only through the forge API.
|
||||
Paths below are Mimir paths. Written for Aqua (AquaSynth) before the generative activity grammar. It lives here because the symphony is a Ratatoskr cut (dings, then orbiting personas) and this repo is where its spec will be read; the follow-up named AquaSynth, where void holds no push right, so move it there if Aqua prefers.
|
||||
|
||||
## 1. The problem
|
||||
|
||||
One Mind's identity has to be a timbre plus a contour that stays recognisable after it is mixed with
|
||||
the other Minds and passed through a lossy path. The symphony adds harmony (several Minds sounding
|
||||
together as music), which Mimir never needed: Mimir emits one voice at a time, a low-gain song per speaker.
|
||||
|
||||
## 2. What Mimir found that transfers
|
||||
|
||||
Confidence: A = measured number in a receipt, B = measured but synthetic or single run,
|
||||
C = design claim or authored opinion with no measurement behind it.
|
||||
|
||||
| Claim | Evidence | Conf. |
|
||||
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- |
|
||||
| Tidy lab beeps are the wrong signal for a room; a birdsong-like motif replaced them. | `docs/bioacoustic-timeline-watermark.md` L3-10 (stated as a decision, no listening test). | C |
|
||||
| Identity is carried redundantly: contour survives pitch shift, rhythm survives bad frequency response, formant envelope survives a dead band, speaker variants expose room asymmetry. | same doc L82-95. Argument, not an ablation. | C |
|
||||
| A word needs no sequence grammar: one heard word identifies its event directly (128 words x 2 speaker variants, 4 syllables each), in an event slot that repeats about every 41 s (derived, not reported: 128 words x 2 speakers = 256 events at 0.16 s spacing = 40.96 s, from `MimirBioacousticTimeline.cs` L34-39 and `SymbolForEvent` L735-739; no receipt or doc states it). No de Bruijn grammar. | same doc L26-33; `src/Mimir.Runtime/Synchronization/MimirBioacousticTimeline.cs` (WordCount, SymbolCount = WordCount\*SpeakerCount, BuildMotifs ~L690-730). | B |
|
||||
| Word identity survives mel-cepstral warp/blur degradation; timing does not unless fitted as one global clock/delay hypothesis. | `docs/bioacoustic-training-harness.md` L58-112: identity 0.908-0.997 on clean input only; degraded identity 0.532-0.908 and timing 0.187-0.728 for compact-fast-index across the four degraded cases in `artifacts/bioacoustic-training/bioacoustic-20260524-150729/compact-fast-index/*/summary.json` (`IdentityScore`, `TimingScore`); clean timing 0.357 before vs 0.905 after the global clock fit. | A (synthetic, clean-only range) |
|
||||
| Contestant shapes were scored against the same decoder family and damage panel; the best scorer was the six-syllable "canary-packet-trill", a hypothesis not confirmed on a physical room (two timing chips plus four payload-coloured formant notes), not the literal bird imitations. | `research/bioacoustic-contestants-2026-05-24.md` L24-38, L128-174 (honest floor 88.4 language_score, 19 bps, payload 1.000 clean), L487-489 (96 ms motif is the best tradeoff). | B |
|
||||
| Too-short or too-tight songs fail under blur: 0.100 s event spacing lost anchors to overlap; 0.105 s kept; 78 ms motif and 5-syllable finch-burst-packet collapsed (blur-light 4.358, the razor decoder; 218.368 with the sprint decoder, L453-454). | contestants doc L169-171, L446-455, L487-489. | A |
|
||||
| A 3-bit payload variant (separate band/rhythm bit) did not survive degradation (best 12.849, clean 8.084). | contestants doc L183-185. | A |
|
||||
| Real birds carry identity through redundant contour, roughness, onset statistics and cepstral envelope together; Redpoll is dense chatter with short vertical attacks, not a neat token stream; Guan is a slow broad curved phrase, good for probing the channel, poor for bitrate. The packet song was judged "still too ladder-like". | contestants doc L565-581 (two Xeno-canto mirrors via Wikimedia; Brown Thornbill file invalid, L511-513, L534-539). Flux numbers L549-562. | B (two recordings, hand reading) |
|
||||
| Positive log-mel spectral flux is a good onset/anchor surface; sustained bands recede, attacks and glides light up. | contestants doc L541-545, L577-581; but the naive flux decoder lost on speed (33.466 vs 88.129 score, clean roundtrip, L158 and L231-237). | B |
|
||||
| Once the schedule is known, the receiver only discriminates among a few local candidates; wide identity search is only for acquisition. Streaming decoder reached 1121 score at 62.6x realtime, payload 1.000 on all nine damage cases. | contestants doc L248-309. | A (synthetic damage) |
|
||||
| Polarity must be a nuisance variable; mic paths flip sign. | contestants doc L391-395. | B (physical, one capture) |
|
||||
| Physical room run: with per-channel acquisition offsets the co-streamer shotgun mic (ch1) locked 37/37 events, payload 1.000, timing 0.828; the cardioid/near mic (ch0) reached 26/37, payload 0.595, and stayed unreliable. | contestants doc L371-388. | B (physical, one capture) |
|
||||
| Speaker split: left and right vocabularies differ in root (x0.94 / x1.08) and syllable weights, so each side is separable. | `MimirBioacousticTimeline.cs` BuildMotifs ~L704-725 (read as code, no experiment isolates the benefit). | C |
|
||||
|
||||
Rendering recipe actually in code (`MimirBioacousticTimeline.cs` AddSyllable ~L610-640): each syllable is
|
||||
a log-frequency glide (exponential sweep) with a raised-cosine envelope, summed partials at
|
||||
1, 1.5, 2, 2.75 times the glide (weights 1, 0.42, 0.24, 0.10) plus 0.08 weight of a slow "air" term,
|
||||
20-32 ms long, four per word, gain 0.030 (canary profile 0.024 in `MimirBioacousticContestant.cs`).
|
||||
Word roots are log-spaced 2.6 to 9.6 kHz; syllable frequencies are clamped 2.3 to 14.6 kHz;
|
||||
word length 118 ms. Contours come from a small hand-written bank (8 contour shapes and 8 rhythm
|
||||
offset sets, chosen by hash of word and speaker).
|
||||
|
||||
## 3. Design constraints for Aqua's grammar
|
||||
|
||||
1. Identity lives in several channels at once: contour (syllable glide shapes), log-frequency position
|
||||
(root), formant envelope (the partial ratios), rhythm (inter-syllable offsets) and per-speaker
|
||||
timbre. No single channel may be load-bearing. Place Minds on a log-frequency axis, not linear.
|
||||
2. Do not build a sequence grammar for identity (de Bruijn or otherwise). The whole word is the unit;
|
||||
a hearer should identify the Mind from one short phrase. (watermark doc L31-33.) What it gives up:
|
||||
a de Bruijn code has a provable recovery property from a symbol stream; per-word identity has only
|
||||
an empirical one.
|
||||
3. Keep words short and distinct, but not so tight that they overlap: Mimir's measured edge was about
|
||||
100 to 105 ms event spacing and a 96 ms six-syllable motif; below that identity decayed. Aqua's
|
||||
events need not carry timing, so it can relax this, which is untested.
|
||||
4. Use sharp onsets for placement but avoid a ladder of evenly spaced rungs; Mimir's own verdict was
|
||||
that its packet song was still too ladder-like and real birds use irregular, redundant contour
|
||||
(contestants doc L573-576). Vary rhythm and syllable length per word as the code already does.
|
||||
5. Reject: row of tidy lab beeps (watermark doc L3-6); literal bird imitation as an end in itself (the
|
||||
prettiest contestant, nightingale-cascade, was expensive and easy to confuse under smearing, per
|
||||
`MimirBioacousticContestant.cs` profile note); a third payload bit on a second axis (measured loss).
|
||||
6. Keep the generator deterministic from a typed description (contour, root, rhythm, speaker tint),
|
||||
authored once and rendered, not regenerated in the audio callback. Contestants doc L493-496 states
|
||||
this as the AquaSynth lesson: separate authoring intent from realtime DSP.
|
||||
|
||||
## 4. What does not transfer, or is unknown
|
||||
|
||||
- Harmony. Mimir emits one voice per speaker and decodes one stream; nothing in the research tests
|
||||
simultaneous voices, intervals between Minds, consonance, or masking between concurrent songs.
|
||||
Searched for "harmon"; the only hit is the `HarmonicNotch` anchor kind (an envelope notch used as a
|
||||
timing anchor, `MimirBioacousticContestant.cs` ~L33, L231), not musical harmony.
|
||||
- Orbiting spatialisation. Mimir's left/right split is a calibration device (one vocabulary per
|
||||
speaker to learn each side of the room), not a perceptual pan or orbit. Nothing here informs it.
|
||||
- Pleasantness. There is no listening test, no psychoacoustic metric, and no loudness-weighted score
|
||||
in any file read. The only pleasantness evidence is the authored one-line verdicts on each profile in
|
||||
`MimirBioacousticContestant.cs` L52-170 ("lab bird with a clipboard", "most plausible room sound",
|
||||
"prettiest contestant") and the "wrong animal for a room" sentence. The score is
|
||||
`realtime * timing * frequency * payload_bitrate` (contestants doc L39-59): decodability only.
|
||||
Gain 0.021-0.030 is chosen for low audibility, not for pleasing anyone.
|
||||
- Mimir's numbers are synthetic damage (mel-cepstral and log-mel warp/blur) plus a few Scarlett loopback
|
||||
runs. The first physical room runs decoded mics only partially (contestants doc L355-395), and the
|
||||
later live receipt's cardioid result was retracted as noise
|
||||
(`calibration/bioacoustic/complex-contour-live-20260525-063229.md`, correction at watermark doc L225-230).
|
||||
- The pipeline decodes against a known schedule and tiny candidate set; Aqua's listener is a human
|
||||
mix, so none of the decoder scores say how distinguishable Minds are to a person.
|
||||
- Not read: `src/Mimir.BufferSmoke` (the harness executable). It does not match a "bioacoustic" path
|
||||
filter on the tree listing, so I read its documentation (`docs/bioacoustic-training-harness.md`) and
|
||||
the `MimirBioacoustic*.cs` sources instead. The `artifacts/bioacoustic-training/` receipts were listed
|
||||
but not opened beyond the summary text in the docs.
|
||||
|
||||
## 5. Open questions for Aqua
|
||||
|
||||
- What is the "decoder" for Aqua: a human listener, a classifier, or both? Constraints above only
|
||||
hold against a machine decoder.
|
||||
- Does a Mind keep one word for life, or does the grammar generate many songs sharing a timbre and root
|
||||
(activity-dependent contour over a fixed identity)? Mimir only has fixed words.
|
||||
- How many Minds, and how are roots allocated on the log axis when several sound at once? Mimir used 2
|
||||
speaker variants per word and 128 words; harmony will change the spacing rule.
|
||||
- Which dimension bends with activity (contour, rhythm density, gain) and which must stay fixed
|
||||
(timbre, root)? Mimir has no activity channel to copy.
|
||||
- Is a measured pleasantness proxy wanted (roughness, masking, loudness-weighted)? Mimir has none to reuse.
|
||||
- Spatialised orbit plus a speaker-keyed timbre: if the pan moves, does identity still rest on timbre
|
||||
alone? Mimir's per-side vocabulary is not a precedent.
|
||||
|
|
@ -6,6 +6,7 @@ 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 = {
|
||||
|
|
@ -93,7 +94,7 @@ export function CompactUploadCardRenderer({
|
|||
)}
|
||||
{upload.status === UploadStatus.Error && (
|
||||
<UploadCardError>
|
||||
<Text size="T200">{upload.error.message}</Text>
|
||||
<UploadFailureNote error={upload.error} />
|
||||
</UploadCardError>
|
||||
)}
|
||||
{upload.status === UploadStatus.Idle && fileSizeExceeded && (
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ 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 = {
|
||||
|
|
@ -192,7 +193,7 @@ export function UploadCardRenderer({
|
|||
)}
|
||||
{upload.status === UploadStatus.Error && (
|
||||
<UploadCardError>
|
||||
<Text size="T200">{upload.error.message}</Text>
|
||||
<UploadFailureNote error={upload.error} />
|
||||
</UploadCardError>
|
||||
)}
|
||||
{upload.status === UploadStatus.Idle && fileSizeExceeded && (
|
||||
|
|
|
|||
33
src/app/components/upload-card/UploadFailureNote.test.tsx
Normal file
33
src/app/components/upload-card/UploadFailureNote.test.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
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(<UploadFailureNote error={error} />);
|
||||
const before = html.slice(0, html.indexOf('<details'));
|
||||
expect(text(before)).toBe(
|
||||
'Upload failed: the connection dropped before the file started. Send again.'
|
||||
);
|
||||
expect(before).not.toContain('bytes');
|
||||
expect(html).toMatch(/<details[^>]*>.*Details.*<\/details>/s);
|
||||
expect(text(html.slice(html.indexOf('<details')))).toContain('4 bytes');
|
||||
});
|
||||
|
||||
it('an error nobody classified still shows a sentence, not a blank card', () => {
|
||||
const html = renderToStaticMarkup(<UploadFailureNote error={new Error('boom')} />);
|
||||
expect(text(html)).toContain('Upload failed. Send again');
|
||||
expect(text(html)).toContain('boom');
|
||||
});
|
||||
});
|
||||
22
src/app/components/upload-card/UploadFailureNote.tsx
Normal file
22
src/app/components/upload-card/UploadFailureNote.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
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 (
|
||||
<>
|
||||
<Text size="T200">{uploadFailureText(error)}</Text>
|
||||
<details style={{ flex: 1, minWidth: 0 }}>
|
||||
<summary style={{ minHeight: 44, display: 'flex', alignItems: 'center' }}>
|
||||
<Text as="span" size="T200">
|
||||
Details
|
||||
</Text>
|
||||
</summary>
|
||||
<Text size="T200" style={{ wordBreak: 'break-word' }}>
|
||||
{error.message}
|
||||
</Text>
|
||||
</details>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -14,7 +14,15 @@ import { markAsRead } from '../../utils/notifications';
|
|||
import { getBlobSafeMimeType } from '../../utils/mimeTypes';
|
||||
import { timeDayMonYear } from '../../utils/time';
|
||||
import { getMemberDisplayName } from '../../utils/room';
|
||||
import { Album, AlbumFile } from './buildAlbums';
|
||||
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 { useAlbums } from './useAlbums';
|
||||
|
|
@ -67,6 +75,26 @@ 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 (
|
||||
<>
|
||||
<FileRow room={room} file={file} author={author} />
|
||||
{n > 0 && (
|
||||
<button type="button" className={css.Row} onClick={() => setShown(!shown)}>
|
||||
<Text size="T200" className={css.Dim}>
|
||||
{`${shown ? '▾' : '▸'} ${n} earlier ${n === 1 ? 'version' : 'versions'}`}
|
||||
</Text>
|
||||
</button>
|
||||
)}
|
||||
{shown &&
|
||||
file.earlier.map((e) => <FileRow key={e.id} room={room} file={e} author={author} />)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const countLabel = (n: number) => `${n} ${n === 1 ? 'file' : 'files'}`;
|
||||
|
||||
/** The zone never mounts RoomTimeline, so it sends the read receipt the timeline would. */
|
||||
|
|
@ -85,16 +113,51 @@ 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<string, unknown>,
|
||||
threadRoot: e.threadRootId,
|
||||
}));
|
||||
return looseFiles(events);
|
||||
}
|
||||
|
||||
type AlbumViewProps = { room: Room; onTimeline: () => void };
|
||||
export function AlbumView({ room, onTimeline }: AlbumViewProps) {
|
||||
useMarkZoneRead(room);
|
||||
const { loading, error, albums } = useAlbums(room);
|
||||
const { loading, error, albums, truncated } = useAlbums(room);
|
||||
const [dateFormat] = useSetting(settingsAtom, 'dateFormatString');
|
||||
const [openId, setOpenId] = useState<string>();
|
||||
const [path, setPath] = useState<string[]>([]);
|
||||
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 = <Text>Loading albums…</Text>;
|
||||
|
|
@ -103,43 +166,96 @@ export function AlbumView({ room, onTimeline }: AlbumViewProps) {
|
|||
const images = open.files.length > 0 && open.files.every((f) => f.kind === 'image');
|
||||
body = (
|
||||
<>
|
||||
<button type="button" className={css.Back} onClick={() => setOpenId(undefined)}>
|
||||
<Text size="B400">‹ Albums</Text>
|
||||
<button
|
||||
type="button"
|
||||
className={css.Back}
|
||||
onClick={() => {
|
||||
setPath(folderOf(open));
|
||||
setOpenId(undefined);
|
||||
}}
|
||||
>
|
||||
<Text size="B400">{`‹ ${
|
||||
folderOf(open).length ? pathLabel(folderOf(open)) : 'Albums'
|
||||
}`}</Text>
|
||||
</button>
|
||||
<Text size="H4">{open.title}</Text>
|
||||
<Text size="T200" className={css.Dim}>
|
||||
{authorOf(open)} · {countLabel(open.count)} · {timeDayMonYear(open.ts, dateFormat)}
|
||||
</Text>
|
||||
{open.count === 0 && <Text>This album has no files.</Text>}
|
||||
{canShare && (
|
||||
<ShareSet
|
||||
key={open.id}
|
||||
room={room}
|
||||
into={{ albumId: open.id, lastId: lastEvent(open).id, lastTs: lastEvent(open).ts }}
|
||||
/>
|
||||
)}
|
||||
<div className={images ? `${css.Files} ${css.FilesWide}` : css.Files}>
|
||||
{open.files.map((f) => (
|
||||
<FileRow key={f.id} room={room} file={f} author={authorOf(open)} />
|
||||
<VersionedFileRow key={f.id} room={room} file={f} author={authorOf(open)} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
} else if (encrypted)
|
||||
body = <Text>This zone is encrypted, so its albums cannot be listed here.</Text>;
|
||||
else if (albums.length === 0) body = <Text>No albums yet.</Text>;
|
||||
else if (albums.length === 0 && path.length === 0) body = <Text>No albums yet.</Text>;
|
||||
else {
|
||||
body = albums.map((a) => (
|
||||
<button key={a.id} type="button" className={css.Row} onClick={() => setOpenId(a.id)}>
|
||||
<Text size="T400">{a.title}</Text>
|
||||
<Text size="T200" className={css.Dim}>
|
||||
{authorOf(a)} · {countLabel(a.count)} · {timeDayMonYear(a.ts, dateFormat)}
|
||||
</Text>
|
||||
</button>
|
||||
));
|
||||
const here = listFolder(albums, path);
|
||||
body = (
|
||||
<>
|
||||
{path.length > 0 && (
|
||||
<button type="button" className={css.Back} onClick={() => setPath(path.slice(0, -1))}>
|
||||
<Text size="B400">{`‹ ${
|
||||
path.length > 1 ? pathLabel(path.slice(0, -1)) : 'Albums'
|
||||
}`}</Text>
|
||||
</button>
|
||||
)}
|
||||
{path.length > 0 && <Text size="H4">{pathLabel(path)}</Text>}
|
||||
{here.folders.map((f) => (
|
||||
<button
|
||||
key={`d:${f.name}`}
|
||||
type="button"
|
||||
className={css.Row}
|
||||
onClick={() => setPath(f.path)}
|
||||
>
|
||||
<Text size="T400">{`${f.name} /`}</Text>
|
||||
<Text size="T200" className={css.Dim}>
|
||||
{f.albums} {f.albums === 1 ? 'album' : 'albums'} · {timeDayMonYear(f.ts, dateFormat)}
|
||||
</Text>
|
||||
</button>
|
||||
))}
|
||||
{here.albums.map(({ album: a, leaf }) => (
|
||||
<button key={a.id} type="button" className={css.Row} onClick={() => setOpenId(a.id)}>
|
||||
<Text size="T400">{leaf}</Text>
|
||||
<Text size="T200" className={css.Dim}>
|
||||
{authorOf(a)} · {countLabel(a.count)} · {timeDayMonYear(a.ts, dateFormat)}
|
||||
</Text>
|
||||
</button>
|
||||
))}
|
||||
{here.folders.length === 0 && here.albums.length === 0 && <Text>Nothing here.</Text>}
|
||||
{truncated && (
|
||||
<Text size="T200" className={css.Dim}>
|
||||
The list is cut short: some albums or files are not loaded.
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Scroll hideTrack visibility="Hover">
|
||||
<div className={css.Column}>
|
||||
{body}
|
||||
{looseNote && (
|
||||
<Text size="T200" className={css.Dim}>
|
||||
{looseNote}
|
||||
</Text>
|
||||
)}
|
||||
{/* Stays mounted while an album is open, so a running send keeps its progress. */}
|
||||
{canShare && (
|
||||
<div hidden={!!open}>
|
||||
<ShareSet room={room} />
|
||||
<ShareSet room={room} folder={path} />
|
||||
</div>
|
||||
)}
|
||||
<button type="button" className={css.Row} onClick={onTimeline}>
|
||||
|
|
|
|||
140
src/app/features/albums/ShareSet.folder.test.tsx
Normal file
140
src/app/features/albums/ShareSet.folder.test.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
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<typeof captured.run>;
|
||||
maxBytes: NonNullable<typeof captured.maxBytes>;
|
||||
}) => {
|
||||
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<typeof import('./shareSet')>()),
|
||||
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<typeof import('react')>();
|
||||
// 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(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<MatrixClientProvider value={{ sendMessage: async () => ({ event_id: '$e' }) } as never}>
|
||||
<MediaConfigProvider value={mediaConfig}>
|
||||
<ShareSet room={room} folder={folder} into={into} />
|
||||
</MediaConfigProvider>
|
||||
</MatrixClientProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -14,7 +14,7 @@ const text = (html: string) =>
|
|||
const noop = () => undefined;
|
||||
const file = (name: string) => ({ name, type: 'audio/ogg' } as File);
|
||||
|
||||
const view = (over: Partial<ShareState<File>> = {}) =>
|
||||
const view = (over: Partial<ShareState<File>> = {}, adding = false) =>
|
||||
renderToStaticMarkup(
|
||||
<ShareSetView
|
||||
state={{ files: [], title: '', phase: { kind: 'idle' }, epoch: 0, ...over }}
|
||||
|
|
@ -23,6 +23,8 @@ const view = (over: Partial<ShareState<File>> = {}) =>
|
|||
onTitle={noop}
|
||||
onSend={noop}
|
||||
onCancel={noop}
|
||||
onStop={noop}
|
||||
adding={adding}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -54,6 +56,7 @@ describe('the share panel, drawn', () => {
|
|||
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(
|
||||
|
|
@ -76,6 +79,18 @@ describe('the share panel, drawn', () => {
|
|||
});
|
||||
});
|
||||
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
import React, { useRef, useState, useSyncExternalStore } from 'react';
|
||||
import { Text } from 'folds';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
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 { DEFAULT_TITLE, sendShareSet } from './shareSet';
|
||||
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. */
|
||||
|
|
@ -28,6 +30,9 @@ type ShareViewProps = {
|
|||
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<HTMLInputElement>;
|
||||
};
|
||||
|
||||
|
|
@ -42,6 +47,8 @@ export function ShareSetView({
|
|||
onTitle,
|
||||
onSend,
|
||||
onCancel,
|
||||
onStop,
|
||||
adding,
|
||||
inputRef,
|
||||
}: ShareViewProps) {
|
||||
const { files, title, phase, epoch } = state;
|
||||
|
|
@ -57,7 +64,7 @@ export function ShareSetView({
|
|||
/>
|
||||
{files.length === 0 && phase.kind !== 'sending' && (
|
||||
<button type="button" className={css.Row} onClick={onPickClick}>
|
||||
<Text size="B400">Share files ›</Text>
|
||||
<Text size="B400">{adding ? 'Add files ›' : 'Share files ›'}</Text>
|
||||
</button>
|
||||
)}
|
||||
{phase.kind === 'done' && (
|
||||
|
|
@ -67,21 +74,28 @@ export function ShareSetView({
|
|||
)}
|
||||
{files.length > 0 && (
|
||||
<>
|
||||
<input
|
||||
className={css.ShareTitle}
|
||||
value={title}
|
||||
placeholder={DEFAULT_TITLE}
|
||||
aria-label="Album title"
|
||||
disabled={phase.kind === 'sending'}
|
||||
onChange={(e) => onTitle(e.target.value)}
|
||||
/>
|
||||
{!adding && (
|
||||
<input
|
||||
className={css.ShareTitle}
|
||||
value={title}
|
||||
placeholder={DEFAULT_TITLE}
|
||||
aria-label="Album title"
|
||||
disabled={phase.kind === 'sending'}
|
||||
onChange={(e) => onTitle(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
<Text size="T200" className={`${css.Dim} ${css.ShareNames}`}>
|
||||
{files.map((f) => f.name).join(' · ')}
|
||||
</Text>
|
||||
{phase.kind === 'sending' ? (
|
||||
<Text size="T300">
|
||||
Sending {phase.done} of {files.length}…
|
||||
</Text>
|
||||
<>
|
||||
<Text size="T300">
|
||||
Sending {phase.done} of {files.length}…
|
||||
</Text>
|
||||
<button type="button" className={css.Row} onClick={onStop}>
|
||||
<Text size="B400">Stop</Text>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button type="button" className={css.Row} onClick={onSend}>
|
||||
|
|
@ -100,29 +114,61 @@ export function ShareSetView({
|
|||
);
|
||||
}
|
||||
|
||||
export function ShareSet({ room }: { room: Room }) {
|
||||
/** 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<HTMLInputElement>(null);
|
||||
// Built once per mount (AlbumView keys the panel by room): the pick lives in the controller, so a
|
||||
// rerender with a new client or Room object must not build another. Live values go through `live`.
|
||||
const live = useRef({ mx, room, queryClient });
|
||||
live.current = { mx, room, queryClient };
|
||||
const live = useRef({ mx, room, queryClient, folder, into, mediaConfig });
|
||||
live.current = { mx, room, queryClient, folder, into, mediaConfig };
|
||||
const [controller]: [ShareController<File>, unknown] = useState(() =>
|
||||
createShareController<File>({
|
||||
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) =>
|
||||
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<string>((resolve, reject) => {
|
||||
uploadContent(live.current.mx, file, { onSuccess: resolve, onError: reject });
|
||||
const { mx: client } = live.current;
|
||||
let upload: Promise<unknown> | 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,
|
||||
|
|
@ -140,7 +186,9 @@ export function ShareSet({ room }: { room: Room }) {
|
|||
},
|
||||
onFile,
|
||||
},
|
||||
title,
|
||||
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 }))
|
||||
),
|
||||
})
|
||||
|
|
@ -155,6 +203,8 @@ export function ShareSet({ room }: { room: Room }) {
|
|||
onTitle={controller.setTitle}
|
||||
onSend={controller.send}
|
||||
onCancel={controller.cancel}
|
||||
onStop={controller.stop}
|
||||
adding={!!into}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
133
src/app/features/albums/albumFolders.test.ts
Normal file
133
src/app/features/albums/albumFolders.test.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
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<typeof listFolder>) => ({
|
||||
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<string, unknown>[] = [];
|
||||
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<string, unknown>, 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'],
|
||||
});
|
||||
});
|
||||
});
|
||||
73
src/app/features/albums/albumFolders.ts
Normal file
73
src/app/features/albums/albumFolders.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
// 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<string, FolderEntry>();
|
||||
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(' / ');
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { AlbumInput, ZoneEvent, buildAlbums } from './buildAlbums';
|
||||
import { AlbumInput, ZoneEvent, buildAlbums, lastEvent } from './buildAlbums';
|
||||
|
||||
const root = (id: string, ts: number, over: Partial<ZoneEvent> = {}): ZoneEvent => ({
|
||||
event_id: id,
|
||||
|
|
@ -215,6 +215,42 @@ describe('buildAlbums', () => {
|
|||
});
|
||||
});
|
||||
|
||||
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) =>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
// 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 = {
|
||||
|
|
@ -22,11 +23,14 @@ export type AlbumFile = {
|
|||
id: string;
|
||||
kind: FileKind;
|
||||
name: string;
|
||||
sender: string;
|
||||
ts: number;
|
||||
/** The event content, as RenderMessageContent reads it. */
|
||||
content: Record<string, unknown>;
|
||||
/** 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 = {
|
||||
|
|
@ -64,7 +68,7 @@ const tsOf = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v
|
|||
|
||||
const asString = (v: unknown): string => (typeof v === 'string' ? v : '');
|
||||
|
||||
const toFile = (e: ZoneEvent): AlbumFile | undefined => {
|
||||
export 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;
|
||||
|
|
@ -72,9 +76,11 @@ 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: [],
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -103,14 +109,15 @@ const toAlbum = (input: AlbumInput, server: string): Album | undefined => {
|
|||
})
|
||||
.sort((a, b) => a.ts - b.ts)
|
||||
.map((f) => ({ ...f, readBy: readersOf(list, f.id, f.content, server) }));
|
||||
const heads = foldVersions(files);
|
||||
return {
|
||||
id: root.event_id,
|
||||
title: titleOf(root),
|
||||
author: root.sender ?? '',
|
||||
ts: tsOf(root.origin_server_ts),
|
||||
cover: files.find((f) => f.kind === 'image'),
|
||||
files,
|
||||
count: files.length,
|
||||
cover: heads.find((f) => f.kind === 'image'),
|
||||
files: heads,
|
||||
count: heads.length,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -127,3 +134,51 @@ 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);
|
||||
|
|
|
|||
126
src/app/features/albums/publisherWire.test.ts
Normal file
126
src/app/features/albums/publisherWire.test.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
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<string, unknown> = {},
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
55
src/app/features/albums/readPages.test.ts
Normal file
55
src/app/features/albums/readPages.test.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { Page, readPages } from './readPages';
|
||||
|
||||
const pager = (pages: Page<number>[]) => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
27
src/app/features/albums/readPages.ts
Normal file
27
src/app/features/albums/readPages.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
/** One page of a Matrix paginated endpoint: its events and the token for the next page, if any. */
|
||||
export type Page<T> = { 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 <T>(
|
||||
fetchPage: (from: string | undefined) => Promise<Page<T>>,
|
||||
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 };
|
||||
};
|
||||
|
|
@ -2,16 +2,17 @@ import { describe, expect, it } from 'vitest';
|
|||
import { Picked, ShareDeps, createShareController } from './shareController';
|
||||
import { ShareResult } from './shareSet';
|
||||
|
||||
const file = (name: string): Picked => ({ name, type: 'audio/ogg' });
|
||||
const 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 }[] = [];
|
||||
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<Picked>['run'] = (files, title, onFile) => {
|
||||
calls.push({ files, title, onFile });
|
||||
const run: ShareDeps<Picked>['run'] = (files, title, onFile, signal) => {
|
||||
calls.push({ files, title, onFile, signal });
|
||||
return new Promise<ShareResult>((resolve, reject) => {
|
||||
release = resolve;
|
||||
fail = reject;
|
||||
|
|
@ -259,3 +260,121 @@ describe('an encrypted room', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
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']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
// send is in, and the rules that keep a room to one send at a time. ShareSet.tsx draws it.
|
||||
import { ShareResult, remainingAfter, resultNote } from './shareSet';
|
||||
|
||||
export type Picked = { name: string; type: string };
|
||||
export type Picked = { name: string; type: string; size?: number };
|
||||
|
||||
export type SharePhase =
|
||||
| { kind: 'idle' }
|
||||
|
|
@ -19,11 +19,28 @@ export type ShareState<F extends Picked> = {
|
|||
|
||||
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 = <F extends Picked>(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<F extends Picked> = {
|
||||
roomId: string;
|
||||
isEncrypted: () => boolean;
|
||||
/** Sends the files as one album; reports each settled file through onFile. */
|
||||
run: (files: F[], title: string, onFile: () => void) => Promise<ShareResult>;
|
||||
run: (files: F[], title: string, onFile: () => void, signal: AbortSignal) => Promise<ShareResult>;
|
||||
/** 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;
|
||||
};
|
||||
|
|
@ -34,6 +51,7 @@ const sending = new Set<string>();
|
|||
export const createShareController = <F extends Picked>(deps: ShareDeps<F>) => {
|
||||
let state: ShareState<F> = { files: [], title: '', phase: { kind: 'idle' }, epoch: 0 };
|
||||
const listeners = new Set<() => void>();
|
||||
let running: AbortController | undefined;
|
||||
const set = (patch: Partial<ShareState<F>>) => {
|
||||
state = { ...state, ...patch };
|
||||
listeners.forEach((l) => l());
|
||||
|
|
@ -47,24 +65,52 @@ export const createShareController = <F extends Picked>(deps: ShareDeps<F>) => {
|
|||
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 { files, title } = state;
|
||||
set({ phase: { kind: 'sending', done: 0 } });
|
||||
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 } });
|
||||
});
|
||||
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: resultNote(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) {
|
||||
set({ phase: { kind: 'done', text: `Could not share: ${e?.message ?? 'send failed'}` } });
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
|
@ -80,6 +126,8 @@ export const createShareController = <F extends Picked>(deps: ShareDeps<F>) => {
|
|||
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,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ import {
|
|||
DEFAULT_TITLE,
|
||||
ShareFile,
|
||||
SharePort,
|
||||
CANCELLED,
|
||||
freshest,
|
||||
refusalOf,
|
||||
remainingAfter,
|
||||
resultNote,
|
||||
sendShareSet,
|
||||
|
|
@ -56,7 +59,7 @@ 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: [] });
|
||||
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([
|
||||
|
|
@ -158,3 +161,168 @@ describe('after a send', () => {
|
|||
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<string, unknown>[] = [];
|
||||
const port = {
|
||||
...r.port,
|
||||
send: async (c: Record<string, unknown>) => {
|
||||
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<ShareFile> => ({
|
||||
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<ShareFile> = {
|
||||
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<ShareFile> = {
|
||||
...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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
// 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. */
|
||||
|
|
@ -13,6 +15,8 @@ export type SharePort<F extends ShareFile> = {
|
|||
contentOf: (file: F) => Promise<Record<string, unknown>>;
|
||||
/** 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 = {
|
||||
|
|
@ -22,8 +26,30 @@ export type ShareResult = {
|
|||
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',
|
||||
|
|
@ -44,22 +70,46 @@ export const titleOrDefault = (title: string): string => title.trim() || DEFAULT
|
|||
*/
|
||||
export const sendShareSet = async <F extends ShareFile>(
|
||||
port: SharePort<F>,
|
||||
title: string,
|
||||
into: string | ExistingAlbum,
|
||||
files: F[]
|
||||
): Promise<ShareResult> => {
|
||||
const albumId = await port.send({ msgtype: 'm.text', body: titleOrDefault(title) });
|
||||
const result: ShareResult = { albumId, sent: 0, failed: [], failedAt: [] };
|
||||
let lastId = albumId;
|
||||
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 {
|
||||
} 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');
|
||||
}
|
||||
|
|
@ -69,15 +119,20 @@ export const sendShareSet = async <F extends ShareFile>(
|
|||
|
||||
/**
|
||||
* What stays picked after a send: only the files that did not go, so a retry is one tap. A retry
|
||||
* makes a second album (adding to an existing one is not built), and a share where nothing went
|
||||
* 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 = <T>(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: ${result.failed.join(', ')}`;
|
||||
return `Shared ${result.sent}. Not sent: ${result.failed.join(', ')}`;
|
||||
if (result.sent === 0) return `Nothing was shared. Not sent: ${failedList(result)}`;
|
||||
return `Shared ${result.sent}. Not sent: ${failedList(result)}`;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,12 +7,11 @@ 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();
|
||||
|
|
@ -29,37 +28,49 @@ export const useIsFileZone = (room: Room): boolean => {
|
|||
);
|
||||
};
|
||||
|
||||
export type AlbumsState = { loading: boolean; error: Error | null; albums: Album[] };
|
||||
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';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export const useAlbums = (room: Room): AlbumsState => {
|
||||
const mx = useMatrixClient();
|
||||
const { roomId } = room;
|
||||
const query = useQuery({
|
||||
queryKey: ['albums', roomId],
|
||||
queryFn: async (): Promise<Album[]> => {
|
||||
queryFn: async (): Promise<{ albums: Album[]; truncated: boolean }> => {
|
||||
const get = (path: string, params: Record<string, string>) =>
|
||||
mx.http.authedRequest<Chunk>(Method.Get, path, params, undefined, {
|
||||
mx.http.authedRequest<Page<ZoneEvent>>(Method.Get, path, params, undefined, {
|
||||
prefix: ClientPrefix.V1,
|
||||
});
|
||||
const paged = (path: string, params: Record<string, string>) =>
|
||||
readPages<ZoneEvent>((from) => get(path, from ? { ...params, from } : params));
|
||||
const room_ = encodeURIComponent(roomId);
|
||||
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, mx.getDomain() ?? '');
|
||||
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 };
|
||||
},
|
||||
retry: 1,
|
||||
refetchInterval: REFRESH_MS,
|
||||
|
|
@ -68,6 +79,7 @@ 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: query.data?.albums ?? [],
|
||||
truncated: query.data?.truncated ?? false,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
83
src/app/features/albums/versions.test.ts
Normal file
83
src/app/features/albums/versions.test.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { PUBLISH_KEY, foldVersions, replacesOf } from './versions';
|
||||
|
||||
type F = { id: string; sender: string; content: Record<string, unknown>; 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([]);
|
||||
});
|
||||
});
|
||||
56
src/app/features/albums/versions.ts
Normal file
56
src/app/features/albums/versions.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
// 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, unknown>): string | undefined => {
|
||||
const id = (content[PUBLISH_KEY] as { replaces?: unknown } | undefined)?.replaces;
|
||||
return typeof id === 'string' && id ? id : undefined;
|
||||
};
|
||||
|
||||
type Versioned<F> = 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<string, unknown> }
|
||||
>(
|
||||
files: Versioned<F>[]
|
||||
): Versioned<F>[] => {
|
||||
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<string>();
|
||||
files.forEach((f) => {
|
||||
const t = target(f);
|
||||
if (t) replaced.add(t.id);
|
||||
});
|
||||
const chainOf = (head: Versioned<F>): Versioned<F>[] => {
|
||||
const out: Versioned<F>[] = [];
|
||||
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) }));
|
||||
};
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
|
|
@ -6,7 +6,7 @@ import { Page, PageContent, PageContentCenter, PageHeader } from '../../componen
|
|||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
import { getHomePath } from '../../pages/pathUtils';
|
||||
import { DeskView } from './DeskView';
|
||||
import { unverifiedForks } from './optionsHash';
|
||||
import { useUnverified } from './useUnverified';
|
||||
import { deskFor } from './source';
|
||||
|
||||
const labelOf = (mind: string) => mind.charAt(0).toUpperCase() + mind.slice(1);
|
||||
|
|
@ -19,14 +19,7 @@ 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, setUnverified] = useState<ReadonlySet<string>>(new Set());
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
unverifiedForks(list?.forks ?? []).then((bad) => live && setUnverified(bad));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [list]);
|
||||
const unverified = useUnverified(list);
|
||||
|
||||
return (
|
||||
<Page>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
import React from 'react';
|
||||
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 { getDeskPath } from '../../pages/pathUtils';
|
||||
import { buildDesk } from './buildDesk';
|
||||
import { ForkList } from './forkContract';
|
||||
import { deskFor } from './source';
|
||||
import { useUnverified } from './useUnverified';
|
||||
|
||||
/** 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 open = deskFor(mind)?.open_count ?? 0;
|
||||
export function DeskLink({ mind, open }: { mind: string; open: number }) {
|
||||
return (
|
||||
<NavItem variant="Background" radii="400">
|
||||
<NavLink to={getDeskPath(mind)}>
|
||||
|
|
@ -29,3 +30,17 @@ export function DeskEntry({ mind }: { mind: string }) {
|
|||
</NavItem>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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<string> | 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 <DeskLink mind={mind} open={openCount(list, unverified)} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,10 +5,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
|||
import { ScreenSize, ScreenSizeProvider } from '../../hooks/useScreenSize';
|
||||
import { SkyInkContextProvider } from '../../hooks/useTheme';
|
||||
import { SCRIM_GROUND, SCRIM_INK } from '../scene/skyInk';
|
||||
import { DeskEntry } from './DeskEntry';
|
||||
import { DeskView, SIGN_IN_NOTE } from './DeskView';
|
||||
import { DeskEntry, DeskLink, openCount } from './DeskEntry';
|
||||
import { DeskView, SIGN_IN_NOTE, rowOpen } from './DeskView';
|
||||
import { ForkList } from './forkContract';
|
||||
import { STATES } from './fixtures';
|
||||
import { STATES, entry as forkEntry, list as forkList } from './fixtures';
|
||||
import { setDeskSource } from './source';
|
||||
|
||||
// useTheme reads the stored settings when it loads.
|
||||
|
|
@ -119,8 +119,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('1 open.');
|
||||
expect(html).not.toContain('Nothing is waiting');
|
||||
expect(html).toContain('Nothing is waiting on you from Void.');
|
||||
expect(html).not.toContain('1 open.');
|
||||
});
|
||||
it('never claims an empty desk while the list says forks are open, whatever rows it could show', () => {
|
||||
setDeskSource(() => undefined);
|
||||
|
|
@ -132,6 +132,29 @@ 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(/<textarea/g)?.length ?? 0;
|
||||
expect(rows).toBeGreaterThan(0);
|
||||
expect(html.match(/<textarea[^>]* disabled=""/g)).toHaveLength(rows);
|
||||
expect(html.split(SIGN_IN_NOTE)).toHaveLength(rows + 1);
|
||||
});
|
||||
});
|
||||
it('shows a guest answer with its text and never an answerer id', () => {
|
||||
const html = view('guest', ScreenSize.Desktop);
|
||||
expect(html).toContain('Answer Second way "From outside the circle."');
|
||||
expect(html).toContain('1 decider · 2 answerers');
|
||||
expect(html).not.toContain('guest-1');
|
||||
expect(html).not.toContain('acct-1');
|
||||
});
|
||||
it('lists a question settled while it had answers as a ruling only, with neither row nor answers nor open count', () => {
|
||||
const html = view('settledWhileAnswered', ScreenSize.Desktop);
|
||||
expect(html).toContain('Question late (ruling:late-1)');
|
||||
expect(html).not.toContain('Context for late');
|
||||
expect(html).not.toContain('Arrived after');
|
||||
expect(html).toContain('Nothing is waiting on you from Void.');
|
||||
});
|
||||
it('shows no booth for a Mind without art rather than another face', () => {
|
||||
expect(view('noAnswers', ScreenSize.Desktop, 'nobody')).not.toContain('data-pose');
|
||||
});
|
||||
|
|
@ -230,32 +253,66 @@ describe('counts when the ids are withheld', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('the navigator count', () => {
|
||||
const two = forkList([forkEntry('one'), forkEntry('two')]);
|
||||
it('is none until the hash check has landed', () => {
|
||||
expect(openCount(two, undefined)).toBe(0);
|
||||
});
|
||||
it('is the desk page count once it has: an open card withdrawn as unverified is not counted', () => {
|
||||
expect(openCount(two, new Set())).toBe(2);
|
||||
expect(openCount(two, new Set([two.forks[0].card.fork_id]))).toBe(1);
|
||||
});
|
||||
it('is none for a Mind with no desk', () => {
|
||||
expect(openCount(undefined, new Set())).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DeskEntry', () => {
|
||||
afterEach(() => setDeskSource(() => undefined));
|
||||
const bare = (html: string) => html.replace(/<[^>]*>/g, '');
|
||||
const entry = (mind: string) =>
|
||||
renderToStaticMarkup(
|
||||
<MemoryRouter>
|
||||
<DeskEntry mind={mind} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
const link = (open: number) =>
|
||||
renderToStaticMarkup(
|
||||
<MemoryRouter>
|
||||
<DeskLink mind="void" open={open} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
it('reads "Desk · N open" and links to that Mind\'s desk', () => {
|
||||
setDeskSource(() => STATES.blocked);
|
||||
const html = entry('void');
|
||||
const html = link(2);
|
||||
expect(html).toContain('Desk · 2 open');
|
||||
expect(html).toContain('href="/desk/void"');
|
||||
});
|
||||
it('reads the count the list states, not a count of the forks it carries', () => {
|
||||
setDeskSource(() => ({ ...STATES.empty, open_count: 5 }));
|
||||
expect(entry('void')).toContain('Desk · 5 open');
|
||||
it('is pinned even at zero open', () => {
|
||||
expect(bare(link(0))).toBe('Desk');
|
||||
expect(link(0)).toContain('href="/desk/void"');
|
||||
});
|
||||
it('is pinned even at zero open, when nothing is published, and when only settled forks exist', () => {
|
||||
const bare = (html: string) => html.replace(/<[^>]*>/g, '');
|
||||
const states = [STATES.empty, undefined, { ...STATES.settled, open_count: 0 }];
|
||||
states.forEach((state) => {
|
||||
it('is pinned when nothing is published, and when only settled forks exist', () => {
|
||||
[undefined, { ...STATES.settled, open_count: 0 }].forEach((state) => {
|
||||
setDeskSource(() => state);
|
||||
const html = entry('void');
|
||||
expect(bare(html)).toBe('Desk');
|
||||
expect(html).toContain('href="/desk/void"');
|
||||
expect(bare(entry('void'))).toBe('Desk');
|
||||
expect(entry('void')).toContain('href="/desk/void"');
|
||||
});
|
||||
});
|
||||
it('shows no count before the hash check has landed, so a card about to be withdrawn is never counted', () => {
|
||||
setDeskSource(() => STATES.blocked);
|
||||
expect(bare(entry('void'))).toBe('Desk');
|
||||
});
|
||||
});
|
||||
|
||||
describe('a row on a rotating screen', () => {
|
||||
it('is open on a wide screen whatever was tapped, and folded on a phone until tapped', () => {
|
||||
expect(rowOpen(false, false)).toBe(true);
|
||||
expect(rowOpen(false, true)).toBe(true);
|
||||
expect(rowOpen(true, false)).toBe(false);
|
||||
expect(rowOpen(true, true)).toBe(true);
|
||||
});
|
||||
it('reads the screen it is on now, not the one it mounted on: wide to phone folds, phone to wide opens', () => {
|
||||
const untapped = [false, true, false, true].map((phone) => rowOpen(phone, false));
|
||||
expect(untapped).toEqual([true, false, true, false]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -50,10 +50,13 @@ function Answers({ answers }: { answers: AnswerRow[] }) {
|
|||
);
|
||||
}
|
||||
|
||||
/** Phones list titles and a tap opens one; wide screens show every row open. Derived, so rotating never leaves it stale. */
|
||||
export const rowOpen = (phone: boolean, tapped: boolean) => !phone || tapped;
|
||||
|
||||
function Row({ row, now }: { row: DeskRow; now: number }) {
|
||||
const phone = useScreenSizeContext() === ScreenSize.Mobile;
|
||||
// Phones list titles; a tap opens one. Wide screens show every row open.
|
||||
const [open, setOpen] = useState(!phone);
|
||||
const [tapped, setTapped] = useState(false);
|
||||
const open = rowOpen(phone, tapped);
|
||||
if (row.kind === 'unverified') {
|
||||
return (
|
||||
<li className={css.Row}>
|
||||
|
|
@ -72,7 +75,7 @@ function Row({ row, now }: { row: DeskRow; now: number }) {
|
|||
type="button"
|
||||
className={css.Fold}
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(!open)}
|
||||
onClick={() => setTapped(!tapped)}
|
||||
>
|
||||
{row.title}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -52,9 +52,20 @@ describe('buildDesk', () => {
|
|||
const odd = list([entry('a', { order: 2 }), entry('b', { order: 9 })]);
|
||||
expect(buildDesk(odd).open.map((r) => r.forkId)).toEqual(['c:question:a', 'c:question:b']);
|
||||
});
|
||||
it("takes the open count from the list's open_count, not from the rows it could verify or carries", () => {
|
||||
const d = buildDesk({ ...list([entry('only')]), open_count: 4 }, new Set(['c:question:only']));
|
||||
expect(d.counts.open).toBe(4);
|
||||
it("takes the open count from the list's open_count, so rows it could not carry still count", () => {
|
||||
expect(buildDesk({ ...list([entry('only')]), open_count: 4 }).counts.open).toBe(4);
|
||||
});
|
||||
it('does not count an open card it withdraws as unverified: one filter owns the shown rows and the count', () => {
|
||||
const l = list([entry('a'), entry('b')]);
|
||||
const d = buildDesk(l, new Set(['c:question:a']));
|
||||
expect(d.counts.open).toBe(1);
|
||||
expect(d.open.filter((r) => r.kind === 'question')).toHaveLength(1);
|
||||
expect(buildDesk(l, new Set(l.forks.map((f) => f.card.fork_id))).counts.open).toBe(0);
|
||||
});
|
||||
it('does not count a withdrawn id that names a closed card, and never goes below zero', () => {
|
||||
const l = list([entry('a'), entry('shut', { status: 'closed', ruling: 'r:1' })]);
|
||||
expect(buildDesk(l, new Set(['c:question:shut'])).counts.open).toBe(1);
|
||||
expect(buildDesk({ ...l, open_count: 0 }, new Set(['c:question:a'])).counts.open).toBe(0);
|
||||
});
|
||||
it('marks the answer the record names as counted, and no other', () => {
|
||||
const [row] = questions(buildDesk(STATES.answered));
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ export type Desk = {
|
|||
open: DeskRow[];
|
||||
/** Closed with a ruling id. A card closed with no ruling (withdrawn) is not listed. */
|
||||
recorded: { forkId: string; title: string; rulingId: string }[];
|
||||
/** The list's own open_count: the one count the navigator, the status line and the booth share. */
|
||||
/** The list's open_count less the open cards withdrawn as unverified: the one count the navigator, the status line and the booth share. */
|
||||
counts: { open: number };
|
||||
};
|
||||
|
||||
|
|
@ -72,6 +72,7 @@ export const buildDesk = (
|
|||
if (!list) return { published: false, open: [], recorded: [], counts: { open: 0 } };
|
||||
const open: DeskRow[] = [];
|
||||
const recorded: Desk['recorded'] = [];
|
||||
let withdrawn = 0;
|
||||
list.forks.forEach((entry) => {
|
||||
const { card, record } = entry;
|
||||
if (card.status === 'closed') {
|
||||
|
|
@ -81,6 +82,7 @@ export const buildDesk = (
|
|||
}
|
||||
if (unverified.has(card.fork_id)) {
|
||||
open.push({ kind: 'unverified', forkId: card.fork_id });
|
||||
withdrawn += 1;
|
||||
return;
|
||||
}
|
||||
open.push({
|
||||
|
|
@ -101,6 +103,6 @@ export const buildDesk = (
|
|||
published: true,
|
||||
open,
|
||||
recorded,
|
||||
counts: { open: list.open_count },
|
||||
counts: { open: Math.max(0, list.open_count - withdrawn) },
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -116,4 +116,30 @@ export const STATES = {
|
|||
'n1'
|
||||
),
|
||||
]),
|
||||
/** Answers from a Guest (weight 0, served all the same) beside a Member's; ids are never shown. */
|
||||
guest: list([
|
||||
entry(
|
||||
'open',
|
||||
{},
|
||||
[
|
||||
answer('open', 1, { choice: 'A', answerer: 'acct-1', authority: 'Member' }),
|
||||
answer('open', 2, {
|
||||
choice: 'B',
|
||||
note: 'From outside the circle.',
|
||||
answerer: 'guest-1',
|
||||
authority: 'Guest',
|
||||
}),
|
||||
],
|
||||
'n1'
|
||||
),
|
||||
]),
|
||||
/** Closed with a ruling while answers were still arriving: the card settled, the answers stay on its record. */
|
||||
settledWhileAnswered: list([
|
||||
entry(
|
||||
'late',
|
||||
{ status: 'closed', ruling: 'ruling:late-1', ruled_from: ['n1'] },
|
||||
[answer('late', 1), answer('late', 2, { note: 'Arrived after.', answerer: 'acct-2' })],
|
||||
'n1'
|
||||
),
|
||||
]),
|
||||
} satisfies Record<string, ForkList>;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,14 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import vectors from './optionsHashVectors.json';
|
||||
import { entry } from './fixtures';
|
||||
import { ForkCard } from './forkContract';
|
||||
import { canonicalShown, optionsHash, shownOf, unverifiedForks } from './optionsHash';
|
||||
import {
|
||||
canonicalShown,
|
||||
optionsHash,
|
||||
shownOf,
|
||||
unverifiedForks,
|
||||
unverifiedOrAll,
|
||||
} from './optionsHash';
|
||||
|
||||
describe('options_hash against the CultLib vectors', () => {
|
||||
it('has all fifteen vectors', () => {
|
||||
|
|
@ -48,3 +54,18 @@ describe('unverifiedForks', () => {
|
|||
expect(await unverifiedForks([{ ...e, card }])).toEqual(new Set());
|
||||
});
|
||||
});
|
||||
|
||||
describe('unverifiedOrAll', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
it('answers as unverifiedForks does when the check runs', async () => {
|
||||
const e = entry('plain');
|
||||
expect(await unverifiedOrAll([e])).toEqual(await unverifiedForks([e]));
|
||||
});
|
||||
it('withdraws every card when the check cannot run', async () => {
|
||||
vi.stubGlobal('crypto', { subtle: undefined });
|
||||
const a = entry('a');
|
||||
const b = entry('b');
|
||||
expect(await unverifiedOrAll([a, b])).toEqual(new Set([a.card.fork_id, b.card.fork_id]));
|
||||
expect(await unverifiedOrAll([])).toEqual(new Set());
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -49,3 +49,10 @@ export const unverifiedForks = async (forks: ForkEntry[]): Promise<Set<string>>
|
|||
);
|
||||
return bad;
|
||||
};
|
||||
|
||||
/**
|
||||
* `unverifiedForks`, except that a check that cannot run (no `crypto.subtle` on an insecure origin, a rejected
|
||||
* digest) withdraws every card: a card nobody could verify is neither shown nor counted.
|
||||
*/
|
||||
export const unverifiedOrAll = async (forks: ForkEntry[]): Promise<Set<string>> =>
|
||||
unverifiedForks(forks).catch(() => new Set(forks.map(({ card }) => card.fork_id)));
|
||||
|
|
|
|||
21
src/app/features/desk/useUnverified.ts
Normal file
21
src/app/features/desk/useUnverified.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { ForkList } from './forkContract';
|
||||
import { unverifiedOrAll } from './optionsHash';
|
||||
|
||||
/**
|
||||
* The fork ids of `list` whose options_hash fails the recompute: the one verified filter, shared by the desk page
|
||||
* and the navigator's count. Undefined until the check has landed, so no caller reads a count that a card is
|
||||
* about to be withdrawn from. `list` must be a stable reference (memoized by the caller): a new one re-runs the check.
|
||||
*/
|
||||
export const useUnverified = (list: ForkList | undefined): ReadonlySet<string> | undefined => {
|
||||
const [bad, setBad] = useState<ReadonlySet<string>>();
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
setBad(undefined);
|
||||
unverifiedOrAll(list?.forks ?? []).then((found) => live && setBad(found));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [list]);
|
||||
return bad;
|
||||
};
|
||||
|
|
@ -45,7 +45,7 @@ describe('the Media row of a Mind', () => {
|
|||
throw new Error('must not be asked');
|
||||
});
|
||||
const carried = mediaRoomIdFromStewardships(
|
||||
{ void: ['GameCult/x'], media: { void: '!c:gamecult.org' } },
|
||||
{ void: ['GameCult/x'], '@rooms': { file_zones: { void: '!c:gamecult.org' } } },
|
||||
'void'
|
||||
);
|
||||
expect(carried).toBe('!c:gamecult.org');
|
||||
|
|
@ -58,7 +58,23 @@ describe('the Media row of a Mind', () => {
|
|||
|
||||
it('ignores a stewardships state that carries no id for the Mind, or something that is not a room id', () => {
|
||||
expect(mediaRoomIdFromStewardships({ void: ['GameCult/x'] }, 'void')).toBeUndefined();
|
||||
expect(mediaRoomIdFromStewardships({ media: { void: 'nope' } }, 'void')).toBeUndefined();
|
||||
expect(
|
||||
mediaRoomIdFromStewardships({ '@rooms': { file_zones: { void: 'nope' } } }, 'void')
|
||||
).toBeUndefined();
|
||||
// the project ids beside it, another Mind's id, and the old provisional key carry nothing for this Mind
|
||||
expect(
|
||||
mediaRoomIdFromStewardships(
|
||||
{
|
||||
'@rooms': { projects: { x: '!p:s' }, file_zones: { kiko: '!k:s' } },
|
||||
media: { void: '!m:s' },
|
||||
},
|
||||
'void'
|
||||
)
|
||||
).toBeUndefined();
|
||||
// a Mind named "constructor" must not read an inherited property as an id
|
||||
expect(
|
||||
mediaRoomIdFromStewardships({ '@rooms': { file_zones: {} } }, 'constructor')
|
||||
).toBeUndefined();
|
||||
expect(mediaRoomIdFromStewardships(undefined, 'void')).toBeUndefined();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2,20 +2,29 @@
|
|||
* A Mind's media room: the file-zone room Eureka provisions under the alias #<mind>-files:<server>.
|
||||
* The navigator shows a Media row for every Mind; this module decides what that row says and where it points.
|
||||
* A room id carried by the root Space's org.gamecult.stewardships state wins over the alias; the alias is the
|
||||
* convention until the provisioning projection carries ids (ratatoskr:follow_up:stewardships-carry-room-ids).
|
||||
* fallback for a Mind whose zone the provisioning run has not recorded yet.
|
||||
*/
|
||||
import { EstateRoom } from './buildEstate';
|
||||
|
||||
export const STEWARDSHIPS_KEY = 'org.gamecult.stewardships';
|
||||
|
||||
/** Where provisioning records room ids in the stewardships state (gamecult-ops synapse-accounts-hand-managed.py ROOMS_KEY). "@" is no legal Matrix localpart, so no Mind can own the key. */
|
||||
export const ROOMS_KEY = '@rooms';
|
||||
|
||||
export const mediaAlias = (mind: string, server: string): string => `#${mind}-files:${server}`;
|
||||
|
||||
/**
|
||||
* The room id the stewardships state names for a Mind's media room, if it does. The state's mind-to-repos
|
||||
* lists are not read here; the id lives under `media[<mind>]` (provisional shape, awaiting the follow-up above).
|
||||
* The room id the stewardships state names for a Mind's media room, under `@rooms.file_zones[<mind>]`.
|
||||
* The state's mind-to-repos lists are not read here.
|
||||
*/
|
||||
export const mediaRoomIdFromStewardships = (content: unknown, mind: string): string | undefined => {
|
||||
const id = (content as { media?: Record<string, unknown> } | null | undefined)?.media?.[mind];
|
||||
const rooms = (
|
||||
content as
|
||||
| Record<string, { file_zones?: Record<string, unknown> } | undefined>
|
||||
| null
|
||||
| undefined
|
||||
)?.[ROOMS_KEY];
|
||||
const id = rooms?.file_zones?.[mind];
|
||||
return typeof id === 'string' && id.startsWith('!') ? id : undefined;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,21 @@ const { createRidgeScene, createScene } = vi.hoisted(() => ({
|
|||
}));
|
||||
vi.mock('./ridge/Ridge', () => ({ createRidgeScene }));
|
||||
vi.mock('./Scene', () => ({ createScene }));
|
||||
// The physical sky is baked per call (tens of ms: atmosphere LUTs) and the clock here is fixed, so a state always
|
||||
// draws the same sky. Baking once per state keeps the 21-offset sweep under the default timeout when the machine is
|
||||
// loaded; the sky each build receives is still the one `ridgeSkyFor` makes for that state.
|
||||
vi.mock('./skyForState', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./skyForState')>();
|
||||
const baked = new Map<string, ReturnType<typeof actual.ridgeSkyFor>>();
|
||||
return {
|
||||
...actual,
|
||||
ridgeSkyFor: (...args: Parameters<typeof actual.ridgeSkyFor>) => {
|
||||
if (args.length !== 1) return actual.ridgeSkyFor(...args);
|
||||
if (!baked.has(args[0])) baked.set(args[0], actual.ridgeSkyFor(args[0]));
|
||||
return baked.get(args[0]) as ReturnType<typeof actual.ridgeSkyFor>;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const canvas = {} as HTMLCanvasElement;
|
||||
const config = { tier: 'low' as const };
|
||||
|
|
@ -185,6 +200,19 @@ describe('startBackgroundScene follows the sky state', () => {
|
|||
expect(live.dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('a rebuild for a sky that was replaced while its key was awaited builds nothing: only the newest sky is built', async () => {
|
||||
createRidgeScene.mockReturnValueOnce(fakeScene()).mockReturnValueOnce(fakeScene());
|
||||
createRidgeScene.mockReturnValue(fakeScene());
|
||||
const scene = await startBackgroundScene(config, caps, { canvas });
|
||||
setSkyState('day');
|
||||
setSkyState('dusk');
|
||||
await flush();
|
||||
// The initial build and one rebuild, for dusk; day's rebuild stood down before building anything.
|
||||
expect(createRidgeScene).toHaveBeenCalledTimes(2);
|
||||
expect(hex(createRidgeScene.mock.calls[1][0].sky.top)).toBe(hex(ridgeSkyFor('dusk').top));
|
||||
scene?.dispose();
|
||||
});
|
||||
|
||||
// A second sky change `ticks` microtasks after the first. True when it landed mid-build: the build for the first
|
||||
// change finished, was found stale and dropped (never shown), and only the newest sky was announced.
|
||||
const changeAfter = async (ticks: number) => {
|
||||
|
|
|
|||
|
|
@ -1,40 +1,62 @@
|
|||
import { readdirSync, readFileSync } from 'fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { classLists, emitted, expectPhoneHitClasses, phoneHitClasses } from './phoneHitTestKit';
|
||||
import {
|
||||
classLists,
|
||||
emitted,
|
||||
expectPhoneHitClasses,
|
||||
uncoveredIconButtons,
|
||||
} from './phoneHitTestKit';
|
||||
|
||||
// Every IconButton under src carries a PhoneHit class, or is named here with the reason it has none. The per-group
|
||||
// suites pin which variant a listed file's buttons carry; this one sees files no group lists, so a new bare
|
||||
// IconButton fails here instead of shipping with a sub-44px phone hit area.
|
||||
const EXEMPT: Record<string, { indices: number[]; why: string }> = {
|
||||
'features/estate/EstateNavigator.tsx': {
|
||||
'app/features/estate/EstateNavigator.tsx': {
|
||||
indices: [0],
|
||||
why: 'Find a room sits in a css.Targets row that already gives it a 44 px phone target',
|
||||
},
|
||||
'features/lobby/LobbyHeader.tsx': {
|
||||
'app/features/lobby/LobbyHeader.tsx': {
|
||||
indices: [1],
|
||||
why: 'Members button, hidden on mobile',
|
||||
},
|
||||
};
|
||||
|
||||
const tsxFiles = (dir: string): string[] =>
|
||||
readdirSync(`${process.cwd()}/src/app/${dir}`, { withFileTypes: true }).flatMap((e) => {
|
||||
if (e.isDirectory()) return tsxFiles(`${dir}${e.name}/`);
|
||||
return e.name.endsWith('.tsx') ? [`${dir}${e.name}`] : [];
|
||||
// Every script file, not only .tsx: an IconButton can be rendered from a .ts or .js module too. A file that never
|
||||
// says IconButton is skipped; one that does is parsed and every mention of it must be a tag the guard can check.
|
||||
const SCRIPT = /\.(?:[cm]?[jt]s|[jt]sx)$/;
|
||||
const sourceFiles = (dir: string): string[] =>
|
||||
readdirSync(`${process.cwd()}/src/${dir}`, { withFileTypes: true }).flatMap((e) => {
|
||||
if (e.isDirectory()) return sourceFiles(`${dir}${e.name}/`);
|
||||
return SCRIPT.test(e.name) ? [`${dir}${e.name}`] : [];
|
||||
});
|
||||
|
||||
describe('phone hit areas: every IconButton is covered or named', () => {
|
||||
const scanned = sourceFiles('').flatMap((file) => {
|
||||
const src = readFileSync(`${process.cwd()}/src/${file}`, 'utf8');
|
||||
return src.includes('IconButton') ? [{ file, ...uncoveredIconButtons(src, file) }] : [];
|
||||
});
|
||||
|
||||
it('lists exactly the IconButtons without a PhoneHit class', () => {
|
||||
expect(scanned.length).toBeGreaterThan(0);
|
||||
const bare: Record<string, number[]> = {};
|
||||
tsxFiles('').forEach((file) => {
|
||||
const src = readFileSync(`${process.cwd()}/src/app/${file}`, 'utf8');
|
||||
if (!src.includes('<IconButton')) return;
|
||||
const missing = phoneHitClasses(src).flatMap((c, i) => (c === null ? [i] : []));
|
||||
scanned.forEach(({ file, bare: missing }) => {
|
||||
if (missing.length > 0) bare[file] = missing;
|
||||
});
|
||||
expect(bare).toEqual(
|
||||
Object.fromEntries(Object.entries(EXEMPT).map(([f, e]) => [f, e.indices]))
|
||||
);
|
||||
});
|
||||
|
||||
// An IconButton passed as a value (as={IconButton}), re-bound, re-exported or destructured cannot be tied to a
|
||||
// PhoneHit class, so the guard names it instead of passing over it. Render it as a <IconButton> tag, or extend the
|
||||
// guard to read the new form.
|
||||
it('finds no IconButton the guard cannot check', () => {
|
||||
const unhandled: Record<string, string[]> = {};
|
||||
scanned.forEach(({ file, unhandled: found }) => {
|
||||
if (found.length > 0) unhandled[file] = found;
|
||||
});
|
||||
expect(unhandled).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
// UrlPreviewCard's arrows are absolutely positioned by their own class; PhoneHit would set position: relative on them
|
||||
|
|
|
|||
|
|
@ -104,4 +104,16 @@ describe('what the PhoneHit class relies on', () => {
|
|||
expect(stack).toMatch(/direction="Column"/);
|
||||
expect(stack).toMatch(/gap="100"/);
|
||||
});
|
||||
|
||||
// The same column rhythm in the scrolling Sidebar: its 40 px buttons sit in a Column of Box gap="100" (4 px).
|
||||
it('the emoji sidebar column keeps its 4 px gap', () => {
|
||||
const src = readSource('components/emoji-board/components/Sidebar.tsx');
|
||||
const column = src.slice(
|
||||
src.indexOf('export function Sidebar('),
|
||||
src.indexOf('export const SidebarStack')
|
||||
);
|
||||
expect(column).toMatch(
|
||||
/<Box className=\{css\.SidebarContent\} direction="Column"[^>]*gap="100"/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
expectSizeTie,
|
||||
iconButtons,
|
||||
phoneHitOf,
|
||||
uncoveredIconButtons,
|
||||
} from './phoneHitTestKit';
|
||||
|
||||
describe('phone hit test kit', () => {
|
||||
|
|
@ -151,4 +152,125 @@ describe('phone hit test kit', () => {
|
|||
expect(() => check(wrongModule, ['PhoneHit'])).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('uncoveredIconButtons: the coverage guard is not blind to a spelling of IconButton', () => {
|
||||
const imp = "import { PhoneHit } from '../styles/PhoneHit.css';\n";
|
||||
const none = { bare: [], unhandled: [] };
|
||||
|
||||
it('sees an IconButton imported under an alias, as a tag', () => {
|
||||
const bare = "import { IconButton as IB } from 'folds';\nconst A = () => <IB />;";
|
||||
expect(uncoveredIconButtons(bare)).toEqual({ bare: [0], unhandled: [] });
|
||||
const ok = `${imp}import { IconButton as IB } from 'folds';\nconst A = () => <IB className={PhoneHit} />;`;
|
||||
expect(uncoveredIconButtons(ok)).toEqual(none);
|
||||
});
|
||||
|
||||
it('sees a namespace member tag', () => {
|
||||
const bare = "import * as F from 'folds';\nconst A = () => <F.IconButton />;";
|
||||
expect(uncoveredIconButtons(bare)).toEqual({ bare: [0], unhandled: [] });
|
||||
const ok = `${imp}import * as F from 'folds';\nconst A = () => <F.IconButton className={PhoneHit}></F.IconButton>;`;
|
||||
expect(uncoveredIconButtons(ok)).toEqual(none);
|
||||
});
|
||||
|
||||
it('fails loudly on an IconButton passed or bound as a value, and on a re-export', () => {
|
||||
const asProp =
|
||||
"import { IconButton } from 'folds';\nconst A = () => <Box as={IconButton} />;";
|
||||
expect(uncoveredIconButtons(asProp).unhandled).toEqual(['2:26']);
|
||||
const aliasValue =
|
||||
"import { IconButton as IB } from 'folds';\nconst A = () => <Box as={IB} />;";
|
||||
expect(uncoveredIconButtons(aliasValue).unhandled).toEqual(['2:26']);
|
||||
const rebound = "import { IconButton } from 'folds';\nconst B = IconButton;";
|
||||
expect(uncoveredIconButtons(rebound).unhandled).toEqual(['2:11']);
|
||||
const destructured = "import * as F from 'folds';\nconst { IconButton: IB } = F;";
|
||||
expect(uncoveredIconButtons(destructured).unhandled).toEqual(['2:9']);
|
||||
const member = "import * as F from 'folds';\nconst B = F.IconButton;";
|
||||
expect(uncoveredIconButtons(member).unhandled).toEqual(['2:13']);
|
||||
const indexed = "import * as F from 'folds';\nconst B = F['IconButton'];";
|
||||
expect(uncoveredIconButtons(indexed).unhandled).toEqual(['2:13']);
|
||||
expect(uncoveredIconButtons("export { IconButton } from 'folds';").unhandled).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reads a non-.tsx file by its extension', () => {
|
||||
const ts =
|
||||
"import { IconButton } from 'folds';\nexport const x = <T,>(v: T) => <IconButton />;";
|
||||
expect(uncoveredIconButtons(ts, 'x.tsx').bare).toEqual([0]);
|
||||
const js = "import { IconButton } from 'folds';\nexport const B = IconButton;";
|
||||
expect(uncoveredIconButtons(js, 'x.ts').unhandled).toEqual(['2:18']);
|
||||
expect(uncoveredIconButtons(js, 'x.js').unhandled).toEqual(['2:18']);
|
||||
const jsx = "import { IconButton } from 'folds';\nexport const A = () => <IconButton />;";
|
||||
expect(uncoveredIconButtons(jsx, 'x.jsx').bare).toEqual([0]);
|
||||
});
|
||||
|
||||
it('does not count IconButtonX, a comment or a string as an IconButton', () => {
|
||||
const src =
|
||||
"import { IconButtonGroup } from 'folds';\n// IconButton\nconst a = 'IconButton';\nconst A = () => <IconButtonGroup />;";
|
||||
expect(uncoveredIconButtons(src)).toEqual(none);
|
||||
});
|
||||
|
||||
it('does not count a PhoneHit that an inner scope rebinds', () => {
|
||||
const tag = '<IconButton className={PhoneHit} />';
|
||||
const cases = [
|
||||
`const A = (PhoneHit) => ${tag};`,
|
||||
`const A = ({ PhoneHit }) => ${tag};`,
|
||||
`const A = ([a, PhoneHit]) => ${tag};`,
|
||||
`function A() { const PhoneHit = 'x'; return ${tag}; }`,
|
||||
`function A() { return ${tag}; var PhoneHit; }`,
|
||||
`function A() { if (a) { var PhoneHit = 'x'; } return ${tag}; }`,
|
||||
`function A() { { let { PhoneHit } = o; return ${tag}; } }`,
|
||||
`function A() { function PhoneHit() {} return ${tag}; }`,
|
||||
`function A() { for (const PhoneHit of xs) { return ${tag}; } }`,
|
||||
`function A() { try { f(); } catch (PhoneHit) { return ${tag}; } }`,
|
||||
`const A = function PhoneHit() { return ${tag}; };`,
|
||||
`function A() { switch (x) { case 1: const PhoneHit = 'x'; return ${tag}; } }`,
|
||||
];
|
||||
cases.forEach((c) => expect(uncoveredIconButtons(`${imp}${c}`).bare, c).toEqual([0]));
|
||||
// the same shapes without the rebinding count, and a rebinding that is not in scope does not hide it
|
||||
expect(uncoveredIconButtons(`${imp}const A = () => ${tag};`).bare).toEqual([]);
|
||||
expect(
|
||||
uncoveredIconButtons(`${imp}const f = (PhoneHit) => 1;\nconst A = () => ${tag};`).bare
|
||||
).toEqual([]);
|
||||
expect(uncoveredIconButtons(`${imp}function A() { return ${tag}; }`).bare).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not count an import type of PhoneHit', () => {
|
||||
const tag = 'const A = () => <IconButton className={PhoneHit} />;';
|
||||
const typeOnly = `import type { PhoneHit } from '../styles/PhoneHit.css';\n${tag}`;
|
||||
expect(uncoveredIconButtons(typeOnly).bare).toEqual([0]);
|
||||
const typeSpecifier = `import { type PhoneHit } from '../styles/PhoneHit.css';\n${tag}`;
|
||||
expect(uncoveredIconButtons(typeSpecifier).bare).toEqual([0]);
|
||||
expect(uncoveredIconButtons(`${imp}${tag}`).bare).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('drawnPx reads the parsed tag, not its text', () => {
|
||||
it('ignores a size in a string, comment or other prop name', () => {
|
||||
expect(drawnPx('<IconButton title=" size=\'500\'">', '<Icon />')).toEqual({ px: 40 });
|
||||
expect(drawnPx('<IconButton data-size="600" iconSize="500">', '<Icon />')).toEqual({
|
||||
px: 40,
|
||||
});
|
||||
expect(
|
||||
drawnPx('<IconButton size="300">', '{/* <Icon size="500" /> */}<Icon size="50" />')
|
||||
).toEqual({
|
||||
px: 24,
|
||||
});
|
||||
expect(drawnPx('<IconButton size="300">', '{"<Icon size=\'500\' />"}')).toHaveProperty(
|
||||
'undetermined'
|
||||
);
|
||||
});
|
||||
|
||||
it('reads a size from a braced string, and the last size wins', () => {
|
||||
expect(drawnPx("<IconButton size={'300'}>", '<Icon size={"50"} />')).toEqual({ px: 24 });
|
||||
expect(drawnPx('<IconButton size="300" size="500">', '<Icon size="50" />')).toEqual({
|
||||
px: 40,
|
||||
});
|
||||
});
|
||||
|
||||
it('reads an Icon spread after a size, and a size past a braced prop with a > inside', () => {
|
||||
expect(drawnPx('<IconButton onClick={() => a > b} size="300">', '<Icon />')).toEqual({
|
||||
px: 32,
|
||||
});
|
||||
expect(drawnPx('<IconButton size="300">', '<Icon size="50" {...p} />')).toHaveProperty(
|
||||
'undetermined'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -61,39 +61,78 @@ export const rulesFor = (css: string, selector: RegExp, media: string | undefine
|
|||
export const readSource = (file: string) =>
|
||||
readFileSync(`${process.cwd()}/src/app/${file}`, 'utf8');
|
||||
|
||||
/** The end of a JSX opening tag starting at `from`: its closing > outside any {} expression. */
|
||||
const tagEnd = (src: string, from: number) => {
|
||||
let depth = 0;
|
||||
let j = from;
|
||||
for (; j < src.length; j += 1) {
|
||||
if (src[j] === '{') depth += 1;
|
||||
else if (src[j] === '}') depth -= 1;
|
||||
else if (src[j] === '>' && depth === 0) break;
|
||||
}
|
||||
return j;
|
||||
};
|
||||
|
||||
const tsx = (src: string) =>
|
||||
ts.createSourceFile('source.tsx', src, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
||||
// The extension picks the script kind (.ts reads `<T>x` as an assertion, .tsx and .jsx read it as JSX).
|
||||
const tsx = (src: string, fileName = 'source.tsx') =>
|
||||
ts.createSourceFile(fileName, src, ts.ScriptTarget.Latest, true);
|
||||
|
||||
type Opening = ts.JsxOpeningElement | ts.JsxSelfClosingElement;
|
||||
|
||||
/** Every IconButton element (not IconButtonX) of a parsed source, in source order. */
|
||||
const isTag = (n: ts.Node): n is Opening =>
|
||||
ts.isJsxOpeningElement(n) || ts.isJsxSelfClosingElement(n);
|
||||
|
||||
/** Local names the source binds IconButton to by import: IconButton itself and any alias (IconButton as IB). */
|
||||
const iconButtonNames = (sf: ts.SourceFile) => {
|
||||
const names = new Set(['IconButton']);
|
||||
sf.statements.forEach((st) => {
|
||||
const nb = ts.isImportDeclaration(st) ? st.importClause?.namedBindings : undefined;
|
||||
if (nb && ts.isNamedImports(nb))
|
||||
nb.elements.forEach((e) => {
|
||||
if ((e.propertyName ?? e.name).text === 'IconButton') names.add(e.name.text);
|
||||
});
|
||||
});
|
||||
return names;
|
||||
};
|
||||
|
||||
/** Whether a JSX tag name is an IconButton: the plain or aliased identifier, or a namespace member (<F.IconButton>). */
|
||||
const isIconButtonTag = (tag: ts.JsxTagNameExpression, names: Set<string>) =>
|
||||
(ts.isIdentifier(tag) && names.has(tag.text)) ||
|
||||
(ts.isPropertyAccessExpression(tag) && tag.name.text === 'IconButton');
|
||||
|
||||
/** Every IconButton element (not IconButtonX) of a parsed source, in source order, however its tag is spelled. */
|
||||
const elements = (sf: ts.SourceFile) => {
|
||||
const names = iconButtonNames(sf);
|
||||
const found: Opening[] = [];
|
||||
const walk = (n: ts.Node) => {
|
||||
if (
|
||||
(ts.isJsxOpeningElement(n) || ts.isJsxSelfClosingElement(n)) &&
|
||||
ts.isIdentifier(n.tagName) &&
|
||||
n.tagName.text === 'IconButton'
|
||||
)
|
||||
found.push(n);
|
||||
if (isTag(n) && isIconButtonTag(n.tagName, names)) found.push(n);
|
||||
ts.forEachChild(n, walk);
|
||||
};
|
||||
walk(sf);
|
||||
return found;
|
||||
};
|
||||
|
||||
/** Every mention of IconButton that is not a JSX tag or the import itself: passed as a value (as={IconButton}),
|
||||
* re-bound, re-exported, reached by destructuring or by an element access. Such a render cannot be tied to a
|
||||
* PhoneHit class, so each is reported ("line:col") and the guard fails on it. */
|
||||
const unhandledMentions = (sf: ts.SourceFile) => {
|
||||
const names = iconButtonNames(sf);
|
||||
const out: string[] = [];
|
||||
const at = (n: ts.Node) => {
|
||||
const { line, character } = sf.getLineAndCharacterOfPosition(n.getStart());
|
||||
out.push(`${line + 1}:${character + 1}`);
|
||||
};
|
||||
const walk = (n: ts.Node) => {
|
||||
if (ts.isIdentifier(n) && names.has(n.text)) {
|
||||
const p = n.parent;
|
||||
const tagged = (x: ts.Node) =>
|
||||
(isTag(x) || ts.isJsxClosingElement(x)) && x.tagName === (n as ts.Node);
|
||||
const member =
|
||||
ts.isPropertyAccessExpression(p) &&
|
||||
p.name === n &&
|
||||
(isTag(p.parent) || ts.isJsxClosingElement(p.parent)) &&
|
||||
p.parent.tagName === p;
|
||||
if (!ts.isImportSpecifier(p) && !tagged(p) && !member) at(n);
|
||||
} else if (
|
||||
ts.isStringLiteralLike(n) &&
|
||||
n.text === 'IconButton' &&
|
||||
ts.isElementAccessExpression(n.parent)
|
||||
)
|
||||
at(n);
|
||||
ts.forEachChild(n, walk);
|
||||
};
|
||||
walk(sf);
|
||||
return out;
|
||||
};
|
||||
|
||||
const bodyOf = (el: Opening) =>
|
||||
ts.isJsxOpeningElement(el) && ts.isJsxElement(el.parent)
|
||||
? el.getSourceFile().text.slice(el.end, el.parent.closingElement.getStart())
|
||||
|
|
@ -105,29 +144,94 @@ export const iconButtons = (src: string) =>
|
|||
|
||||
export const iconButtonTags = (src: string) => iconButtons(src).map((b) => b.tag);
|
||||
|
||||
/** Local names the source imports from the PhoneHit.css module (as written, not aliased). */
|
||||
/** Local names the source imports as values from the PhoneHit.css module (as written, not aliased). An
|
||||
* `import type`, or a `type` specifier, binds no value and is not read. */
|
||||
const importedHits = (sf: ts.SourceFile) =>
|
||||
new Set(
|
||||
sf.statements.flatMap((st) => {
|
||||
const nb = ts.isImportDeclaration(st) ? st.importClause?.namedBindings : undefined;
|
||||
if (!ts.isImportDeclaration(st) || !ts.isStringLiteral(st.moduleSpecifier)) return [];
|
||||
const nb = st.importClause?.namedBindings;
|
||||
return nb &&
|
||||
!st.importClause?.isTypeOnly &&
|
||||
ts.isNamedImports(nb) &&
|
||||
ts.isStringLiteral((st as ts.ImportDeclaration).moduleSpecifier) &&
|
||||
/^(\.\.\/)+styles\/PhoneHit\.css$/.test(
|
||||
((st as ts.ImportDeclaration).moduleSpecifier as ts.StringLiteral).text
|
||||
)
|
||||
? nb.elements.filter((e) => !e.propertyName).map((e) => e.name.text)
|
||||
/^(\.\.\/)+styles\/PhoneHit\.css$/.test(st.moduleSpecifier.text)
|
||||
? nb.elements.filter((e) => !e.propertyName && !e.isTypeOnly).map((e) => e.name.text)
|
||||
: [];
|
||||
})
|
||||
);
|
||||
|
||||
const bindingNames = (b: ts.BindingName): string[] =>
|
||||
ts.isIdentifier(b)
|
||||
? [b.text]
|
||||
: b.elements.flatMap((e) => (ts.isOmittedExpression(e) ? [] : bindingNames(e.name)));
|
||||
|
||||
const declaresVar = (list: ts.VariableDeclarationList, name: string) =>
|
||||
list.declarations.some((d) => bindingNames(d.name).includes(name));
|
||||
|
||||
/** Whether `var name` is declared anywhere in a function body, outside nested functions (var hoists to the function). */
|
||||
const hoistsVar = (fn: ts.Node, name: string) => {
|
||||
let found = false;
|
||||
const walk = (n: ts.Node) => {
|
||||
if (found || (n !== fn && ts.isFunctionLike(n))) return;
|
||||
if (
|
||||
ts.isVariableDeclarationList(n) &&
|
||||
// eslint-disable-next-line no-bitwise -- NodeFlags is a bit set; var is the one declaration kind with neither flag
|
||||
(n.flags & ts.NodeFlags.BlockScoped) === 0 &&
|
||||
declaresVar(n, name)
|
||||
)
|
||||
found = true;
|
||||
ts.forEachChild(n, walk);
|
||||
};
|
||||
walk(fn);
|
||||
return found;
|
||||
};
|
||||
|
||||
/** Whether one scope node declares `name` of its own: parameters, a function expression's own name, block-level
|
||||
* let/const/function/class, a for-loop head or a catch variable. */
|
||||
const scopeDeclares = (scope: ts.Node, name: string): boolean => {
|
||||
const inStatements = (sts: readonly ts.Statement[]) =>
|
||||
sts.some(
|
||||
(st) =>
|
||||
(ts.isVariableStatement(st) && declaresVar(st.declarationList, name)) ||
|
||||
((ts.isFunctionDeclaration(st) || ts.isClassDeclaration(st)) && st.name?.text === name)
|
||||
);
|
||||
if (ts.isBlock(scope) || ts.isModuleBlock(scope)) return inStatements(scope.statements);
|
||||
if (ts.isCaseBlock(scope)) return inStatements(scope.clauses.flatMap((c) => [...c.statements]));
|
||||
if (ts.isFunctionLike(scope))
|
||||
return (
|
||||
scope.parameters.some((p) => bindingNames(p.name).includes(name)) ||
|
||||
((ts.isFunctionExpression(scope) || ts.isClassExpression(scope)) &&
|
||||
scope.name?.text === name) ||
|
||||
hoistsVar(scope, name)
|
||||
);
|
||||
if (ts.isForStatement(scope) || ts.isForInStatement(scope) || ts.isForOfStatement(scope))
|
||||
return (
|
||||
!!scope.initializer &&
|
||||
ts.isVariableDeclarationList(scope.initializer) &&
|
||||
declaresVar(scope.initializer, name)
|
||||
);
|
||||
if (ts.isCatchClause(scope))
|
||||
return (
|
||||
!!scope.variableDeclaration && bindingNames(scope.variableDeclaration.name).includes(name)
|
||||
);
|
||||
return false;
|
||||
};
|
||||
|
||||
/** Whether an inner scope between the identifier and the module rebinds its name, so it is not the import. */
|
||||
const shadowed = (id: ts.Identifier) => {
|
||||
for (let n: ts.Node | undefined = id.parent; n && !ts.isSourceFile(n); n = n.parent)
|
||||
if (scopeDeclares(n, id.text)) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const MERGERS = new Set(['classNames', 'clsx', 'cx']);
|
||||
|
||||
/** The PhoneHit* class an IconButton carries, or null. className must appear exactly once, with no props spread
|
||||
* after it (a spread can override it at runtime), and its expression must be: the bare identifier; a classNames /
|
||||
* clsx / cx call with it as a direct argument; or a template with it as a direct span. The identifier must occur
|
||||
* nowhere else in the expression, so a key, a member, a closure, a conditional or a comma can never carry it. When
|
||||
* `imported` is given, the identifier must also be a binding imported from the PhoneHit.css module. */
|
||||
* `imported` is given, the identifier must also resolve to a value binding imported from the PhoneHit.css module
|
||||
* (not an `import type`, not rebound by an inner scope). */
|
||||
const hitOf = (el: Opening, imported?: Set<string>): string | null => {
|
||||
const props = el.attributes.properties;
|
||||
const at = props.flatMap((p, i) =>
|
||||
|
|
@ -139,7 +243,9 @@ const hitOf = (el: Opening, imported?: Set<string>): string | null => {
|
|||
attr.initializer && ts.isJsxExpression(attr.initializer) && attr.initializer.expression;
|
||||
if (!expr) return null;
|
||||
const isHit = (n: ts.Node): n is ts.Identifier =>
|
||||
ts.isIdentifier(n) && /^PhoneHit\w*$/.test(n.text) && (!imported || imported.has(n.text));
|
||||
ts.isIdentifier(n) &&
|
||||
/^PhoneHit\w*$/.test(n.text) &&
|
||||
(!imported || (imported.has(n.text) && !shadowed(n)));
|
||||
let direct: ts.Node[] = [];
|
||||
if (ts.isIdentifier(expr)) direct = [expr];
|
||||
else if (
|
||||
|
|
@ -174,6 +280,18 @@ export const phoneHitClasses = (src: string) => {
|
|||
return elements(sf).map((el) => hitOf(el, imported));
|
||||
};
|
||||
|
||||
/** What a source file leaves uncovered: the indices (source order) of its IconButton elements without a PhoneHit
|
||||
* class, and the "line:col" of every mention of IconButton that is not a tag and so cannot be checked at all. The
|
||||
* fileName picks the parser (.ts, .tsx, .js, ...). */
|
||||
export const uncoveredIconButtons = (src: string, fileName = 'source.tsx') => {
|
||||
const sf = tsx(src, fileName);
|
||||
const imported = importedHits(sf);
|
||||
return {
|
||||
bare: elements(sf).flatMap((el, i) => (hitOf(el, imported) === null ? [i] : [])),
|
||||
unhandled: unhandledMentions(sf),
|
||||
};
|
||||
};
|
||||
|
||||
/** The class each IconButton must carry, in source order (null = must carry none): the variant is chosen by the
|
||||
* button's drawn size and whether a clipping container or neighbours surround it, so a swapped variant is a wrong
|
||||
* hit area. The name must also be the one PhoneHit.css exports, not a local of the same name. */
|
||||
|
|
@ -192,29 +310,44 @@ const ICON_PX: Record<string, number> = {
|
|||
};
|
||||
|
||||
/** The size prop of a tag: its literal value, the default when absent, or undefined when it is an expression. */
|
||||
const sizeProp = (tag: string) => {
|
||||
const sizeProp = (el: Opening) => {
|
||||
const props = el.attributes.properties;
|
||||
// props spread in may carry a size of their own
|
||||
if (tag.includes('{...')) return undefined;
|
||||
const m = tag.match(/\ssize=(?:"(\w+)"|\{([^}]*)\})/);
|
||||
if (!m) return '400';
|
||||
return m[1] ?? m[2].match(/^'(\w+)'$/)?.[1];
|
||||
if (props.some(ts.isJsxSpreadAttribute)) return undefined;
|
||||
const sizes = props.filter(
|
||||
(p): p is ts.JsxAttribute =>
|
||||
ts.isJsxAttribute(p) && ts.isIdentifier(p.name) && p.name.text === 'size'
|
||||
);
|
||||
if (sizes.length === 0) return '400';
|
||||
const init = sizes[sizes.length - 1].initializer;
|
||||
const lit = init && ts.isJsxExpression(init) ? init.expression : init;
|
||||
return lit && ts.isStringLiteralLike(lit) ? lit.text : undefined;
|
||||
};
|
||||
|
||||
/** The px an IconButton draws, from its size prop and the Icon inside; undefined with a reason when the source
|
||||
* does not say (a variable size, no Icon, Icons of different sizes). */
|
||||
* does not say (a variable size, no Icon, Icons of different sizes). Both are read off the parsed tag and body. */
|
||||
export const drawnPx = (tag: string, body: string): { px: number } | { undetermined: string } => {
|
||||
const pad = BUTTON_PAD[sizeProp(tag) ?? ''];
|
||||
let button: Opening | undefined;
|
||||
const first = (n: ts.Node) => {
|
||||
if (!button && isTag(n)) button = n;
|
||||
else if (!button) ts.forEachChild(n, first);
|
||||
};
|
||||
first(tsx(tag.endsWith('/>') ? tag : `${tag}</x>`));
|
||||
const pad = BUTTON_PAD[(button && sizeProp(button)) ?? ''];
|
||||
if (pad === undefined)
|
||||
return { undetermined: 'size of the IconButton is not a literal (or props are spread in)' };
|
||||
const icons = [...body.matchAll(/<Icon(?=[\s>/])/g)].map((m) => {
|
||||
const t = body.slice(m.index, tagEnd(body, m.index ?? 0) + 1);
|
||||
return ICON_PX[sizeProp(t) ?? ''];
|
||||
});
|
||||
const icons: (number | undefined)[] = [];
|
||||
const find = (n: ts.Node) => {
|
||||
if (isTag(n) && ts.isIdentifier(n.tagName) && n.tagName.text === 'Icon')
|
||||
icons.push(ICON_PX[sizeProp(n) ?? '']);
|
||||
ts.forEachChild(n, find);
|
||||
};
|
||||
find(tsx(`<>${body}</>`));
|
||||
if (icons.length === 0) return { undetermined: 'no <Icon> inside' };
|
||||
if (icons.some((p) => p === undefined))
|
||||
return { undetermined: 'size of an Icon is not a literal (or props are spread in)' };
|
||||
if (new Set(icons).size > 1) return { undetermined: 'Icons of different sizes inside' };
|
||||
return { px: icons[0] + 2 * pad };
|
||||
return { px: (icons[0] as number) + 2 * pad };
|
||||
};
|
||||
|
||||
/** Per file, per IconButton index (source order): why its drawn size cannot be derived from the source. */
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { IImageInfo, IThumbnailContent, IVideoInfo } from '../../types/matrix/co
|
|||
import { AccountDataEvent } from '../../types/matrix/accountData';
|
||||
import { getStateEvent } from './room';
|
||||
import { Membership, StateEvent } from '../../types/matrix/room';
|
||||
import { NO_INFO, UploadFailureInfo } from './uploadReason';
|
||||
|
||||
const DOMAIN_REGEX = /\b(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}\b/;
|
||||
|
||||
|
|
@ -392,8 +393,19 @@ export const uploadFailure = async (
|
|||
if (trace.previous) parts.push(`retried (attempt 1: ${trace.previous})`);
|
||||
}
|
||||
const error = `Upload failed: ${message ? `${name}: ${message}` : name} (${parts.join(', ')})`;
|
||||
const err = new MatrixError({ error, errcode: name });
|
||||
// A homeserver refusal keeps its own errcode (M_TOO_LARGE ...), which is what names it to the sender.
|
||||
const errcode = typeof e?.errcode === 'string' && e.errcode.startsWith('M_') ? e.errcode : name;
|
||||
const err = new MatrixError({ error, errcode });
|
||||
(err as any).cause = e;
|
||||
const info: UploadFailureInfo = {
|
||||
unreadable: readable !== 'file readable',
|
||||
offline: typeof navigator !== 'undefined' && navigator.onLine === false,
|
||||
hidden: typeof document !== 'undefined' && document?.visibilityState === 'hidden',
|
||||
aborted: name === 'AbortError',
|
||||
progressed: trace?.firstProgressAt !== undefined,
|
||||
stopped: trace?.signalAborted === true,
|
||||
};
|
||||
(err as any).info = info;
|
||||
return err;
|
||||
};
|
||||
|
||||
|
|
@ -435,14 +447,14 @@ export const uploadContent = async (
|
|||
body = await copyInMemory(file);
|
||||
} catch (readErr: any) {
|
||||
const errcode = readErr?.name || 'Error';
|
||||
onError(
|
||||
new MatrixError({
|
||||
error: `Upload failed: file unreadable before upload (${errcode}) (${file.size} bytes, ${
|
||||
file.type || 'unknown type'
|
||||
})`,
|
||||
errcode,
|
||||
})
|
||||
);
|
||||
const unreadable = new MatrixError({
|
||||
error: `Upload failed: file unreadable before upload (${errcode}) (${file.size} bytes, ${
|
||||
file.type || 'unknown type'
|
||||
})`,
|
||||
errcode,
|
||||
});
|
||||
(unreadable as any).info = { ...NO_INFO, unreadable: true };
|
||||
onError(unreadable);
|
||||
return;
|
||||
}
|
||||
const probe = startAbortProbe();
|
||||
|
|
|
|||
111
src/app/utils/uploadReason.test.ts
Normal file
111
src/app/utils/uploadReason.test.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { uploadContent, uploadFailure } from './matrix';
|
||||
import { uploadFailureText, uploadReason } from './uploadReason';
|
||||
|
||||
// What a person reads, from failures built the way the app builds them: through uploadFailure and
|
||||
// uploadContent, not by hand-made info objects.
|
||||
const abort = () => new DOMException('', 'AbortError');
|
||||
const blob = new Blob(['abcd'], { type: 'image/jpeg' });
|
||||
const textOf = async (e: unknown, trace = { startedAt: Date.now() } as any, file: Blob = blob) =>
|
||||
uploadFailureText(await uploadFailure(e, file, trace));
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('what the sender is told when an upload fails', () => {
|
||||
it('a drop before any byte left says the file never started, and to send again', async () => {
|
||||
expect(await textOf(abort())).toBe(
|
||||
'Upload failed: the connection dropped before the file started. Send again.'
|
||||
);
|
||||
});
|
||||
|
||||
it('a drop after progress says partway through and points at Wi-Fi', async () => {
|
||||
const text = await textOf(abort(), { startedAt: 1, firstProgressAt: 5, loaded: 2, total: 4 });
|
||||
expect(text).toContain('dropped partway through');
|
||||
expect(text).toContain('Wi-Fi');
|
||||
});
|
||||
|
||||
it('a drop with the tab in the background says to keep the tab open', async () => {
|
||||
vi.stubGlobal('document', { visibilityState: 'hidden' });
|
||||
const text = await textOf(abort());
|
||||
expect(text).toContain('(this tab was in the background)');
|
||||
expect(text).toContain('Keep this tab open');
|
||||
});
|
||||
|
||||
it('offline outranks the drop it caused', async () => {
|
||||
vi.stubGlobal('navigator', { onLine: false });
|
||||
expect(await textOf(abort())).toBe('Upload failed: you are offline. Reconnect and send again.');
|
||||
});
|
||||
|
||||
it('offline is not blamed for a failure that was not a dropped request', async () => {
|
||||
vi.stubGlobal('navigator', { onLine: false });
|
||||
expect(await textOf(new Error('Bad Gateway'))).toContain('Upload failed. Send again;');
|
||||
});
|
||||
|
||||
it('a file the browser can no longer read says to pick it again', async () => {
|
||||
const gone = new Blob(['x']);
|
||||
(gone as any).arrayBuffer = () => Promise.reject(new DOMException('gone', 'NotReadableError'));
|
||||
expect(await textOf(abort(), undefined, gone)).toContain('can no longer read the file');
|
||||
});
|
||||
|
||||
it('a stopped upload says stopped, not that the connection failed', async () => {
|
||||
const text = await textOf(abort(), { startedAt: 1, signalAborted: true });
|
||||
expect(text).toBe('Upload failed: stopped. Send it again when you are ready.');
|
||||
});
|
||||
|
||||
it('a homeserver refusal says what the server said in words, with what to do', () => {
|
||||
expect(uploadFailureText({ errcode: 'M_TOO_LARGE' })).toBe(
|
||||
'Upload failed: too large for this server. Pick a smaller file.'
|
||||
);
|
||||
expect(uploadFailureText({ errcode: 'M_FORBIDDEN' })).toContain('Ask a steward');
|
||||
expect(uploadFailureText({ errcode: 'M_LIMIT_EXCEEDED' })).toContain('Wait a moment');
|
||||
});
|
||||
|
||||
it('a failure we cannot name stays generic and sends the sender to the details', async () => {
|
||||
const text = await textOf(new Error('Timeout'));
|
||||
expect(text).toBe(
|
||||
'Upload failed. Send again; if it keeps failing, show the details below to a steward.'
|
||||
);
|
||||
expect(uploadReason({ errcode: 'constructor' })).toBeUndefined();
|
||||
expect(uploadReason(null)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('the long diagnostic text is still on the error for the details', async () => {
|
||||
const err = await uploadFailure(abort(), blob, { startedAt: Date.now() });
|
||||
expect(err.message).toContain('4 bytes');
|
||||
expect(err.message).toContain('no progress');
|
||||
});
|
||||
});
|
||||
|
||||
describe('through uploadContent, as the composer and the share panel call it', () => {
|
||||
it('an unreadable picked file is named before any request', async () => {
|
||||
const bad = new Blob(['x']);
|
||||
(bad as any).arrayBuffer = () => Promise.reject(new DOMException('gone', 'NotReadableError'));
|
||||
const mx = { uploadContent: vi.fn() } as any;
|
||||
const onError = vi.fn();
|
||||
await uploadContent(mx, bad, { onSuccess: vi.fn(), onError });
|
||||
expect(mx.uploadContent).not.toHaveBeenCalled();
|
||||
expect(uploadFailureText(onError.mock.calls[0][0])).toContain('can no longer read the file');
|
||||
});
|
||||
|
||||
it('a request that dies is told as a drop', async () => {
|
||||
const mx = { uploadContent: () => Promise.reject(abort()) } as any;
|
||||
const onError = vi.fn();
|
||||
await uploadContent(mx, blob, { onSuccess: vi.fn(), onError });
|
||||
expect(uploadFailureText(onError.mock.calls[0][0])).toContain('the connection dropped');
|
||||
});
|
||||
|
||||
it('a server that refuses keeps its errcode and is told in words', async () => {
|
||||
const mx = {
|
||||
uploadContent: () =>
|
||||
Promise.reject(Object.assign(new Error('x'), { errcode: 'M_TOO_LARGE' })),
|
||||
} as any;
|
||||
const onError = vi.fn();
|
||||
await uploadContent(mx, blob, { onSuccess: vi.fn(), onError });
|
||||
expect(onError.mock.calls[0][0].errcode).toBe('M_TOO_LARGE');
|
||||
expect(uploadFailureText(onError.mock.calls[0][0])).toBe(
|
||||
'Upload failed: too large for this server. Pick a smaller file.'
|
||||
);
|
||||
});
|
||||
});
|
||||
78
src/app/utils/uploadReason.ts
Normal file
78
src/app/utils/uploadReason.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
// What a person is told when an upload fails: a cause they can name and the thing to do next.
|
||||
// uploadFailure (matrix.ts) attaches the facts the client observed as `info`; the homeserver's
|
||||
// refusals arrive as Matrix errcodes. The long diagnostic text stays on the error's message.
|
||||
|
||||
/** What the client observed at the moment an upload died. */
|
||||
export type UploadFailureInfo = {
|
||||
/** The browser could not read the picked file back. */
|
||||
unreadable: boolean;
|
||||
/** navigator.onLine was false. */
|
||||
offline: boolean;
|
||||
/** The tab was in the background. */
|
||||
hidden: boolean;
|
||||
/** The request was aborted (the SDK reports a dropped XHR as an AbortError). */
|
||||
aborted: boolean;
|
||||
/** At least one progress event arrived before it died. */
|
||||
progressed: boolean;
|
||||
/** The sender pressed Stop. */
|
||||
stopped: boolean;
|
||||
};
|
||||
|
||||
export const NO_INFO: UploadFailureInfo = {
|
||||
unreadable: false,
|
||||
offline: false,
|
||||
hidden: false,
|
||||
aborted: false,
|
||||
progressed: false,
|
||||
stopped: false,
|
||||
};
|
||||
|
||||
export type UploadReason = { cause: string; action: string };
|
||||
|
||||
const REFUSALS: Record<string, UploadReason> = {
|
||||
M_TOO_LARGE: { cause: 'too large for this server', action: 'Pick a smaller file.' },
|
||||
M_FORBIDDEN: {
|
||||
cause: 'not allowed to upload here',
|
||||
action: 'Ask a steward if you should be able to.',
|
||||
},
|
||||
M_LIMIT_EXCEEDED: {
|
||||
cause: 'uploading too fast, try again shortly',
|
||||
action: 'Wait a moment and send again.',
|
||||
},
|
||||
};
|
||||
|
||||
/** The reason for a failure we can name, or undefined when we cannot: then the text stays generic. */
|
||||
export const uploadReason = (e: unknown): UploadReason | undefined => {
|
||||
const code = (e as { errcode?: unknown } | null)?.errcode;
|
||||
if (typeof code === 'string' && Object.hasOwn(REFUSALS, code)) return REFUSALS[code];
|
||||
const info = (e as { info?: Partial<UploadFailureInfo> } | null)?.info;
|
||||
if (!info) return undefined;
|
||||
if (info.stopped) return { cause: 'stopped', action: 'Send it again when you are ready.' };
|
||||
if (info.unreadable)
|
||||
return {
|
||||
cause: 'this device can no longer read the file',
|
||||
action: 'Pick it again; if it came from another app, save a copy first.',
|
||||
};
|
||||
// Offline and hidden-tab are only read as the cause of a dropped request, not of any failure.
|
||||
if (!info.aborted) return undefined;
|
||||
if (info.offline) return { cause: 'you are offline', action: 'Reconnect and send again.' };
|
||||
if (info.hidden)
|
||||
return {
|
||||
cause: 'the connection dropped (this tab was in the background)',
|
||||
action: 'Keep this tab open while it uploads, and send again.',
|
||||
};
|
||||
return info.progressed
|
||||
? {
|
||||
cause: 'the connection dropped partway through',
|
||||
action: 'Send again; a large file is safer on Wi-Fi.',
|
||||
}
|
||||
: { cause: 'the connection dropped before the file started', action: 'Send again.' };
|
||||
};
|
||||
|
||||
/** The line the upload card shows: the reason when there is one, else a generic line. */
|
||||
export const uploadFailureText = (e: unknown): string => {
|
||||
const reason = uploadReason(e);
|
||||
return reason
|
||||
? `Upload failed: ${reason.cause}. ${reason.action}`
|
||||
: 'Upload failed. Send again; if it keeps failing, show the details below to a steward.';
|
||||
};
|
||||
Loading…
Reference in a new issue