Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
764ee441bd |
267 changed files with 766 additions and 10542 deletions
|
|
@ -6,7 +6,7 @@ and your own place in it) instead of an invite list. Mobile first.
|
||||||
|
|
||||||
**Live now:** the Cinny engine (matrix-js-sdk, Rust crypto, sync) under a
|
**Live now:** the Cinny engine (matrix-js-sdk, Rust crypto, sync) under a
|
||||||
Ratatoskr UI: branding, GameCult theme, estate navigation, pose portraits
|
Ratatoskr UI: branding, GameCult theme, estate navigation, pose portraits
|
||||||
(opt-in setting), a background scene (on in the dev config, `backgroundScene.tier`; the device can lower it) and an opt-in soundscape.
|
(opt-in setting), a background scene and soundscape (opt-in, off by default).
|
||||||
**Planned:** see [`docs/gamecult/ratatoskr-target.md`](docs/gamecult/ratatoskr-target.md)
|
**Planned:** see [`docs/gamecult/ratatoskr-target.md`](docs/gamecult/ratatoskr-target.md)
|
||||||
for the target and cut sequence. Internal names that are protocol or stored
|
for the target and cut sequence. Internal names that are protocol or stored
|
||||||
state (`cinny_*` localStorage keys, the `in.cinny.room.power_level_tags` event
|
state (`cinny_*` localStorage keys, the `in.cinny.room.power_level_tags` event
|
||||||
|
|
|
||||||
38
config.json
38
config.json
|
|
@ -1,38 +1,38 @@
|
||||||
{
|
{
|
||||||
"defaultHomeserver": 0,
|
"defaultHomeserver": 0,
|
||||||
"homeserverList": [
|
"homeserverList": [
|
||||||
"chat.gamecult.org"
|
"gamecult.org",
|
||||||
|
"converser.eu",
|
||||||
|
"matrix.org",
|
||||||
|
"mozilla.org",
|
||||||
|
"unredacted.org",
|
||||||
|
"xmr.se"
|
||||||
],
|
],
|
||||||
"allowCustomHomeservers": false,
|
"allowCustomHomeservers": true,
|
||||||
"featuredCommunities": {
|
"featuredCommunities": {
|
||||||
"openAsDefault": false,
|
"openAsDefault": false,
|
||||||
"spaces": [
|
"spaces": ["#gamecult:gamecult.org"],
|
||||||
"#gamecult:gamecult.org"
|
"rooms": [
|
||||||
|
"#tuwunel:grin.hu",
|
||||||
|
"#free-software-movement:matrix.org",
|
||||||
|
"#gentoo:matrix.org"
|
||||||
],
|
],
|
||||||
"rooms": [],
|
"servers": ["activematrixrooms.com", "matrixrooms.info", "matrix.org", "mozilla.org", "unredacted.org"]
|
||||||
"servers": [
|
|
||||||
"gamecult.org"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
|
|
||||||
"hashRouter": {
|
"hashRouter": {
|
||||||
"enabled": true,
|
"enabled": false,
|
||||||
"basename": "/"
|
"basename": "/"
|
||||||
},
|
},
|
||||||
|
|
||||||
"backgroundScene": {
|
"backgroundScene": {
|
||||||
"tier": "full",
|
"tier": "off"
|
||||||
"style": "ridge"
|
|
||||||
},
|
},
|
||||||
|
|
||||||
"dings": {
|
"dings": {
|
||||||
"enabled": false
|
"enabled": false
|
||||||
},
|
},
|
||||||
"fileZoneShare": {
|
|
||||||
"enabled": false
|
|
||||||
},
|
|
||||||
"push": {
|
|
||||||
"vapidPublicKey": "",
|
|
||||||
"gatewayUrl": "",
|
|
||||||
"appId": ""
|
|
||||||
},
|
|
||||||
"registration": {
|
"registration": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"url": ""
|
"url": ""
|
||||||
|
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
# File zone: what a Mind can read from a file event
|
|
||||||
|
|
||||||
Ruling `ratatoskr:ruling:persona-file-sharing-zone`. The Mind-side fetch is the mouth's
|
|
||||||
(`eureka-body:cut_spec:mouth-fetch-attachment.r1`); this page is the client side of the same contract.
|
|
||||||
A Mind cannot fetch an `mxc://` itself (the media endpoint answers 401), so it never holds one.
|
|
||||||
|
|
||||||
## What the Mind passes
|
|
||||||
|
|
||||||
Room id and event id of the file event. Never a bare `mxc://`, URL or hash. The mouth fetches the event,
|
|
||||||
reads the media reference from its content, and enforces the Mind's own membership in that room at fetch
|
|
||||||
time. A room the Mind is not in is refused by the mouth, whatever the client wrote.
|
|
||||||
|
|
||||||
## What the client writes (this repo, `RoomInput.tsx` send path)
|
|
||||||
|
|
||||||
A file event is an `m.room.message` whose `msgtype` is `m.audio`, `m.image`, `m.video` or `m.file`:
|
|
||||||
|
|
||||||
| field | where | note |
|
|
||||||
| ------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
||||||
| `msgtype` | content | selects the kind from the file's mimetype prefix |
|
|
||||||
| `body`, `filename` | content | both the file's name (display only; never a path) |
|
|
||||||
| `url` | content | the `mxc://` of an unencrypted file; the mouth reads it |
|
|
||||||
| `file` | content | present instead of `url` in an encrypted room: `{url, key, iv, hashes.sha256 (ciphertext), v}`. A Mind has no key; the mouth refuses it |
|
|
||||||
| `info.mimetype` | content | the browser-reported type, normalised by `safeFile` (e.g. `application/ogg` becomes `audio/ogg`). A claim, not proof: the mouth sniffs magic bytes |
|
|
||||||
| `info.size` | content | bytes of the file as uploaded |
|
|
||||||
| `info.sha256` | content | lower-case hex sha256 of the plaintext bytes, computed by the client before send; written in unencrypted rooms only (a plaintext hash in an encrypted room would leak the file's identity) |
|
|
||||||
|
|
||||||
The hash is computed over the same bytes that are uploaded, reading the whole file into memory before send (large videos pay for it); if hashing fails the file is sent without one. A file sent by something other than this
|
|
||||||
client may lack `info.sha256`; the mouth then logs the hash it computed, and the row can show no `fetched for`
|
|
||||||
for it.
|
|
||||||
|
|
||||||
`info.mimetype` and `info.size` are written on audio and file events always; on image and video events `info` exists only if the browser could load the media, otherwise `info` holds `sha256` alone. Image and video events also carry `info.w`, `info.h`, `info.xyz.amorgan.blurhash` and a thumbnail
|
|
||||||
reference; audio carries no duration. A Mind should not rely on any of those.
|
|
||||||
|
|
||||||
## Limits and refusals
|
|
||||||
|
|
||||||
- Size: the mouth streams at most 5 MiB and refuses a larger file. The client does not enforce this cap;
|
|
||||||
its own limit is the homeserver's `m.upload.size`. A file over 5 MiB can be shared with people and
|
|
||||||
not fetched for a Mind.
|
|
||||||
- Refusals are the mouth's and are typed: not a member of the room, bridged or Discord room, encrypted
|
|
||||||
file, over the cap, content type not matching the magic bytes. The client does not re-check them and
|
|
||||||
nothing in the client depends on the mouth's log.
|
|
||||||
- The Mind cites files by room id and event id only; a file in a room the Mind is not in does not exist
|
|
||||||
for it.
|
|
||||||
|
|
||||||
## Receipt: `org.gamecult.file_received`
|
|
||||||
|
|
||||||
The Mind posts one event of type `org.gamecult.file_received` after a successful fetch, to the room of the
|
|
||||||
file. When the file has an album root the receipt is a reply in the album's thread, so it arrives with the album's
|
|
||||||
own listing; otherwise (a composer file sent with no thread) it is a plain room event with no `m.relates_to`. Both
|
|
||||||
count, and the client finds a receipt by its `event_id` anywhere in the room's loaded timeline or the album's thread:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"m.relates_to": { "rel_type": "m.thread", "event_id": "<album root event id>" },
|
|
||||||
"org.gamecult.file_received": {
|
|
||||||
"event_id": "<file event id>",
|
|
||||||
"sha256": "<hex sha256 of the bytes the Mind fetched>",
|
|
||||||
"size": 123456
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
A plain room-event receipt may also carry `m.relates_to: {rel_type: "m.reference", event_id: <file event id>}`, which lets
|
|
||||||
the mouth find its earlier receipts through `/relations` for dedup. The client matches by `content.event_id` and reads no
|
|
||||||
relation but this one: if a `m.reference` is present its `event_id` must equal `content.event_id`, else the receipt counts
|
|
||||||
for nothing. A thread relation is not read. However many receipts a Mind has posted for a file, the row draws one
|
|
||||||
`fetched for` for that Mind.
|
|
||||||
|
|
||||||
Two rules, both enforced on each side:
|
|
||||||
|
|
||||||
1. **The sender is the Mind.** The row trusts the Matrix `sender` of the receipt event and nothing inside it:
|
|
||||||
no Mind field is read. `fetched for X` counts only when the sender is X's own Matrix identity (the
|
|
||||||
full id is `@<mind>:<this homeserver>` for a Mind in the Persona catalogue; a display name, a lookalike or another server's `@void:evil.example` does not count).
|
|
||||||
2. **Fetched means verified.** The Mind posts only after the sha256 of the bytes it fetched equals the file
|
|
||||||
event's `info.sha256`, and the receipt carries that hash. On a mismatch it posts nothing (or a typed
|
|
||||||
refusal that the row does not draw). The row checks it again: the receipt's `sha256` must equal the
|
|
||||||
file event's `info.sha256`. A file event with no `info.sha256` can show no `fetched for`.
|
|
||||||
|
|
||||||
The receipt is inert: no paths, URLs, filenames or anything a browser would fetch, and the client reads only
|
|
||||||
`event_id`, `sha256` and the sender. It has no `msgtype`, so the album's file listing ignores it. The mouth's
|
|
||||||
log is the fuller record for Minds; this event is the public tick.
|
|
||||||
|
|
||||||
The row draws `fetched for <Mind>`: one receipt per Mind per file (the latest), several Minds side by side. A
|
|
||||||
receipt for an event that is not a file of that album, a malformed one, and a redacted one are ignored.
|
|
||||||
The wording is deliberate: the receipt says the mouth fetched and stored the file for the Mind, not that it is in the Mind's sandbox and not that it was read. Client strings never say "saved" or "read".
|
|
||||||
The sender check keeps a member from ticking for a Mind; it does not prove the Mind read the file beyond
|
|
||||||
what the hash and the Mind's own account show.
|
|
||||||
|
|
||||||
## Not built yet
|
|
||||||
|
|
||||||
- Files sent from the composer carry no thread relation, so they reach an album only if written into a
|
|
||||||
thread by another tool; the album upload path (docs/file-zone.md, "Upload") is unwritten. Their receipts are plain
|
|
||||||
room events, and the timeline row for the file shows `fetched for` from them.
|
|
||||||
- A receipt that is still encrypted when it arrives is not seen until a later timeline event triggers a rescan; zones are
|
|
||||||
unencrypted and the hash is written only there, so this is the encrypted-room limit again.
|
|
||||||
- The timeline row reads the room's loaded timeline only: a receipt older than what the client has paged in is not
|
|
||||||
seen there (an album's own listing reads the thread, which is paged separately).
|
|
||||||
- Encrypted zones are not listed.
|
|
||||||
|
|
@ -76,16 +76,16 @@ Reuse the composer's pipeline, pointed at an album instead of the main timeline:
|
||||||
|
|
||||||
## Existing code to reuse (verified present in the repo)
|
## Existing code to reuse (verified present in the repo)
|
||||||
|
|
||||||
| need | where |
|
| need | where |
|
||||||
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
|
|---|---|
|
||||||
| audio player (play/seek/volume, encrypted download) | `src/app/components/message/content/AudioContent.tsx` (210 lines) |
|
| audio player (play/seek/volume, encrypted download) | `src/app/components/message/content/AudioContent.tsx` (210 lines) |
|
||||||
| video, image, generic file | `.../content/VideoContent.tsx`, `ImageContent.tsx`, `FileContent.tsx` |
|
| video, image, generic file | `.../content/VideoContent.tsx`, `ImageContent.tsx`, `FileContent.tsx` |
|
||||||
| full-screen image viewer | `src/app/components/image-viewer/ImageViewer.tsx` |
|
| full-screen image viewer | `src/app/components/image-viewer/ImageViewer.tsx` |
|
||||||
| media dispatch by msgtype | `src/app/components/RenderMessageContent.tsx`, `message/MsgTypeRenderers.tsx` |
|
| media dispatch by msgtype | `src/app/components/RenderMessageContent.tsx`, `message/MsgTypeRenderers.tsx` |
|
||||||
| upload + progress | `state/upload.ts`, `components/upload-card`, `upload-board` |
|
| upload + progress | `state/upload.ts`, `components/upload-card`, `upload-board` |
|
||||||
| event content builders | `features/room/msgContent.ts` |
|
| event content builders | `features/room/msgContent.ts` |
|
||||||
| thread plumbing | `features/room/message/Message.tsx`, `RoomTimeline.tsx`, `RoomInput.tsx` (thread relation on send) |
|
| thread plumbing | `features/room/message/Message.tsx`, `RoomTimeline.tsx`, `RoomInput.tsx` (thread relation on send) |
|
||||||
| room-scoped file/pin precedent | `features/room/room-pin-menu/RoomPinMenu.tsx` |
|
| room-scoped file/pin precedent | `features/room/room-pin-menu/RoomPinMenu.tsx` |
|
||||||
|
|
||||||
Nothing named "gallery" exists as a feature; `AudioContent` takes its controls as render props (`renderMediaControl`), so it can be rehosted in a row without a fork. **UNKNOWN:** whether `AudioContent` can be rendered outside a timeline event context (it needs only `url`, `info`, `mimeType`, `encInfo`, from the event content, so probably yes).
|
Nothing named "gallery" exists as a feature; `AudioContent` takes its controls as render props (`renderMediaControl`), so it can be rehosted in a row without a fork. **UNKNOWN:** whether `AudioContent` can be rendered outside a timeline event context (it needs only `url`, `info`, `mimeType`, `encInfo`, from the event content, so probably yes).
|
||||||
|
|
||||||
|
|
@ -104,20 +104,3 @@ Slices 1+2 serve the first use case. Each is its own PR. Because marks are ordin
|
||||||
- How the client identifies the zone room (config id recommended).
|
- How the client identifies the zone room (config id recommended).
|
||||||
- Whether exports are per listener (proposed) or a merged all-listeners file.
|
- Whether exports are per listener (proposed) or a merged all-listeners file.
|
||||||
- Where `device` comes from, now that reactions cannot carry it (export dialog, proposed).
|
- Where `device` comes from, now that reactions cannot carry it (export dialog, proposed).
|
||||||
|
|
||||||
## Share a set (built, behind `fileZoneShare.enabled`)
|
|
||||||
|
|
||||||
The ruling `ratatoskr:ruling:persona-file-sharing-zone` asks that a set of files arrive together, not one attachment at a time. The zone already holds one thread per album, so a set is an album: **one root text message plus one `m.thread` reply per file**, the exact shape `buildAlbums` reads. Nothing new on the wire and no homeserver change; a Mind's mouth that posts into the zone room and a person using the client produce the same events.
|
|
||||||
|
|
||||||
Matrix options checked: (a) a thread, chosen: stock, rendered by every client, already the zone's album; (b) `m.gallery` (MSC4274) puts several items in one event, but it is unstable, most clients and this one do not render it, and each item still needs its own upload; (c) a shared custom relation: no client groups by it. A thread costs one extra event per set (the root) and nothing else.
|
|
||||||
|
|
||||||
Client, `src/app/features/albums`:
|
|
||||||
|
|
||||||
- `shareSet.ts`: `sendShareSet(port, title, files)`. Sends the root, then each file in pick order, each reply falling back to the previous event (`threadRelation`). A file whose upload fails is named in the result and the rest still go; a root that fails to send throws before any upload. The root alone is not an album in the list (`/threads` returns only roots that have a reply), so a share where every upload failed leaves a lone text message in the timeline. After a send the panel keeps only the failed files picked; a retry makes a second album. Ports are injected, so the order and failure rules are tested without a homeserver (`shareSet.test.ts` reads the sent events back through `buildAlbums`).
|
|
||||||
- `shareController.ts`: the panel's state machine (picked files, phase, the one-send-per-room guard, the encrypted refusal, failed files kept by position), apart from React so tests drive it without a DOM; `shareController.test.ts` and `ShareSet.test.tsx` (static render of each state, the flag) pin it. No DOM test environment exists in this repo, so a real tap on a real input is covered by the phone check, not by tests.
|
|
||||||
- `ShareSet.tsx`: phone layout. Idle, it is one text row, "Share files". Picking files (any number from the native picker; a second pick replaces the first) mounts a title field, the picked names on one dim line, and Send / Cancel as full-width rows; progress is "Sending 2 of 5". It is shown only on the album list (kept mounted but hidden while an album is open, so a running send keeps its state), one send at a time per room (tracked outside the component; leaving the page does not stop a send, it only loses its progress and result), and never in an encrypted zone (re-checked when Send is tapped) (the plaintext `info.sha256` the Mind verifies against is written in unencrypted rooms only).
|
|
||||||
- Content per file comes from `getMsgContent` in `features/room/msgContent.ts`, now also used by the composer, so a shared file and an attached file carry the same `info` including `sha256`.
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
|
||||||
|
|
@ -1,108 +0,0 @@
|
||||||
# chat.gamecult.org: shell compression and caching
|
|
||||||
|
|
||||||
Follow-up `ratatoskr:follow_up:chat-shell-compression`. Measured 2026-11-12 against the live
|
|
||||||
host, which serves release `ratatoskr-nemesis-9b9b495d` (the `index-CAataSJe.js` bundle; the offline table
|
|
||||||
below also uses the `f8115609` handoff build, `index-D0yL0Dgy.js`, and says which row is which). The host config is
|
|
||||||
`nginx/chat.gamecult.org.conf` in `gamecult-ops`, owned by Eureka; nothing here is applied
|
|
||||||
to the host.
|
|
||||||
|
|
||||||
## What the host serves now
|
|
||||||
|
|
||||||
The 2026-10-31 probe (no Content-Encoding, no Cache-Control on `/assets/`) is stale: the
|
|
||||||
`nginx.conf` has `gzip on`, the site file sets level 5, `gzip_vary` and `gzip_types` for js, css, json, svg and wasm, and carries an immutable
|
|
||||||
`Cache-Control` on `/assets/` and `/preview/assets/`. Probed with `curl -H 'Accept-Encoding: X'`.
|
|
||||||
|
|
||||||
| Resource | raw | gzip (served now) | brotli (served now) |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `/assets/index-CAataSJe.js` | 3,732,771 | 956,085 | none: `br` is ignored, identity returned |
|
|
||||||
| `/assets/index-BLdKqKqx.css` | 111,267 | 19,952 | none |
|
|
||||||
| `/assets/matrix_sdk_crypto_wasm_bg-DwIieW7K.wasm` | 5,573,870 | 1,878,147 | none |
|
|
||||||
| `/` (index.html, no-cache) | 3,066 | gzip | none |
|
|
||||||
|
|
||||||
Headers: hashed `/assets/` answer `Cache-Control: public, max-age=31536000, immutable`;
|
|
||||||
`/`, `index.html`, `sw.js`, `manifest.json`, `config.json` answer `no-cache`; compressed
|
|
||||||
responses carry `Vary: Accept-Encoding` and a weak ETag. `config.json` (424 B) is below
|
|
||||||
`gzip_min_length 1024` and goes uncompressed, which is correct.
|
|
||||||
|
|
||||||
So the original ask is done. What is left is brotli, which nginx 1.26.3 here cannot serve:
|
|
||||||
`/usr/sbin/nginx -V` shows `--with-http_gzip_static_module` and no brotli module.
|
|
||||||
|
|
||||||
## What brotli buys
|
|
||||||
|
|
||||||
Level-5 gzip is what the host does per request. Offline measurement on the same files
|
|
||||||
(node `zlib`, 2026-11-12):
|
|
||||||
|
|
||||||
| File | raw | gzip 5 (node) | gzip 9 | brotli 11 | brotli vs live |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| live main chunk `index-CAataSJe.js` | 3,732,771 | 957,199 | 945,424 | 725,167 | -24% |
|
|
||||||
| handoff main chunk `index-D0yL0Dgy.js` | 3,868,313 | 1,015,680 | 1,001,446 | 773,992 | -24% |
|
|
||||||
| crypto wasm | 5,573,870 | 1,868,282 | 1,851,108 | 1,343,595 | -28% |
|
|
||||||
| `ReactPrism-Dm2uRF4v.js` | 577,517 | 209,339 | 208,376 | 163,525 | -22% |
|
|
||||||
|
|
||||||
A cold load of the two big files falls from about 2.8 MB to about 2.1 MB on the wire; the
|
|
||||||
saving is ~0.7 MB once per hashed build per device while the HTTP cache keeps the file (immutable; the
|
|
||||||
service worker does no precaching, so eviction or a new device pays again). gzip 9 alone buys 1-2%, so
|
|
||||||
`gzip_static` without brotli is not worth a build step. Brotli is the whole prize, and it
|
|
||||||
needs one root step.
|
|
||||||
|
|
||||||
Cost: brotli q11 takes about 12 s for the wasm and 8 s for the main chunk on one core, so
|
|
||||||
the whole dist is about two minutes at build time (measured, 84 files) and zero CPU per request. Do not use q11
|
|
||||||
on the fly; that is why the plan is precompressed twins.
|
|
||||||
|
|
||||||
## Root list for Eureka (host)
|
|
||||||
|
|
||||||
1. `apt install libnginx-mod-http-brotli-filter libnginx-mod-http-brotli-static` (trixie/main, 1.0.0~rc-6,
|
|
||||||
depend on `nginx-abi-1.26.3-1`, which the installed nginx provides; apt may offer an nginx patch upgrade
|
|
||||||
alongside, say so before accepting). Do not enable the config yet.
|
|
||||||
2. Install a build with twins (`PRECOMPRESS=1`, below) the usual way
|
|
||||||
(`docs/gamecult/client-build-install.md`). Twins for files that are not there change nothing.
|
|
||||||
3. In the `chat.gamecult.org` `server` block, beside the existing gzip lines:
|
|
||||||
|
|
||||||
```nginx
|
|
||||||
brotli_static on; # serve <file>.br when the client sends br
|
|
||||||
gzip_static on; # serve <file>.gz; falls back to the on-the-fly gzip above
|
|
||||||
brotli on; # on-the-fly br for files with no twin (index.html, small files)
|
|
||||||
brotli_comp_level 5;
|
|
||||||
brotli_types text/css application/javascript text/javascript application/json application/manifest+json image/svg+xml application/wasm;
|
|
||||||
```
|
|
||||||
|
|
||||||
The `location ^~ /assets/` block and `/preview/`, `/element/` and `/mockups/` inherit these; they only add headers.
|
|
||||||
No other change: the `Cache-Control` and `Vary` lines stay as they are, and
|
|
||||||
`gzip_vary on` already makes the on-the-fly and static paths vary correctly.
|
|
||||||
4. `nginx -t && systemctl reload nginx`. Twins first, config second: with `brotli on` and no twins every
|
|
||||||
client gets on-the-fly q5 brotli of the 3.7 and 5.6 MB files, which costs CPU per request.
|
|
||||||
|
|
||||||
Rollback: remove the five lines and reload. The `.br`/`.gz` files can stay on disk, unused.
|
|
||||||
|
|
||||||
## Build side
|
|
||||||
|
|
||||||
`build-prod-dist.sh` (Void's, in `/opt/commons/handoff/ratatoskr-main-build/`) gains
|
|
||||||
`PRECOMPRESS=1`, which runs `precompress.cjs <dist>` before the manifest is written. It
|
|
||||||
writes `<file>.br` (q11) and `<file>.gz` (level 9, no mtime, deterministic for one node) next
|
|
||||||
to each js, css, wasm, json, svg, html file over 1 KiB, only when smaller than the original.
|
|
||||||
Default is off, so builds installed before the host change do not ship ~27 MB of twins that
|
|
||||||
nothing serves. The twins appear in `SHA256SUMS`; the determinism compare still applies.
|
|
||||||
Tested on `ReactPrism-Dm2uRF4v.js` (gzip roundtrip identical to the original); a full
|
|
||||||
`PRECOMPRESS=1` build has not been run because it waits on step 1.
|
|
||||||
|
|
||||||
The script is not in this repo; its placement is a standing open item of the build handoff.
|
|
||||||
|
|
||||||
## Before/after measurement
|
|
||||||
|
|
||||||
Run from any machine; `X` is the asset URL. Before = today's table above.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
for ae in identity gzip br; do
|
|
||||||
printf '%s ' "$ae"; curl -s -o /dev/null -H "Accept-Encoding: $ae" -w '%{size_download} %header{content-encoding}\n' "$X"
|
|
||||||
done
|
|
||||||
curl -sI -H 'Accept-Encoding: br' "$X" | grep -iE 'content-encoding|vary|etag|cache-control'
|
|
||||||
```
|
|
||||||
|
|
||||||
Pass: with `br` the main chunk is about 774 KB and the wasm about 1.34 MB with
|
|
||||||
`Content-Encoding: br`; with `gzip` the bytes are no larger than today; with `identity`
|
|
||||||
the file is the raw size; `Vary: Accept-Encoding` and the immutable `Cache-Control` are
|
|
||||||
still present on all three; `index.html`, `sw.js` and `config.json` still `no-cache`.
|
|
||||||
The service worker's registration and an existing session must still work (open chat on
|
|
||||||
the operator's phone, hard reload once, check the network panel's "transferred" column).
|
|
||||||
Check `/preview/assets/` the same way, but `/preview/` is served from `/srv/cinny/preview`, which this build
|
|
||||||
script does not produce: it gets on-the-fly brotli, not twins, until a preview build carries them.
|
|
||||||
|
|
@ -1,121 +0,0 @@
|
||||||
# Ratatoskr client: build, install, verify, roll back
|
|
||||||
|
|
||||||
Who does what. The steward (Void) builds and hands off; Void holds no root and does not deploy.
|
|
||||||
Eureka installs on the chat.gamecult.org host, which is Eureka's. This page is the shared
|
|
||||||
procedure. Nothing here is run by the build step against the live host.
|
|
||||||
|
|
||||||
## Facts about the host
|
|
||||||
|
|
||||||
- Web root: `/srv/cinny/`. `current` is a symlink to one directory under `releases/`; the web
|
|
||||||
server serves `current`. Releases are `releases/ratatoskr-<8hex>` (older ones are
|
|
||||||
`ratatoskr-nemesis-<8hex>`). Previous releases stay on disk; that is the rollback.
|
|
||||||
- The client is static. `config.json` sits in the release root beside `index.html` and is read
|
|
||||||
at load. Cinny's `Dockerfile` and `docker-nginx.conf` are upstream's container path, not the
|
|
||||||
chat.gamecult.org path.
|
|
||||||
- Check the pointer before anything else: `readlink /srv/cinny/current`.
|
|
||||||
|
|
||||||
## 1. Build (steward)
|
|
||||||
|
|
||||||
Two builds exist, and they treat `config.json` differently.
|
|
||||||
|
|
||||||
**By hand (the repo's config).** `vite build` copies the repo's `config.json` into `dist/`
|
|
||||||
(`vite.config.js`, static copy of `config.json`). Ratatoskr #103 made the repo file the intended
|
|
||||||
served config, and #106 set `backgroundScene.style` to `ridge`; the served file has not caught up
|
|
||||||
(see Open). Do not hand-edit `dist/config.json`.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git clone https://eureka.gamecult.org/forge/GameCult/Ratatoskr.git && cd Ratatoskr
|
|
||||||
git checkout <full-40-hex-sha> # build a named commit, not a branch tip
|
|
||||||
npm ci --ignore-scripts # .npmrc sets legacy-peer-deps; skips husky's prepare hook
|
|
||||||
node scripts/sync-poses.mjs # pose art is not committed; this also rewrites the tracked
|
|
||||||
# src/app/components/message/pose/catalogue.json
|
|
||||||
NODE_OPTIONS=--max_old_space_size=6144 npm run build # writes dist/
|
|
||||||
```
|
|
||||||
|
|
||||||
Skip `sync-poses` and the build ships with no poses. Do not use `npm run build:preview` for
|
|
||||||
production: it rewrites `dist/config.json` for preview (hash router, chosen tier and style).
|
|
||||||
|
|
||||||
**The handoff build (what the host installs).**
|
|
||||||
`/opt/commons/handoff/ratatoskr-main-build/build-prod-dist.sh <full-sha>` (see its `README.md`)
|
|
||||||
writes `/opt/commons/handoff/ratatoskr-main-<full-sha>/{dist,SHA256SUMS,BUILD-INFO,build.log}`,
|
|
||||||
locks the files read-only and refuses to overwrite. The dist carries the repo's `config.json` at
|
|
||||||
that sha (BUILD-INFO `config_json_source`, `config_json_sha256`); there is no separate pinned copy.
|
|
||||||
Its live check always runs: it fetches the served config and compares it byte for byte with the
|
|
||||||
repo's. A difference prints the diff and stops the build. `ALLOW_CONFIG_DRIFT=1` builds anyway and
|
|
||||||
BUILD-INFO records `config_json_live_check: DIFFERS ...` and `config_json_live_sha256`, so the
|
|
||||||
handoff states which config differs and the installer decides. From Yggdrasil the public name does
|
|
||||||
not resolve: pass `LIVE_CURL_ARGS="--resolve chat.gamecult.org:443:127.0.0.1"`.
|
|
||||||
The folder, with the three pins (`SHA256SUMS` sha256, `BUILD-INFO` sha256, lockfile sha256) and
|
|
||||||
the node and npm versions, is what the install request names.
|
|
||||||
Every build stamps its commit into `index.html` (`scripts/buildSha.js`): `<meta name="build-sha" content="<40-hex>">`,
|
|
||||||
with `-dirty` appended when the tree had uncommitted changes and `unknown` when there was no git. Where git governs the tree it decides: an explicit `BUILD_SHA` naming a different commit than HEAD stamps `<sha>-mismatch`, a git failure stamps `unknown`. A full-sha `BUILD_SHA` stands only for a tree git does not govern (a source archive) or cannot read (unusable git, for example a dubious-ownership refusal). The handoff
|
|
||||||
build refuses a dist whose stamp differs from the sha it was asked for, and prints the stamp in its block.
|
|
||||||
To read it from anywhere: `curl -s https://chat.gamecult.org/ | grep -o 'build-sha" content="[^"]*'`.
|
|
||||||
|
|
||||||
Before handing off:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd <folder>/dist && sha256sum -c ../SHA256SUMS # 0 failures
|
|
||||||
grep -rl 'demo.invalid\|demo-preview-token' <folder>/dist # must print nothing
|
|
||||||
# from Yggdrasil the public name may not resolve; --resolve points it at the local server
|
|
||||||
diff <(curl -fsS --resolve chat.gamecult.org:443:127.0.0.1 https://chat.gamecult.org/config.json) <folder>/dist/config.json
|
|
||||||
```
|
|
||||||
|
|
||||||
A config difference is a finding to report, not something to paper over: either live drifted or
|
|
||||||
the repo is wrong, and a ruling (Meta, for `backgroundScene.style`) says which ships.
|
|
||||||
|
|
||||||
## 2. Install (host owner, root, by hand)
|
|
||||||
|
|
||||||
Note the rollback target first: `readlink /srv/cinny/current`.
|
|
||||||
|
|
||||||
Unpack beside the live release; do not touch the live directory. The target must not exist, or
|
|
||||||
`cp -r` nests `dist` inside it.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
R=ratatoskr-<8hex>
|
|
||||||
H=/opt/commons/handoff/ratatoskr-main-<full-sha>
|
|
||||||
test ! -e /srv/cinny/releases/$R
|
|
||||||
cp -r $H/dist /srv/cinny/releases/$R
|
|
||||||
cmp /srv/cinny/current/config.json /srv/cinny/releases/$R/config.json
|
|
||||||
(cd /srv/cinny/releases/$R && sha256sum -c $H/SHA256SUMS)
|
|
||||||
```
|
|
||||||
|
|
||||||
`cmp` is a drift check, not a pass check. Live has no trailing newline; the repo's file has one,
|
|
||||||
so even equal content differs. Read the difference (`diff` the two) and settle which config
|
|
||||||
ships. To keep the host's config byte-for-byte, as past installs did,
|
|
||||||
`cp /srv/cinny/current/config.json /srv/cinny/releases/$R/config.json`; `sha256sum -c` then flags
|
|
||||||
`config.json` only (read that one line; `--ignore-missing` does not hide a mismatch).
|
|
||||||
|
|
||||||
Switch (a single symlink swap; `ln -sfn` unlinks then links, so there is a moment with no link):
|
|
||||||
|
|
||||||
```sh
|
|
||||||
ln -sfn releases/$R /srv/cinny/current
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. Verify
|
|
||||||
|
|
||||||
- `readlink /srv/cinny/current` names `releases/$R`.
|
|
||||||
- `curl -s https://chat.gamecult.org/config.json | sha256sum` equals `sha256sum /srv/cinny/releases/$R/config.json`.
|
|
||||||
- Load https://chat.gamecult.org/ and hard-refresh (the service worker, `sw.js`, can hold the
|
|
||||||
previous assets). Sign-in card shows, the background scene draws, the browser console has no
|
|
||||||
page errors, a poses/asset request returns 200 and not index.html.
|
|
||||||
|
|
||||||
## 4. Roll back
|
|
||||||
|
|
||||||
Point `current` at the release noted before the switch. Live at the time of writing is
|
|
||||||
`ratatoskr-nemesis-9b9b495d`:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
ln -sfn releases/ratatoskr-nemesis-9b9b495d /srv/cinny/current
|
|
||||||
```
|
|
||||||
|
|
||||||
Hard-refresh to clear `sw.js`. Do not delete the failed release; leave it for diagnosis.
|
|
||||||
|
|
||||||
## Open
|
|
||||||
|
|
||||||
- The served config (checked 2026-11-12) is not the repo's: it says `backgroundScene.style`
|
|
||||||
`terrain` where the repo says `ridge` (#106, Meta 2026-11-11), lacks the repo's `dings`,
|
|
||||||
`fileZoneShare` and `registration` blocks (all off in the repo), and has no trailing newline.
|
|
||||||
The new keys default off or inert when absent, so only the style is a decision. Meta says which
|
|
||||||
style ships. Until the served file is replaced, every handoff build stops at the live check
|
|
||||||
unless `ALLOW_CONFIG_DRIFT=1` is set (section 1).
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Ratatoskr: target and prose map
|
# Ratatoskr: target and prose map
|
||||||
|
|
||||||
Ratatoskr is GameCult's Matrix chat client: this fork of Cinny, forge
|
Ratatoskr is GameCult's Matrix chat client: this fork of Cinny, forge
|
||||||
`GameCult/Ratatoskr` (renamed from `GameCult/cinny`; the forge redirects the old name).
|
`GameCult/cinny` (to be renamed `GameCult/Ratatoskr`; see the cut sequence).
|
||||||
Void stewards it. Campaign `ratatoskr` lives in the eureka mind for now, beside
|
Void stewards it. Campaign `ratatoskr` lives in the eureka mind for now, beside
|
||||||
the rulings it rests on. Under `eureka-body:ruling:ruling-home-steward-mind` it
|
the rulings it rests on. Under `eureka-body:ruling:ruling-home-steward-mind` it
|
||||||
is handed to Void's mind once, by procedure, as soon as that hand-off procedure
|
is handed to Void's mind once, by procedure, as soon as that hand-off procedure
|
||||||
|
|
@ -346,6 +346,7 @@ Each later cut is one line, with what it waits on.
|
||||||
attention document, scoped to room members; waits on Huginn publishing it.
|
attention document, scoped to room members; waits on Huginn publishing it.
|
||||||
- `rename-ratatoskr`: package name, app name and branding, manifest and
|
- `rename-ratatoskr`: package name, app name and branding, manifest and
|
||||||
titles, the `/preview/` path and its deploy, and every in-repo `cinny`
|
titles, the `/preview/` path and its deploy, and every in-repo `cinny`
|
||||||
reference that is ours (not upstream engine paths). The forge rename of
|
reference that is ours (not upstream engine paths); waits on Void's forge
|
||||||
`GameCult/cinny` to `GameCult/Ratatoskr` is done
|
rename of `GameCult/cinny` to `GameCult/Ratatoskr`
|
||||||
(`eureka-body:ruling:chat-client-named-ratatoskr`).
|
(`eureka-body:ruling:chat-client-named-ratatoskr`), and the campaign's repo
|
||||||
|
list moves with it.
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,6 @@ esbuild
|
||||||
plugins: [rawPlugin],
|
plugins: [rawPlugin],
|
||||||
format: 'iife',
|
format: 'iife',
|
||||||
target: 'es2020',
|
target: 'es2020',
|
||||||
define: { 'import.meta.env.BASE_URL': '"/"' },
|
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
if (ridge) {
|
if (ridge) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Demo pose thumbnails
|
# Demo pose thumbnails
|
||||||
|
|
||||||
These 36 PNGs (eureka, void and nibu, 12 each; about 1.8 MB, up to 480 px wide, palette-quantised) are downscaled copies of GameCult's own
|
These 24 PNGs (about 200 KB, 160 px wide, palette-quantised) are downscaled copies of GameCult's own
|
||||||
Persona art from `/opt/commons/cast-art/poses`. That directory carries no provenance or licence note:
|
Persona art from `/opt/commons/cast-art/poses`. That directory carries no provenance or licence note:
|
||||||
the image model that made the art and the terms it was made under are not recorded.
|
the image model that made the art and the terms it was made under are not recorded.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "Ratatoskr",
|
"name": "Ratatoskr",
|
||||||
"short_name": "Ratatoskr",
|
"short_name": "Ratatoskr",
|
||||||
"id": "/",
|
|
||||||
"description": "GameCult's Matrix client",
|
"description": "GameCult's Matrix client",
|
||||||
"dir": "auto",
|
"dir": "auto",
|
||||||
"lang": "en-US",
|
"lang": "en-US",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
|
"orientation": "portrait",
|
||||||
"start_url": "./",
|
"start_url": "./",
|
||||||
"scope": "./",
|
|
||||||
"background_color": "#07111a",
|
"background_color": "#07111a",
|
||||||
"theme_color": "#07111a",
|
"theme_color": "#07111a",
|
||||||
"icons": [
|
"icons": [
|
||||||
|
|
@ -55,12 +54,6 @@
|
||||||
"src": "./public/android/android-chrome-512x512.png",
|
"src": "./public/android/android-chrome-512x512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "./public/android/android-chrome-maskable-512x512.png",
|
|
||||||
"sizes": "512x512",
|
|
||||||
"type": "image/png",
|
|
||||||
"purpose": "maskable"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 20 KiB |
|
|
@ -1,123 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""Bake the 360 degree horizon seen from the ridge viewpoint (src/app/features/scene/ridge/vienna.ts: VIENNA) into
|
|
||||||
src/app/features/scene/ridge/viennaHorizon.json: per azimuth, the highest elevation angle the terrain subtends, the distance and
|
|
||||||
height of the point that sets it, and the surface slope there (the normal). The source is Copernicus DEM GLO-30, a DSM (it
|
|
||||||
includes buildings and trees, which is what a horizon is made of). Source, licence and attribution go beside the bake, in its
|
|
||||||
`source` block, so the file cannot travel without them.
|
|
||||||
|
|
||||||
python3 scripts/bake-horizon.py <dir with the nine Copernicus_DSM_COG_10_N{47..49}_00_E{015..017}_00_DEM.tif> [out.json]
|
|
||||||
|
|
||||||
Needs numpy, tifffile, imagecodecs (pip, in a venv: this is a build-time tool, the bake is committed).
|
|
||||||
"""
|
|
||||||
import json
|
|
||||||
import math
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import tifffile
|
|
||||||
|
|
||||||
LAT, LON = 48.1929826, 16.3534237 # the server location Meta gave, 2026-11-11; keep equal to VIENNA in vienna.ts
|
|
||||||
STEP_DEG = 0.5
|
|
||||||
RADIUS_KM = 60.0
|
|
||||||
EARTH_R = 6_371_000.0
|
|
||||||
REFRACTION_K = 0.13 # standard terrestrial refraction: effective radius R / (1 - k)
|
|
||||||
EYE_ABOVE_GROUND = 0.0 # the viewpoint is the DSM surface at the point (a roof, in a city); the page lifts it with VIENNA.altitudeKm
|
|
||||||
MIN_KM = 1.0 # the DSM at 30 m cannot resolve neighbouring buildings as a horizon; nearer than this is not ridge
|
|
||||||
|
|
||||||
|
|
||||||
class Mosaic:
|
|
||||||
def __init__(self, folder: Path):
|
|
||||||
self.tiles = {}
|
|
||||||
for p in sorted(folder.glob("Copernicus_DSM_COG_10_N*_E*_DEM.tif")):
|
|
||||||
lat0 = int(p.name.split("_N")[1][:2])
|
|
||||||
lon0 = int(p.name.split("_E")[1][:3])
|
|
||||||
with tifffile.TiffFile(p) as t:
|
|
||||||
self.tiles[(lat0, lon0)] = t.pages[0].asarray()
|
|
||||||
|
|
||||||
def height(self, lat: float, lon: float) -> float:
|
|
||||||
key = (math.floor(lat), math.floor(lon))
|
|
||||||
a = self.tiles.get(key)
|
|
||||||
if a is None:
|
|
||||||
return float("nan")
|
|
||||||
n = a.shape[0]
|
|
||||||
# Tile origin is the north-west corner; pixels are 1/n degree, pixel-is-area (centre at +0.5).
|
|
||||||
y = (key[0] + 1 - lat) * n - 0.5
|
|
||||||
x = (lon - key[1]) * n - 0.5
|
|
||||||
y0, x0 = int(math.floor(y)), int(math.floor(x))
|
|
||||||
y0 = min(max(y0, 0), n - 2)
|
|
||||||
x0 = min(max(x0, 0), n - 2)
|
|
||||||
fy, fx = min(max(y - y0, 0), 1), min(max(x - x0, 0), 1)
|
|
||||||
top = a[y0, x0] * (1 - fx) + a[y0, x0 + 1] * fx
|
|
||||||
bot = a[y0 + 1, x0] * (1 - fx) + a[y0 + 1, x0 + 1] * fx
|
|
||||||
return float(top * (1 - fy) + bot * fy)
|
|
||||||
|
|
||||||
|
|
||||||
def offset(lat: float, lon: float, az_deg: float, d_m: float):
|
|
||||||
az = math.radians(az_deg)
|
|
||||||
dlat = d_m * math.cos(az) / 111_320.0
|
|
||||||
dlon = d_m * math.sin(az) / (111_320.0 * math.cos(math.radians(lat)))
|
|
||||||
return lat + dlat, lon + dlon
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
folder = Path(sys.argv[1])
|
|
||||||
out = Path(sys.argv[2]) if len(sys.argv) > 2 else Path("src/app/features/scene/ridge/viennaHorizon.json")
|
|
||||||
m = Mosaic(folder)
|
|
||||||
h0 = m.height(LAT, LON) + EYE_ABOVE_GROUND
|
|
||||||
az_count = int(360 / STEP_DEG)
|
|
||||||
elev, dist, peak, east, north = [], [], [], [], []
|
|
||||||
for i in range(az_count):
|
|
||||||
az = i * STEP_DEG
|
|
||||||
best, best_d, best_h, best_pt = -90.0, 0.0, h0, (LAT, LON)
|
|
||||||
d = MIN_KM * 1000
|
|
||||||
while d <= RADIUS_KM * 1000:
|
|
||||||
lat, lon = offset(LAT, LON, az, d)
|
|
||||||
h = m.height(lat, lon)
|
|
||||||
if not math.isnan(h):
|
|
||||||
drop = d * d / (2 * EARTH_R) * (1 - REFRACTION_K)
|
|
||||||
ang = math.degrees(math.atan2(h - h0 - drop, d))
|
|
||||||
if ang > best:
|
|
||||||
best, best_d, best_h, best_pt = ang, d, h, (lat, lon)
|
|
||||||
d += 30.0 if d < 5000 else 60.0 if d < 20000 else 120.0
|
|
||||||
# Surface slope at the peak (rise per metre, east and north), central difference over 60 m.
|
|
||||||
la, lo = best_pt
|
|
||||||
dl, dn = 60.0 / 111_320.0, 60.0 / (111_320.0 * math.cos(math.radians(la)))
|
|
||||||
ge = (m.height(la, lo + dn) - m.height(la, lo - dn)) / 120.0
|
|
||||||
gn = (m.height(la + dl, lo) - m.height(la - dl, lo)) / 120.0
|
|
||||||
elev.append(round(best, 3))
|
|
||||||
dist.append(round(best_d / 1000, 2))
|
|
||||||
peak.append(round(best_h, 1))
|
|
||||||
east.append(round(ge, 3))
|
|
||||||
north.append(round(gn, 3))
|
|
||||||
doc = {
|
|
||||||
"viewpoint": {"lat": LAT, "lon": LON, "groundM": round(h0, 1)},
|
|
||||||
"stepDeg": STEP_DEG,
|
|
||||||
"radiusKm": RADIUS_KM,
|
|
||||||
"minKm": MIN_KM,
|
|
||||||
"curvature": f"drop d^2/2R*(1-{REFRACTION_K}), R={int(EARTH_R)} m",
|
|
||||||
"source": {
|
|
||||||
"dataset": "Copernicus DEM GLO-30 (COP-DEM_GLO-30-DGED), a DSM, EGM2008 heights, 1 arc-second grid",
|
|
||||||
"tiles": sorted(f"N{a:02d}_00_E{b:03d}_00" for a, b in m.tiles),
|
|
||||||
"tileBytesEach": "about 42-44 MB (Cloud Optimized GeoTIFF); nine tiles, about 380 MB, read once, not committed",
|
|
||||||
"url": "https://copernicus-dem-30m.s3.amazonaws.com/ (AWS Open Data, eu-central-1, no account)",
|
|
||||||
"licence": "Copernicus DEM licence: free of charge for any use incl. commercial, attribution required, no warranty "
|
|
||||||
"(https://spacedata.copernicus.eu/collections/copernicus-digital-elevation-model, Licencing)",
|
|
||||||
"attribution": "Contains modified Copernicus data: DEM © DLR e.V. 2010-2014 and © Airbus Defence and Space GmbH 2014-2018 "
|
|
||||||
"provided under COPERNICUS by the European Union and ESA; all rights reserved.",
|
|
||||||
"bakedBy": "scripts/bake-horizon.py",
|
|
||||||
},
|
|
||||||
"elevationDeg": elev,
|
|
||||||
"distanceKm": dist,
|
|
||||||
"peakHeightM": peak,
|
|
||||||
"slopeEast": east,
|
|
||||||
"slopeNorth": north,
|
|
||||||
}
|
|
||||||
out.write_text(json.dumps(doc, separators=(",", ":")) + "\n")
|
|
||||||
top = int(np.argmax(elev))
|
|
||||||
print(f"ground {h0:.1f} m; {az_count} azimuths; highest {elev[top]:.2f} deg at az {top * STEP_DEG}, {dist[top]} km, {peak[top]} m")
|
|
||||||
print(f"wrote {out} ({out.stat().st_size} bytes)")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
// The commit a bundle was built from, stamped into its index.html as <meta name="build-sha">, so a built
|
|
||||||
// folder says what it is without a build log: `grep -o 'build-sha" content="[^"]*' dist/index.html`.
|
|
||||||
// Where git governs the tree (REPO_DIR is a repository root) git decides: HEAD, suffixed -dirty when the tree has
|
|
||||||
// uncommitted changes, or `<BUILD_SHA>-mismatch` when an explicit BUILD_SHA names a different commit, so a stamp
|
|
||||||
// never vouches for code it does not hold; a git failure there is 'unknown'. Where git does not govern (a source
|
|
||||||
// archive), a full-sha BUILD_SHA stands, else 'unknown'.
|
|
||||||
import { execFileSync } from 'child_process';
|
|
||||||
import { dirname, resolve } from 'path';
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
|
|
||||||
const REPO_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
||||||
|
|
||||||
// Vite writes vite.config.js.timestamp-*.mjs next to the config while it loads it; that file is the
|
|
||||||
// build's own, not source, so an UNTRACKED one must not mark the tree dirty. A tracked file of that name,
|
|
||||||
// or any other change, still does. Filtering status lines (not a pathspec) is what keeps it untracked-only.
|
|
||||||
const VITE_TEMP = /^\?\? (?:.*\/)?vite\.config\.[a-z]+\.timestamp-\d+-[0-9a-f]+\.mjs$/;
|
|
||||||
|
|
||||||
export const gitIn =
|
|
||||||
(cwd) =>
|
|
||||||
(...args) =>
|
|
||||||
execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
||||||
|
|
||||||
const FULL_SHA = /^[0-9a-f]{40}$/;
|
|
||||||
|
|
||||||
export const readBuildSha = (env = process.env, run = gitIn(REPO_DIR)) => {
|
|
||||||
const fromEnv = (env.BUILD_SHA ?? '').trim();
|
|
||||||
const explicit = FULL_SHA.test(fromEnv) ? fromEnv : null;
|
|
||||||
// Git governs this tree only when REPO_DIR is its root: a source archive unpacked inside some other
|
|
||||||
// repository has no git of its own, and that repository's HEAD says nothing about it.
|
|
||||||
let governed;
|
|
||||||
try {
|
|
||||||
governed = run('rev-parse', '--show-prefix').trim() === '';
|
|
||||||
} catch {
|
|
||||||
governed = false;
|
|
||||||
}
|
|
||||||
if (!governed) return explicit ?? 'unknown';
|
|
||||||
// Git governs and answers: from here a failure is 'unknown', never the bare explicit sha.
|
|
||||||
try {
|
|
||||||
const head = run('rev-parse', 'HEAD').trim();
|
|
||||||
if (!FULL_SHA.test(head)) return 'unknown';
|
|
||||||
if (explicit && explicit !== head) return `${explicit}-mismatch`;
|
|
||||||
const dirty = run('status', '--porcelain', '-uall', '--', '.')
|
|
||||||
.split('\n')
|
|
||||||
.some((l) => l.trim() !== '' && !VITE_TEMP.test(l));
|
|
||||||
return dirty ? `${head}-dirty` : head;
|
|
||||||
} catch {
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const buildShaPlugin = (sha = readBuildSha()) => ({
|
|
||||||
name: 'ratatoskr-build-sha',
|
|
||||||
transformIndexHtml: () => [
|
|
||||||
{ tag: 'meta', attrs: { name: 'build-sha', content: sha }, injectTo: 'head-prepend' },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
@ -1,170 +0,0 @@
|
||||||
import { execFileSync } from 'child_process';
|
|
||||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs';
|
|
||||||
import { tmpdir } from 'os';
|
|
||||||
import { join } from 'path';
|
|
||||||
import { afterEach, describe, expect, it } from 'vitest';
|
|
||||||
import { buildShaPlugin, gitIn, readBuildSha } from './buildSha';
|
|
||||||
|
|
||||||
const SHA = 'b6b1d522f1c0a9d3e2b4c5d6e7f8091a2b3c4d5e';
|
|
||||||
const gitAt =
|
|
||||||
(head, status = '') =>
|
|
||||||
(cmd, ...args) =>
|
|
||||||
cmd === 'rev-parse' ? (args[0] === '--show-prefix' ? '\n' : `${head}\n`) : status;
|
|
||||||
|
|
||||||
describe('readBuildSha', () => {
|
|
||||||
it('reads the checkout it is built from', () => {
|
|
||||||
expect(readBuildSha({}, gitAt(SHA))).toBe(SHA);
|
|
||||||
});
|
|
||||||
it('marks a tree with uncommitted changes, so a stamp never vouches for code it does not hold', () => {
|
|
||||||
expect(readBuildSha({}, gitAt(SHA, ' M src/x.ts\n'))).toBe(`${SHA}-dirty`);
|
|
||||||
});
|
|
||||||
it('lets BUILD_SHA stand in where there is no git, and ignores a value that is not a full sha', () => {
|
|
||||||
const noGit = () => {
|
|
||||||
throw new Error('not a git repository');
|
|
||||||
};
|
|
||||||
expect(readBuildSha({ BUILD_SHA: SHA }, noGit)).toBe(SHA);
|
|
||||||
expect(readBuildSha({ BUILD_SHA: SHA }, gitAt(SHA))).toBe(SHA);
|
|
||||||
// a different HEAD is a mismatch, not a clean stamp of the explicit sha
|
|
||||||
expect(readBuildSha({ BUILD_SHA: SHA }, gitAt('c'.repeat(40)))).toBe(`${SHA}-mismatch`);
|
|
||||||
// a value that is not a lowercase full sha is ignored
|
|
||||||
['b6b1d52', SHA.toUpperCase(), `${SHA}0`].forEach((v) =>
|
|
||||||
expect(readBuildSha({ BUILD_SHA: v }, noGit)).toBe('unknown')
|
|
||||||
);
|
|
||||||
// git that answers but cannot say what the tree holds fails closed
|
|
||||||
const brokenStatus = (cmd, ...args) => {
|
|
||||||
if (cmd === 'status') throw new Error('index file smaller than expected');
|
|
||||||
return args[0] === '--show-prefix' ? '\n' : `${SHA}\n`;
|
|
||||||
};
|
|
||||||
expect(readBuildSha({ BUILD_SHA: SHA }, brokenStatus)).toBe('unknown');
|
|
||||||
expect(readBuildSha({}, brokenStatus)).toBe('unknown');
|
|
||||||
expect(readBuildSha({}, gitAt('nope'))).toBe('unknown');
|
|
||||||
// inside another repository's subfolder git does not govern: the explicit sha stands
|
|
||||||
const nested = (cmd, ...args) => (args[0] === '--show-prefix' ? 'vendor/ratatoskr/\n' : SHA);
|
|
||||||
expect(readBuildSha({ BUILD_SHA: SHA }, nested)).toBe(SHA);
|
|
||||||
expect(readBuildSha({}, nested)).toBe('unknown');
|
|
||||||
expect(readBuildSha({ BUILD_SHA: 'main' }, noGit)).toBe('unknown');
|
|
||||||
});
|
|
||||||
it("says 'unknown' rather than failing the build when git is absent", () => {
|
|
||||||
expect(
|
|
||||||
readBuildSha({}, () => {
|
|
||||||
throw new Error('spawn git ENOENT');
|
|
||||||
})
|
|
||||||
).toBe('unknown');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('readBuildSha against a real repository', () => {
|
|
||||||
const made = [];
|
|
||||||
afterEach(() => {
|
|
||||||
made.splice(0).forEach((d) => rmSync(d, { recursive: true, force: true }));
|
|
||||||
});
|
|
||||||
const repo = () => {
|
|
||||||
const dir = mkdtempSync(join(tmpdir(), 'buildsha-'));
|
|
||||||
made.push(dir);
|
|
||||||
const g = (...a) => execFileSync('git', a, { cwd: dir, stdio: 'ignore' });
|
|
||||||
g('init', '-q');
|
|
||||||
mkdirSync(join(dir, 'src'));
|
|
||||||
writeFileSync(join(dir, 'src', 'a.ts'), 'export {};\n');
|
|
||||||
g('add', '.');
|
|
||||||
g('-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'x');
|
|
||||||
return dir;
|
|
||||||
};
|
|
||||||
it("stays clean with vite's temp config file present, and is dirty with a stray source file", () => {
|
|
||||||
const dir = repo();
|
|
||||||
const run = gitIn(dir);
|
|
||||||
writeFileSync(join(dir, 'vite.config.js.timestamp-1700000000-abc123.mjs'), '');
|
|
||||||
expect(readBuildSha({}, run)).toMatch(/^[0-9a-f]{40}$/);
|
|
||||||
writeFileSync(join(dir, 'src', 'zz-stray.ts'), '');
|
|
||||||
expect(readBuildSha({}, run)).toMatch(/^[0-9a-f]{40}-dirty$/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('readBuildSha dirty rules against a real repository', () => {
|
|
||||||
const made2 = [];
|
|
||||||
afterEach(() => {
|
|
||||||
made2.splice(0).forEach((d) => rmSync(d, { recursive: true, force: true }));
|
|
||||||
});
|
|
||||||
const TEMP = 'vite.config.js.timestamp-1700000000-abc123.mjs';
|
|
||||||
const repo2 = (tracked = []) => {
|
|
||||||
const dir = mkdtempSync(join(tmpdir(), 'buildsha-'));
|
|
||||||
made2.push(dir);
|
|
||||||
const g = (...a) => execFileSync('git', a, { cwd: dir, stdio: 'ignore' });
|
|
||||||
g('init', '-q');
|
|
||||||
mkdirSync(join(dir, 'src'));
|
|
||||||
writeFileSync(join(dir, 'src', 'a.ts'), 'export {};\n');
|
|
||||||
tracked.forEach((f) => writeFileSync(join(dir, f), 'x\n'));
|
|
||||||
g('add', '.');
|
|
||||||
g('-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'x');
|
|
||||||
return {
|
|
||||||
dir,
|
|
||||||
head: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: dir, encoding: 'utf8' }).trim(),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
it('does not let an explicit BUILD_SHA mask a dirty tree', () => {
|
|
||||||
const { dir, head } = repo2();
|
|
||||||
const run = gitIn(dir);
|
|
||||||
expect(readBuildSha({ BUILD_SHA: head }, run)).toBe(head);
|
|
||||||
writeFileSync(join(dir, 'src', 'zz-stray.ts'), '');
|
|
||||||
expect(readBuildSha({ BUILD_SHA: head }, run)).toBe(`${head}-dirty`);
|
|
||||||
});
|
|
||||||
it('lets git speak only for a tree whose root it is: an archive unpacked inside another repository keeps its explicit sha', () => {
|
|
||||||
const { dir } = repo2();
|
|
||||||
const inner = join(dir, 'vendor', 'ratatoskr');
|
|
||||||
mkdirSync(inner, { recursive: true });
|
|
||||||
writeFileSync(join(inner, 'x.ts'), '');
|
|
||||||
expect(readBuildSha({ BUILD_SHA: SHA }, gitIn(inner))).toBe(SHA);
|
|
||||||
expect(readBuildSha({}, gitIn(inner))).toBe('unknown');
|
|
||||||
});
|
|
||||||
it("notices an untracked .mjs that is not vite's temp file", () => {
|
|
||||||
const { dir, head } = repo2();
|
|
||||||
writeFileSync(join(dir, 'foo.mjs'), '');
|
|
||||||
expect(readBuildSha({}, gitIn(dir))).toBe(`${head}-dirty`);
|
|
||||||
rmSync(join(dir, 'foo.mjs'));
|
|
||||||
// only vite's exact shape is its own: vite.config.<ext>.timestamp-<digits>-<hex>.mjs
|
|
||||||
[
|
|
||||||
'vite.config.js.timestamp-1-ab.mjs.bak',
|
|
||||||
'vite.config.js.timestamp-x-ab.mjs',
|
|
||||||
'vite.config.js.timestamp-1-zz.mjs',
|
|
||||||
'vite.config.a.b.timestamp-1-ab.mjs',
|
|
||||||
'vite.config.js.timestamp-1-ab-c.mjs',
|
|
||||||
].forEach((name) => {
|
|
||||||
writeFileSync(join(dir, name), '');
|
|
||||||
expect(readBuildSha({}, gitIn(dir)), name).toBe(`${head}-dirty`);
|
|
||||||
rmSync(join(dir, name));
|
|
||||||
});
|
|
||||||
expect(readBuildSha({}, gitIn(dir))).toBe(head);
|
|
||||||
});
|
|
||||||
it('treats a stray source file as dirty even beside the vite temp file, in the root or a subfolder', () => {
|
|
||||||
const { dir, head } = repo2();
|
|
||||||
const run = gitIn(dir);
|
|
||||||
writeFileSync(join(dir, TEMP), '');
|
|
||||||
writeFileSync(join(dir, 'src', TEMP), '');
|
|
||||||
expect(readBuildSha({}, run)).toBe(head);
|
|
||||||
writeFileSync(join(dir, 'src', 'zz-stray.ts'), '');
|
|
||||||
expect(readBuildSha({}, run)).toBe(`${head}-dirty`);
|
|
||||||
});
|
|
||||||
it('marks a tracked file of the temp name dirty once it changes', () => {
|
|
||||||
const { dir, head } = repo2([TEMP]);
|
|
||||||
const run = gitIn(dir);
|
|
||||||
expect(readBuildSha({}, run)).toBe(head);
|
|
||||||
writeFileSync(join(dir, TEMP), 'changed\n');
|
|
||||||
expect(readBuildSha({}, run)).toBe(`${head}-dirty`);
|
|
||||||
});
|
|
||||||
it('marks an untracked file in a new folder dirty (status lists files, not the folder)', () => {
|
|
||||||
const { dir, head } = repo2();
|
|
||||||
mkdirSync(join(dir, 'new'));
|
|
||||||
writeFileSync(join(dir, 'new', TEMP), '');
|
|
||||||
expect(readBuildSha({}, gitIn(dir))).toBe(head);
|
|
||||||
writeFileSync(join(dir, 'new', 'b.ts'), '');
|
|
||||||
expect(readBuildSha({}, gitIn(dir))).toBe(`${head}-dirty`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('buildShaPlugin', () => {
|
|
||||||
it("hands vite a meta tag for the page's head carrying the sha", () => {
|
|
||||||
const plugin = buildShaPlugin(SHA);
|
|
||||||
expect(plugin.transformIndexHtml()).toEqual([
|
|
||||||
{ tag: 'meta', attrs: { name: 'build-sha', content: SHA }, injectTo: 'head-prepend' },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
// Regenerates preview-demo/poses from the cast-art poses: PNG thumbnails sized for the
|
// Regenerates preview-demo/poses from the cast-art poses: PNG thumbnails sized for the
|
||||||
// portrait's displayed width x device pixel ratio: the bubble portrait is 190 CSS px on desktop and
|
// portrait's displayed width x a phone's device pixel ratio (not for the 124px desktop box).
|
||||||
// min(38vw, 160px) on a phone (layout.css.ts), the desk booth 168 / 88 px (DeskView.css.ts).
|
|
||||||
// Dependency-free: decodes 8-bit RGBA PNGs, area-averages in premultiplied alpha, writes a
|
// Dependency-free: decodes 8-bit RGBA PNGs, area-averages in premultiplied alpha, writes a
|
||||||
// 256-colour palette PNG with a tRNS alpha table. Usage: node scripts/make-pose-thumbs.mjs [src]
|
// 256-colour palette PNG with a tRNS alpha table. Usage: node scripts/make-pose-thumbs.mjs [src]
|
||||||
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||||
|
|
@ -8,7 +7,7 @@ import { dirname, join } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { inflateSync, deflateSync, crc32 } from 'node:zlib';
|
import { inflateSync, deflateSync, crc32 } from 'node:zlib';
|
||||||
|
|
||||||
const WIDTH = 480; // about the 190px desktop portrait x DPR 2.5; art is never upscaled
|
const WIDTH = 480; // phone portrait: min(50vw, 210px) x DPR 2.5; art is never upscaled
|
||||||
const src = process.argv[2] ?? '/opt/commons/cast-art/poses';
|
const src = process.argv[2] ?? '/opt/commons/cast-art/poses';
|
||||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
const outDir = join(root, 'preview-demo/poses');
|
const outDir = join(root, 'preview-demo/poses');
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
// Preview builds: set the background scene tier in dist/config.json (default 'low'); config.json
|
// Preview builds: set the background scene tier in dist/config.json (default 'low'); config.json
|
||||||
// in the repo is the live chat.gamecult.org config (style ridge), and previews pin the style too, ridge unless
|
// in the repo stays 'off'. Previews are static directories with no SPA fallback, so they also switch
|
||||||
// given. Previews are static directories with no SPA fallback, so they also switch
|
|
||||||
// to the hash router: a reload on /login/matrix.org would otherwise be an nginx 404. With --demo it
|
// to the hash router: a reload on /login/matrix.org would otherwise be an nginx 404. With --demo it
|
||||||
// also switches on the pretend homeserver (src/preview-demo): previewDemo true, and the pretend
|
// also switches on the pretend homeserver (src/preview-demo): previewDemo true, and the pretend
|
||||||
// server as the only homeserver. Usage:
|
// server as the only homeserver. Usage:
|
||||||
|
|
@ -11,11 +10,11 @@ import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const demo = args.includes('--demo');
|
const demo = args.includes('--demo');
|
||||||
const [tier = 'low', style = 'ridge'] = args.filter((a) => !a.startsWith('--'));
|
const [tier = 'low', style] = args.filter((a) => !a.startsWith('--'));
|
||||||
const file = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'dist', 'config.json');
|
const file = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'dist', 'config.json');
|
||||||
const config = JSON.parse(fs.readFileSync(file, 'utf8'));
|
const config = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||||
config.hashRouter = { enabled: true, basename: '/' };
|
config.hashRouter = { enabled: true, basename: '/' };
|
||||||
config.backgroundScene = { ...config.backgroundScene, tier, style };
|
config.backgroundScene = { ...config.backgroundScene, tier, ...(style ? { style } : {}) };
|
||||||
if (demo) {
|
if (demo) {
|
||||||
config.previewDemo = true;
|
config.previewDemo = true;
|
||||||
config.defaultHomeserver = 0;
|
config.defaultHomeserver = 0;
|
||||||
|
|
@ -30,5 +29,5 @@ if (demo) {
|
||||||
}
|
}
|
||||||
fs.writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`);
|
fs.writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`);
|
||||||
console.log(
|
console.log(
|
||||||
`${file}: backgroundScene.tier = ${tier}, style = ${style}${demo ? ', previewDemo on' : ''}`
|
`${file}: backgroundScene.tier = ${tier}${style ? `, style = ${style}` : ''}${demo ? ', previewDemo on' : ''}`
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ import {
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import { MatrixError } from 'matrix-js-sdk';
|
import { MatrixError } from 'matrix-js-sdk';
|
||||||
import { Cursor } from '../plugins/text-area';
|
import { Cursor } from '../plugins/text-area';
|
||||||
import { PhoneHit } from '../styles/PhoneHit.css';
|
|
||||||
import { syntaxErrorPosition } from '../utils/dom';
|
import { syntaxErrorPosition } from '../utils/dom';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
||||||
import { Page, PageHeader } from './page';
|
import { Page, PageHeader } from './page';
|
||||||
|
|
@ -295,7 +294,7 @@ export function AccountDataEditor({
|
||||||
</Chip>
|
</Chip>
|
||||||
</Box>
|
</Box>
|
||||||
<Box shrink="No">
|
<Box shrink="No">
|
||||||
<IconButton onClick={requestClose} variant="Surface" className={PhoneHit}>
|
<IconButton onClick={requestClose} variant="Surface">
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,6 @@ import {
|
||||||
} from '../hooks/useKeyBackup';
|
} from '../hooks/useKeyBackup';
|
||||||
import { stopPropagation } from '../utils/keyboard';
|
import { stopPropagation } from '../utils/keyboard';
|
||||||
import { useRestoreBackupOnVerification } from '../hooks/useRestoreBackupOnVerification';
|
import { useRestoreBackupOnVerification } from '../hooks/useRestoreBackupOnVerification';
|
||||||
import { PhoneHit } from '../styles/PhoneHit.css';
|
|
||||||
|
|
||||||
type BackupStatusProps = {
|
type BackupStatusProps = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
|
@ -178,7 +177,6 @@ export function BackupRestoreTile({ crypto }: BackupRestoreTileProps) {
|
||||||
<BackupSyncing count={remainingSession} />
|
<BackupSyncing count={remainingSession} />
|
||||||
)}
|
)}
|
||||||
<IconButton
|
<IconButton
|
||||||
className={PhoneHit}
|
|
||||||
aria-pressed={!!menuCords}
|
aria-pressed={!!menuCords}
|
||||||
size="300"
|
size="300"
|
||||||
variant="Surface"
|
variant="Surface"
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,6 @@ import {
|
||||||
} from '../hooks/useVerificationRequest';
|
} from '../hooks/useVerificationRequest';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
||||||
import { ContainerColor } from '../styles/ContainerColor.css';
|
import { ContainerColor } from '../styles/ContainerColor.css';
|
||||||
import { PhoneHit } from '../styles/PhoneHit.css';
|
|
||||||
|
|
||||||
const DialogHeaderStyles: CSSProperties = {
|
const DialogHeaderStyles: CSSProperties = {
|
||||||
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||||
|
|
@ -262,7 +261,7 @@ export function DeviceVerification({ request, onExit }: DeviceVerificationProps)
|
||||||
<Box grow="Yes">
|
<Box grow="Yes">
|
||||||
<Text size="H4">Device Verification</Text>
|
<Text size="H4">Device Verification</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton className={PhoneHit} size="300" radii="300" onClick={handleCancel}>
|
<IconButton size="300" radii="300" onClick={handleCancel}>
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Header>
|
</Header>
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@ import { ActionUIA, ActionUIAFlowsLoader } from './ActionUIA';
|
||||||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||||
import { useAlive } from '../hooks/useAlive';
|
import { useAlive } from '../hooks/useAlive';
|
||||||
import { UseStateProvider } from './UseStateProvider';
|
import { UseStateProvider } from './UseStateProvider';
|
||||||
import { PhoneHit } from '../styles/PhoneHit.css';
|
|
||||||
|
|
||||||
type UIACallback<T> = (
|
type UIACallback<T> = (
|
||||||
authDict: AuthDict | null
|
authDict: AuthDict | null
|
||||||
|
|
@ -302,7 +301,7 @@ export const DeviceVerificationSetup = forwardRef<HTMLDivElement, DeviceVerifica
|
||||||
<Box grow="Yes">
|
<Box grow="Yes">
|
||||||
<Text size="H4">Setup Device Verification</Text>
|
<Text size="H4">Setup Device Verification</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton className={PhoneHit} size="300" radii="300" onClick={onCancel}>
|
<IconButton size="300" radii="300" onClick={onCancel}>
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Header>
|
</Header>
|
||||||
|
|
@ -337,7 +336,7 @@ export const DeviceVerificationReset = forwardRef<HTMLDivElement, DeviceVerifica
|
||||||
<Box grow="Yes">
|
<Box grow="Yes">
|
||||||
<Text size="H4">Reset Device Verification</Text>
|
<Text size="H4">Reset Device Verification</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton className={PhoneHit} size="300" radii="300" onClick={onCancel}>
|
<IconButton size="300" radii="300" onClick={onCancel}>
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Header>
|
</Header>
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@ import { AsyncStatus } from '../../hooks/useAsyncCallback';
|
||||||
import { useZoom } from '../../hooks/useZoom';
|
import { useZoom } from '../../hooks/useZoom';
|
||||||
import { createPage, usePdfDocumentLoader, usePdfJSLoader } from '../../plugins/pdfjs-dist';
|
import { createPage, usePdfDocumentLoader, usePdfJSLoader } from '../../plugins/pdfjs-dist';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import { PhoneHit, PhoneHitSpread24 } from '../../styles/PhoneHit.css';
|
|
||||||
|
|
||||||
export type PdfViewerProps = {
|
export type PdfViewerProps = {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -109,7 +108,7 @@ export const PdfViewer = as<'div', PdfViewerProps>(
|
||||||
<Box className={classNames(css.PdfViewer, className)} direction="Column" {...props} ref={ref}>
|
<Box className={classNames(css.PdfViewer, className)} direction="Column" {...props} ref={ref}>
|
||||||
<Header className={css.PdfViewerHeader} size="400">
|
<Header className={css.PdfViewerHeader} size="400">
|
||||||
<Box grow="Yes" alignItems="Center" gap="200">
|
<Box grow="Yes" alignItems="Center" gap="200">
|
||||||
<IconButton className={PhoneHit} size="300" radii="300" onClick={requestClose}>
|
<IconButton size="300" radii="300" onClick={requestClose}>
|
||||||
<Icon size="50" src={Icons.ArrowLeft} />
|
<Icon size="50" src={Icons.ArrowLeft} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<Text size="T300" truncate>
|
<Text size="T300" truncate>
|
||||||
|
|
@ -118,7 +117,6 @@ export const PdfViewer = as<'div', PdfViewerProps>(
|
||||||
</Box>
|
</Box>
|
||||||
<Box shrink="No" alignItems="Center" gap="200">
|
<Box shrink="No" alignItems="Center" gap="200">
|
||||||
<IconButton
|
<IconButton
|
||||||
className={PhoneHitSpread24}
|
|
||||||
variant={zoom < 1 ? 'Success' : 'SurfaceVariant'}
|
variant={zoom < 1 ? 'Success' : 'SurfaceVariant'}
|
||||||
outlined={zoom < 1}
|
outlined={zoom < 1}
|
||||||
size="300"
|
size="300"
|
||||||
|
|
@ -132,7 +130,6 @@ export const PdfViewer = as<'div', PdfViewerProps>(
|
||||||
<Text size="B300">{Math.round(zoom * 100)}%</Text>
|
<Text size="B300">{Math.round(zoom * 100)}%</Text>
|
||||||
</Chip>
|
</Chip>
|
||||||
<IconButton
|
<IconButton
|
||||||
className={PhoneHitSpread24}
|
|
||||||
variant={zoom > 1 ? 'Success' : 'SurfaceVariant'}
|
variant={zoom > 1 ? 'Success' : 'SurfaceVariant'}
|
||||||
outlined={zoom > 1}
|
outlined={zoom > 1}
|
||||||
size="300"
|
size="300"
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ import {
|
||||||
IconButton,
|
IconButton,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import { PhoneHit } from '../styles/PhoneHit.css';
|
|
||||||
|
|
||||||
export type UIAFlowOverlayProps = {
|
export type UIAFlowOverlayProps = {
|
||||||
currentStep: number;
|
currentStep: number;
|
||||||
|
|
@ -54,7 +53,6 @@ export function UIAFlowOverlay({
|
||||||
>
|
>
|
||||||
{(anchorRef) => (
|
{(anchorRef) => (
|
||||||
<IconButton
|
<IconButton
|
||||||
className={PhoneHit}
|
|
||||||
ref={anchorRef}
|
ref={anchorRef}
|
||||||
variant="Critical"
|
variant="Critical"
|
||||||
size="300"
|
size="300"
|
||||||
|
|
|
||||||
84
src/app/components/editor/Editor.preview.tsx
Normal file
84
src/app/components/editor/Editor.preview.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import {
|
||||||
|
config,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Icons,
|
||||||
|
Line,
|
||||||
|
Modal,
|
||||||
|
Overlay,
|
||||||
|
OverlayBackdrop,
|
||||||
|
OverlayCenter,
|
||||||
|
} from 'folds';
|
||||||
|
|
||||||
|
import { CustomEditor, useEditor } from './Editor';
|
||||||
|
import { Toolbar } from './Toolbar';
|
||||||
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
|
||||||
|
export function EditorPreview() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const editor = useEditor();
|
||||||
|
const [toolbar, setToolbar] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<IconButton variant="SurfaceVariant" onClick={() => setOpen(!open)}>
|
||||||
|
<Icon src={Icons.BlockQuote} />
|
||||||
|
</IconButton>
|
||||||
|
<Overlay open={open} backdrop={<OverlayBackdrop />}>
|
||||||
|
<OverlayCenter>
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: () => setOpen(false),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Modal size="500">
|
||||||
|
<div style={{ padding: config.space.S400 }}>
|
||||||
|
<CustomEditor
|
||||||
|
editor={editor}
|
||||||
|
placeholder="Send a message..."
|
||||||
|
before={
|
||||||
|
<IconButton variant="SurfaceVariant" size="300" radii="300">
|
||||||
|
<Icon src={Icons.PlusCircle} />
|
||||||
|
</IconButton>
|
||||||
|
}
|
||||||
|
after={
|
||||||
|
<>
|
||||||
|
<IconButton
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
onClick={() => setToolbar(!toolbar)}
|
||||||
|
aria-pressed={toolbar}
|
||||||
|
>
|
||||||
|
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton variant="SurfaceVariant" size="300" radii="300">
|
||||||
|
<Icon src={Icons.Smile} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton variant="SurfaceVariant" size="300" radii="300">
|
||||||
|
<Icon src={Icons.Send} />
|
||||||
|
</IconButton>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
bottom={
|
||||||
|
toolbar && (
|
||||||
|
<div>
|
||||||
|
<Line variant="SurfaceVariant" size="300" />
|
||||||
|
<Toolbar />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</FocusTrap>
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -36,7 +36,6 @@ import { KeySymbol } from '../../utils/key-symbol';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import { PhoneHit, PhoneHitRoom36, PhoneHitSpread36 } from '../../styles/PhoneHit.css';
|
|
||||||
|
|
||||||
function BtnTooltip({ text, shortCode }: { text: string; shortCode?: string }) {
|
function BtnTooltip({ text, shortCode }: { text: string; shortCode?: string }) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -73,7 +72,6 @@ export function MarkButton({ format, icon, tooltip }: MarkButtonProps) {
|
||||||
<TooltipProvider tooltip={tooltip} delay={500}>
|
<TooltipProvider tooltip={tooltip} delay={500}>
|
||||||
{(triggerRef) => (
|
{(triggerRef) => (
|
||||||
<IconButton
|
<IconButton
|
||||||
className={PhoneHitSpread36}
|
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
|
@ -106,7 +104,6 @@ export function BlockButton({ format, icon, tooltip }: BlockButtonProps) {
|
||||||
<TooltipProvider tooltip={tooltip} delay={500}>
|
<TooltipProvider tooltip={tooltip} delay={500}>
|
||||||
{(triggerRef) => (
|
{(triggerRef) => (
|
||||||
<IconButton
|
<IconButton
|
||||||
className={PhoneHitSpread36}
|
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
|
@ -166,7 +163,6 @@ export function HeadingBlockButton() {
|
||||||
>
|
>
|
||||||
{(triggerRef) => (
|
{(triggerRef) => (
|
||||||
<IconButton
|
<IconButton
|
||||||
className={PhoneHitRoom36}
|
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
onClick={() => handleMenuSelect(1)}
|
onClick={() => handleMenuSelect(1)}
|
||||||
size="400"
|
size="400"
|
||||||
|
|
@ -182,7 +178,6 @@ export function HeadingBlockButton() {
|
||||||
>
|
>
|
||||||
{(triggerRef) => (
|
{(triggerRef) => (
|
||||||
<IconButton
|
<IconButton
|
||||||
className={PhoneHitRoom36}
|
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
onClick={() => handleMenuSelect(2)}
|
onClick={() => handleMenuSelect(2)}
|
||||||
size="400"
|
size="400"
|
||||||
|
|
@ -198,7 +193,6 @@ export function HeadingBlockButton() {
|
||||||
>
|
>
|
||||||
{(triggerRef) => (
|
{(triggerRef) => (
|
||||||
<IconButton
|
<IconButton
|
||||||
className={PhoneHitRoom36}
|
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
onClick={() => handleMenuSelect(3)}
|
onClick={() => handleMenuSelect(3)}
|
||||||
size="400"
|
size="400"
|
||||||
|
|
@ -214,7 +208,6 @@ export function HeadingBlockButton() {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
className={PhoneHit}
|
|
||||||
style={{ width: 'unset' }}
|
style={{ width: 'unset' }}
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
onClick={handleMenuOpen}
|
onClick={handleMenuOpen}
|
||||||
|
|
@ -246,7 +239,6 @@ export function ExitFormatting({ tooltip }: ExitFormattingProps) {
|
||||||
<TooltipProvider tooltip={tooltip} delay={500}>
|
<TooltipProvider tooltip={tooltip} delay={500}>
|
||||||
{(triggerRef) => (
|
{(triggerRef) => (
|
||||||
<IconButton
|
<IconButton
|
||||||
className={PhoneHit}
|
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
|
@ -352,7 +344,6 @@ export function Toolbar() {
|
||||||
>
|
>
|
||||||
{(triggerRef) => (
|
{(triggerRef) => (
|
||||||
<IconButton
|
<IconButton
|
||||||
className={PhoneHit}
|
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
onClick={() => setIsMarkdown(!isMarkdown)}
|
onClick={() => setIsMarkdown(!isMarkdown)}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ import {
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import * as css from './styles.css';
|
import * as css from './styles.css';
|
||||||
import { PhoneHit } from '../../../styles/PhoneHit.css';
|
|
||||||
|
|
||||||
export function Sidebar({ children }: { children: ReactNode }) {
|
export function Sidebar({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -69,7 +68,6 @@ function SidebarBtn<T extends string>({
|
||||||
>
|
>
|
||||||
{(ref) => (
|
{(ref) => (
|
||||||
<IconButton
|
<IconButton
|
||||||
className={PhoneHit}
|
|
||||||
aria-pressed={active}
|
aria-pressed={active}
|
||||||
aria-labelledby={`SidebarStackItem-${id}-label`}
|
aria-labelledby={`SidebarStackItem-${id}-label`}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,6 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||||
import { getMouseEventCords } from '../../utils/dom';
|
import { getMouseEventCords } from '../../utils/dom';
|
||||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
|
||||||
|
|
||||||
export type EventReadersProps = {
|
export type EventReadersProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
|
|
@ -53,7 +52,7 @@ export const EventReaders = as<'div', EventReadersProps>(
|
||||||
<Box grow="Yes">
|
<Box grow="Yes">
|
||||||
<Text size="H3">Seen by</Text>
|
<Text size="H3">Seen by</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton className={PhoneHit} size="300" onClick={requestClose}>
|
<IconButton size="300" onClick={requestClose}>
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Header>
|
</Header>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import React from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds';
|
import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds';
|
||||||
import * as css from './ImageEditor.css';
|
import * as css from './ImageEditor.css';
|
||||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
|
||||||
|
|
||||||
export type ImageEditorProps = {
|
export type ImageEditorProps = {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -25,7 +24,7 @@ export const ImageEditor = as<'div', ImageEditorProps>(
|
||||||
>
|
>
|
||||||
<Header className={css.ImageEditorHeader} size="400">
|
<Header className={css.ImageEditorHeader} size="400">
|
||||||
<Box grow="Yes" alignItems="Center" gap="200">
|
<Box grow="Yes" alignItems="Center" gap="200">
|
||||||
<IconButton className={PhoneHit} size="300" radii="300" onClick={requestClose}>
|
<IconButton size="300" radii="300" onClick={requestClose}>
|
||||||
<Icon size="50" src={Icons.ArrowLeft} />
|
<Icon size="50" src={Icons.ArrowLeft} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<Text size="T300" truncate>
|
<Text size="T300" truncate>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Box, IconButton, Text, Icon, Icons, Scroll, Chip } from 'folds';
|
import { Box, IconButton, Text, Icon, Icons, Scroll, Chip } from 'folds';
|
||||||
import { PackAddress } from '../../plugins/custom-emoji';
|
import { PackAddress } from '../../plugins/custom-emoji';
|
||||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
|
||||||
import { Page, PageHeader, PageContent } from '../page';
|
import { Page, PageHeader, PageContent } from '../page';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { RoomImagePack } from './RoomImagePack';
|
import { RoomImagePack } from './RoomImagePack';
|
||||||
|
|
@ -30,7 +29,7 @@ export function ImagePackView({ address, requestClose }: ImagePackViewProps) {
|
||||||
</Chip>
|
</Chip>
|
||||||
</Box>
|
</Box>
|
||||||
<Box shrink="No">
|
<Box shrink="No">
|
||||||
<IconButton onClick={requestClose} variant="Surface" className={PhoneHit}>
|
<IconButton onClick={requestClose} variant="Surface">
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import * as css from './ImageViewer.css';
|
||||||
import { useZoom } from '../../hooks/useZoom';
|
import { useZoom } from '../../hooks/useZoom';
|
||||||
import { usePan } from '../../hooks/usePan';
|
import { usePan } from '../../hooks/usePan';
|
||||||
import { downloadMedia } from '../../utils/matrix';
|
import { downloadMedia } from '../../utils/matrix';
|
||||||
import { PhoneHit, PhoneHitSpread24 } from '../../styles/PhoneHit.css';
|
|
||||||
|
|
||||||
export type ImageViewerProps = {
|
export type ImageViewerProps = {
|
||||||
alt: string;
|
alt: string;
|
||||||
|
|
@ -34,7 +33,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
|
||||||
>
|
>
|
||||||
<Header className={css.ImageViewerHeader} size="400">
|
<Header className={css.ImageViewerHeader} size="400">
|
||||||
<Box grow="Yes" alignItems="Center" gap="200">
|
<Box grow="Yes" alignItems="Center" gap="200">
|
||||||
<IconButton className={PhoneHit} size="300" radii="300" onClick={requestClose}>
|
<IconButton size="300" radii="300" onClick={requestClose}>
|
||||||
<Icon size="50" src={Icons.ArrowLeft} />
|
<Icon size="50" src={Icons.ArrowLeft} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<Text size="T300" truncate>
|
<Text size="T300" truncate>
|
||||||
|
|
@ -43,7 +42,6 @@ export const ImageViewer = as<'div', ImageViewerProps>(
|
||||||
</Box>
|
</Box>
|
||||||
<Box shrink="No" alignItems="Center" gap="200">
|
<Box shrink="No" alignItems="Center" gap="200">
|
||||||
<IconButton
|
<IconButton
|
||||||
className={PhoneHitSpread24}
|
|
||||||
variant={zoom < 1 ? 'Success' : 'SurfaceVariant'}
|
variant={zoom < 1 ? 'Success' : 'SurfaceVariant'}
|
||||||
outlined={zoom < 1}
|
outlined={zoom < 1}
|
||||||
size="300"
|
size="300"
|
||||||
|
|
@ -57,7 +55,6 @@ export const ImageViewer = as<'div', ImageViewerProps>(
|
||||||
<Text size="B300">{Math.round(zoom * 100)}%</Text>
|
<Text size="B300">{Math.round(zoom * 100)}%</Text>
|
||||||
</Chip>
|
</Chip>
|
||||||
<IconButton
|
<IconButton
|
||||||
className={PhoneHitSpread24}
|
|
||||||
variant={zoom > 1 ? 'Success' : 'SurfaceVariant'}
|
variant={zoom > 1 ? 'Success' : 'SurfaceVariant'}
|
||||||
outlined={zoom > 1}
|
outlined={zoom > 1}
|
||||||
size="300"
|
size="300"
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,6 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { BreakWord } from '../../styles/Text.css';
|
import { BreakWord } from '../../styles/Text.css';
|
||||||
import { useAlive } from '../../hooks/useAlive';
|
import { useAlive } from '../../hooks/useAlive';
|
||||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
|
||||||
|
|
||||||
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
|
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
|
||||||
limit: 1000,
|
limit: 1000,
|
||||||
|
|
@ -174,7 +173,7 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box shrink="No">
|
<Box shrink="No">
|
||||||
<IconButton className={PhoneHit} size="300" radii="300" onClick={requestClose}>
|
<IconButton size="300" radii="300" onClick={requestClose}>
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ import { stopPropagation } from '../../utils/keyboard';
|
||||||
import { isRoomAlias, isRoomId } from '../../utils/matrix';
|
import { isRoomAlias, isRoomId } from '../../utils/matrix';
|
||||||
import { parseMatrixToRoom, parseMatrixToRoomEvent, testMatrixTo } from '../../plugins/matrix-to';
|
import { parseMatrixToRoom, parseMatrixToRoomEvent, testMatrixTo } from '../../plugins/matrix-to';
|
||||||
import { tryDecodeURIComponent } from '../../utils/dom';
|
import { tryDecodeURIComponent } from '../../utils/dom';
|
||||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
|
||||||
|
|
||||||
type JoinAddressProps = {
|
type JoinAddressProps = {
|
||||||
onOpen: (roomIdOrAlias: string, via?: string[], eventId?: string) => void;
|
onOpen: (roomIdOrAlias: string, via?: string[], eventId?: string) => void;
|
||||||
|
|
@ -83,7 +82,7 @@ export function JoinAddressPrompt({ onOpen, onCancel }: JoinAddressProps) {
|
||||||
<Box grow="Yes">
|
<Box grow="Yes">
|
||||||
<Text size="H4">Join with Address</Text>
|
<Text size="H4">Join with Address</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton className={PhoneHit} size="300" onClick={onCancel} radii="300">
|
<IconButton size="300" onClick={onCancel} radii="300">
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Header>
|
</Header>
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ import { MatrixError } from 'matrix-js-sdk';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
|
||||||
|
|
||||||
type LeaveRoomPromptProps = {
|
type LeaveRoomPromptProps = {
|
||||||
roomId: string;
|
roomId: string;
|
||||||
|
|
@ -69,7 +68,7 @@ export function LeaveRoomPrompt({ roomId, onDone, onCancel }: LeaveRoomPromptPro
|
||||||
<Box grow="Yes">
|
<Box grow="Yes">
|
||||||
<Text size="H4">Leave Room</Text>
|
<Text size="H4">Leave Room</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton className={PhoneHit} size="300" onClick={onCancel} radii="300">
|
<IconButton size="300" onClick={onCancel} radii="300">
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Header>
|
</Header>
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ import { MatrixError } from 'matrix-js-sdk';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
|
||||||
|
|
||||||
type LeaveSpacePromptProps = {
|
type LeaveSpacePromptProps = {
|
||||||
roomId: string;
|
roomId: string;
|
||||||
|
|
@ -69,7 +68,7 @@ export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptP
|
||||||
<Box grow="Yes">
|
<Box grow="Yes">
|
||||||
<Text size="H4">Leave Space</Text>
|
<Text size="H4">Leave Space</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton className={PhoneHit} size="300" onClick={onCancel} radii="300">
|
<IconButton size="300" onClick={onCancel} radii="300">
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Header>
|
</Header>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { Badge, Box, Icon, IconButton, Icons, Spinner, Text, as, toRem } from 'f
|
||||||
import React, { ReactNode, useCallback } from 'react';
|
import React, { ReactNode, useCallback } from 'react';
|
||||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||||
import FileSaver from 'file-saver';
|
import FileSaver from 'file-saver';
|
||||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
|
||||||
import { mimeTypeToExt } from '../../utils/mimeTypes';
|
import { mimeTypeToExt } from '../../utils/mimeTypes';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
|
|
@ -49,7 +48,6 @@ export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDow
|
||||||
variant={hasError ? 'Critical' : 'SurfaceVariant'}
|
variant={hasError ? 'Critical' : 'SurfaceVariant'}
|
||||||
size="300"
|
size="300"
|
||||||
radii="300"
|
radii="300"
|
||||||
className={PhoneHit}
|
|
||||||
>
|
>
|
||||||
{downloading ? (
|
{downloading ? (
|
||||||
<Spinner size="100" variant={hasError ? 'Critical' : 'Secondary'} />
|
<Spinner size="100" variant={hasError ? 'Critical' : 'Secondary'} />
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
import { createVar, style } from '@vanilla-extract/css';
|
import { createVar, style } from '@vanilla-extract/css';
|
||||||
import { DefaultReset, FocusOutline, color, config, toRem } from 'folds';
|
import { DefaultReset, FocusOutline, color, config, toRem } from 'folds';
|
||||||
import { MOBILE_BREAKPOINT } from '../../hooks/useScreenSize';
|
|
||||||
import { PHONE_TAP } from '../../styles/PhoneHit.css';
|
|
||||||
|
|
||||||
const PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`;
|
|
||||||
const Container = createVar();
|
const Container = createVar();
|
||||||
const ContainerHover = createVar();
|
const ContainerHover = createVar();
|
||||||
const ContainerActive = createVar();
|
const ContainerActive = createVar();
|
||||||
|
|
@ -28,14 +25,6 @@ export const Reaction = style([
|
||||||
selectors: {
|
selectors: {
|
||||||
'button&': {
|
'button&': {
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
// Phone tap target: the chip is 26 px tall, so its hit area grows 9 px above and below (an ::after) and the
|
|
||||||
// same 9 px of margin opens the row, so the chips of one row and of the next never share a tap.
|
|
||||||
'@media': { [PHONE]: { position: 'relative', marginBlock: (PHONE_TAP - 26) / 2 } },
|
|
||||||
},
|
|
||||||
'button&::after': {
|
|
||||||
'@media': {
|
|
||||||
[PHONE]: { content: '""', position: 'absolute', inset: `${-(PHONE_TAP - 26) / 2}px 0` },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
'&[aria-pressed=true]': {
|
'&[aria-pressed=true]': {
|
||||||
vars: {
|
vars: {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import React, { ReactNode, useCallback, useRef, useState } from 'react';
|
||||||
import { Badge, Chip, Icon, IconButton, Icons, ProgressBar, Spinner, Text, toRem } from 'folds';
|
import { Badge, Chip, Icon, IconButton, Icons, ProgressBar, Spinner, Text, toRem } from 'folds';
|
||||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||||
import { Range } from 'react-range';
|
import { Range } from 'react-range';
|
||||||
import { PhoneHitSpread24 } from '../../../styles/PhoneHit.css';
|
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
import { IAudioInfo } from '../../../../types/matrix/common';
|
import { IAudioInfo } from '../../../../types/matrix/common';
|
||||||
|
|
@ -160,7 +159,6 @@ export function AudioContent({
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
size="300"
|
size="300"
|
||||||
radii="Pill"
|
radii="Pill"
|
||||||
className={PhoneHitSpread24}
|
|
||||||
onClick={() => setMute(!mute)}
|
onClick={() => setMute(!mute)}
|
||||||
aria-pressed={mute}
|
aria-pressed={mute}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -60,13 +60,13 @@ export const BubbleLayout = as<'div', BubbleLayoutProps>(
|
||||||
<Box
|
<Box
|
||||||
grow={own ? 'No' : 'Yes'}
|
grow={own ? 'No' : 'Yes'}
|
||||||
direction="Column"
|
direction="Column"
|
||||||
className={classNames(css.BubbleColumn, own && css.BubbleColumnOwn)}
|
className={classNames(own && css.BubbleColumnOwn)}
|
||||||
>
|
>
|
||||||
{header && <div className={classNames(own && css.BubbleHeaderOwn)}>{header}</div>}
|
{header && <div className={classNames(own && css.BubbleHeaderOwn)}>{header}</div>}
|
||||||
{hideBubble ? (
|
{hideBubble ? (
|
||||||
children
|
children
|
||||||
) : (
|
) : (
|
||||||
<Box className={css.BubbleContentRow}>
|
<Box>
|
||||||
<Box
|
<Box
|
||||||
className={classNames(
|
className={classNames(
|
||||||
css.BubbleContent,
|
css.BubbleContent,
|
||||||
|
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
import { readFileSync } from 'fs';
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import zlib from 'zlib';
|
|
||||||
import { compile, processVanillaFile } from '@vanilla-extract/integration';
|
|
||||||
|
|
||||||
// A long reply quote (nowrap) sized the own-message row to 782px on a 390px phone. The row, the column and the bubble
|
|
||||||
// must be able to shrink to their parent, so pin the emitted declarations (as phoneTapTargets.test.ts does).
|
|
||||||
const emitted = async () => {
|
|
||||||
const filePath = `${process.cwd()}/src/app/components/message/layout/layout.css.ts`;
|
|
||||||
const { source } = await compile({ filePath, cwd: process.cwd(), identOption: 'debug' });
|
|
||||||
const code = await processVanillaFile({ source, filePath, identOption: 'debug' });
|
|
||||||
const bytes = Buffer.from(
|
|
||||||
(code.match(/source=#?([A-Za-z0-9+/=]+)/) as RegExpMatchArray)[1],
|
|
||||||
'base64'
|
|
||||||
);
|
|
||||||
return (bytes[0] === 0x1f && bytes[1] === 0x8b ? zlib.gunzipSync(bytes) : bytes).toString();
|
|
||||||
};
|
|
||||||
|
|
||||||
const declarations = (css: string, className: string): string[] => {
|
|
||||||
const rule = new RegExp(`^\\.layout_${className}__[\\w-]+ \\{\\n([^}]*)\\}`, 'm').exec(css);
|
|
||||||
expect(rule, `no .layout_${className} rule emitted`).toBeTruthy();
|
|
||||||
return (rule as RegExpExecArray)[1].split('\n').map((l) => l.trim().replace(/;$/, ''));
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('bubble rows shrink to their column', () => {
|
|
||||||
it.each([
|
|
||||||
['BubbleColumn', ['min-width: 0']],
|
|
||||||
['BubbleContentRow', ['min-width: 0', 'max-width: 100%']],
|
|
||||||
['BubbleContent', ['min-width: 0']],
|
|
||||||
])('%s', async (name, expected) => {
|
|
||||||
const css = await emitted();
|
|
||||||
expect(declarations(css, name)).toEqual(expect.arrayContaining(expected));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('BubbleContent keeps its 800px cap inside the column width', async () => {
|
|
||||||
const css = await emitted();
|
|
||||||
expect(declarations(css, 'BubbleContent').join('\n')).toMatch(/max-width: min\(100%, 50rem\)/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Bubble.tsx puts BubbleColumn on the column and BubbleContentRow on the row', () => {
|
|
||||||
const tsx = readFileSync(
|
|
||||||
`${process.cwd()}/src/app/components/message/layout/Bubble.tsx`,
|
|
||||||
'utf8'
|
|
||||||
);
|
|
||||||
expect(tsx).toMatch(/classNames\(css\.BubbleColumn,/);
|
|
||||||
expect(tsx).toMatch(/<Box className=\{css\.BubbleContentRow\}>/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -139,22 +139,8 @@ export const BubbleBefore = style({
|
||||||
minWidth: toRem(36),
|
minWidth: toRem(36),
|
||||||
});
|
});
|
||||||
|
|
||||||
// minWidth 0 down the column: a flex item never shrinks below its content (a reply quote's unbroken text),
|
|
||||||
// so without it a long quote pushes the column past the row, and a right-aligned row pushes it off the left edge.
|
|
||||||
export const BubbleColumn = style({
|
|
||||||
minWidth: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
// The row around a bubble sizes to its content in a right-aligned (own) column, and a quote's nowrap text
|
|
||||||
// makes that content as wide as the quote: cap the row at the column so the bubble can shrink inside it.
|
|
||||||
export const BubbleContentRow = style({
|
|
||||||
minWidth: 0,
|
|
||||||
maxWidth: '100%',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const BubbleContent = style({
|
export const BubbleContent = style({
|
||||||
minWidth: 0,
|
maxWidth: toRem(800),
|
||||||
maxWidth: `min(100%, ${toRem(800)})`,
|
|
||||||
padding: config.space.S200,
|
padding: config.space.S200,
|
||||||
backgroundColor: color.SurfaceVariant.Container,
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
color: color.SurfaceVariant.OnContainer,
|
color: color.SurfaceVariant.OnContainer,
|
||||||
|
|
@ -299,10 +285,7 @@ export const BubbleLeftArrow = style({
|
||||||
});
|
});
|
||||||
|
|
||||||
// Phone tap target: a sender name is 23 px tall, so the button grows 11 px above and below (44+ px to tap) and the
|
// Phone tap target: a sender name is 23 px tall, so the button grows 11 px above and below (44+ px to tap) and the
|
||||||
// negative margin takes the growth back out of the layout. Sideways it cannot borrow from the layout the
|
// negative margin takes the growth back out of the layout. Hit area only: no background, border or outline moves.
|
||||||
// same way (a negative inline margin makes the button shrink inside its shrink-to-fit parent and clip the name), so
|
|
||||||
// a name under 44 px is 44 px wide, text kept at the start: real growth of at most 16 px, in a row with room for it.
|
|
||||||
// No background, border or outline moves.
|
|
||||||
const SCREEN_PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`;
|
const SCREEN_PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`;
|
||||||
export const Username = style({
|
export const Username = style({
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
|
|
@ -312,7 +295,7 @@ export const Username = style({
|
||||||
'button&': {
|
'button&': {
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
'@media': {
|
'@media': {
|
||||||
[SCREEN_PHONE]: { paddingBlock: 11, marginBlock: -11, minWidth: 44, textAlign: 'start' },
|
[SCREEN_PHONE]: { paddingBlock: 11, marginBlock: -11 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'button&:hover, button&:focus-visible': {
|
'button&:hover, button&:focus-visible': {
|
||||||
|
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { classifyPose, messagePose } from './sentiment';
|
|
||||||
|
|
||||||
describe('classifyPose', () => {
|
|
||||||
it.each([
|
|
||||||
['annoyed', 'ugh, this again'],
|
|
||||||
['surprised', 'wow, no way'],
|
|
||||||
['amused', 'hahaha that is funny'],
|
|
||||||
['happy', 'thanks, great work'],
|
|
||||||
['thinking', 'should we ship it?'],
|
|
||||||
['neutral', 'Merged the branch.'],
|
|
||||||
['neutral', 'sorry, that failed with an error'],
|
|
||||||
])('%s', (label, text) => expect(classifyPose(text)).toBe(label));
|
|
||||||
it('is neutral for empty or non-string text', () => {
|
|
||||||
expect(classifyPose('')).toBe('neutral');
|
|
||||||
expect(classifyPose(undefined)).toBe('neutral');
|
|
||||||
expect(classifyPose(5)).toBe('neutral');
|
|
||||||
});
|
|
||||||
it('is deterministic', () => {
|
|
||||||
expect(classifyPose('thanks?')).toBe(classifyPose('thanks?'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('messagePose', () => {
|
|
||||||
it('lets an explicit label win over the text', () => {
|
|
||||||
expect(messagePose('aqua', { body: 'thanks!', 'org.gamecult.pose': 'annoyed' })).toBe(
|
|
||||||
'annoyed'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
it('keeps an unknown explicit label neutral instead of classifying', () => {
|
|
||||||
expect(messagePose('aqua', { body: 'thanks!', 'org.gamecult.pose': 'conduct' })).toBe(
|
|
||||||
'neutral'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
it('classifies when there is no label, and never outputs a signature label', () => {
|
|
||||||
expect(messagePose('aqua', { body: 'thanks!' })).toBe('happy');
|
|
||||||
expect(messagePose('kiko', { body: 'sorry' })).toBe('neutral');
|
|
||||||
expect(messagePose('aqua', {})).toBe('neutral');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
import { POSE_KEY, NEUTRAL, resolvePose } from './poses';
|
|
||||||
|
|
||||||
// catalogue.json beside this file is a vendored copy of /opt/commons/cast-art/poses/catalogue.json
|
|
||||||
// (npm run sync:poses); nothing else publishes it to the browser.
|
|
||||||
// Local sentiment -> pose label. A lexicon over the message text, no network, no model: the
|
|
||||||
// same text always gives the same pose. Rules are tried in order and the first hit wins, so a
|
|
||||||
// message that says "wow" and "thanks" reads as surprised. Only the shared labels (identical art
|
|
||||||
// for all eight Minds, catalogue.json) are ever output; signature labels are explicit-only.
|
|
||||||
const RULES: [string, RegExp][] = [
|
|
||||||
['annoyed', /\b(annoy\w*|ugh+|argh+|frustrat\w*|again\?|why won'?t|stop it)\b/i],
|
|
||||||
['surprised', /\b(wow|whoa|woah|omg|no way|unexpected\w*|surpris\w*|wait,? what)\b|[?!]{2,}/i],
|
|
||||||
[
|
|
||||||
'determined',
|
|
||||||
/\b(let'?s (go|do|fix|ship)|on it|i'?ll (fix|do|handle)|will do|fixing|resolved?)\b/i,
|
|
||||||
],
|
|
||||||
['amused', /\b(ha(ha)+|lol|lmao|rofl|funny|hilarious)\b|😂|🤣/i],
|
|
||||||
[
|
|
||||||
'happy',
|
|
||||||
/\b(thanks?|thank you|thx|great|awesome|nice|love|perfect|excellent|yay|congrats)\b|🎉|😊/i,
|
|
||||||
],
|
|
||||||
['thinking', /\?\s*$|\b(hmm+|maybe|perhaps|i wonder|not sure|how (do|does|about)|what if)\b/i],
|
|
||||||
];
|
|
||||||
|
|
||||||
export const classifyPose = (text: unknown): string => {
|
|
||||||
if (typeof text !== 'string') return NEUTRAL;
|
|
||||||
const hit = RULES.find(([, re]) => re.test(text));
|
|
||||||
return hit ? hit[0] : NEUTRAL;
|
|
||||||
};
|
|
||||||
|
|
||||||
// An explicit org.gamecult.pose label always wins, even when it is unknown (then neutral).
|
|
||||||
// Only events with no label are read for sentiment.
|
|
||||||
export const messagePose = (mind: string, content: Record<string, unknown>): string =>
|
|
||||||
content[POSE_KEY] !== undefined
|
|
||||||
? resolvePose(mind, content[POSE_KEY])
|
|
||||||
: resolvePose(mind, classifyPose(content.body));
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
import React, { ReactNode } from 'react';
|
|
||||||
import * as css from './styles.css';
|
|
||||||
|
|
||||||
/** The fixed-width first column of a navigator row or section header; its content is centred in it. */
|
|
||||||
export function NavGutter({ children }: { children: ReactNode }) {
|
|
||||||
return <span className={css.NavGutter}>{children}</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One nesting step: everything inside is indented by the same constant. */
|
|
||||||
export function NavLevel({ children }: { children: ReactNode }) {
|
|
||||||
return <div className={css.NavLevel}>{children}</div>;
|
|
||||||
}
|
|
||||||
|
|
@ -4,4 +4,3 @@ export * from './NavEmptyLayout';
|
||||||
export * from './NavItem';
|
export * from './NavItem';
|
||||||
export * from './NavItemContent';
|
export * from './NavItemContent';
|
||||||
export * from './NavItemOptions';
|
export * from './NavItemOptions';
|
||||||
export * from './NavGutter';
|
|
||||||
|
|
|
||||||
|
|
@ -13,31 +13,6 @@ const SCRIM_HOVER = '14%';
|
||||||
const SCRIM_SELECTED = '22%';
|
const SCRIM_SELECTED = '22%';
|
||||||
const SCRIM_PRESSED = '48%';
|
const SCRIM_PRESSED = '48%';
|
||||||
|
|
||||||
// The navigator's one column grid. Every row, section header and join row begins with a gutter of
|
|
||||||
// the same width, with its chevron, room icon or avatar centred in it, so every label starts at
|
|
||||||
// the same x for its depth; each nesting level (NavLevel) indents by one constant step.
|
|
||||||
const GUTTER = toRem(32);
|
|
||||||
const LEVEL_STEP = toRem(16);
|
|
||||||
|
|
||||||
export const NavGutter = style({
|
|
||||||
width: GUTTER,
|
|
||||||
minWidth: GUTTER,
|
|
||||||
flexShrink: 0,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const NavLevel = style({
|
|
||||||
paddingLeft: LEVEL_STEP,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Chevrons and room icons are matched in measured ink (a room glyph is about 1.7x a chevron's; unread rooms stay far above), so a foldout is not outweighed by its rooms.
|
|
||||||
export const NAV_ICON_REST_OPACITY = 0.9;
|
|
||||||
// A room's glyph is bigger and denser than the chevron, so it rests lighter to read as the same weight.
|
|
||||||
export const NAV_ROOM_ICON_REST_OPACITY = 0.26;
|
|
||||||
export const NAV_ICON_UNREAD_OPACITY = 0.6;
|
|
||||||
|
|
||||||
export const NavCategory = style([
|
export const NavCategory = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
|
|
@ -47,16 +22,7 @@ export const NavCategory = style([
|
||||||
|
|
||||||
export const NavCategoryHeader = style({
|
export const NavCategoryHeader = style({
|
||||||
gap: config.space.S100,
|
gap: config.space.S100,
|
||||||
selectors: {
|
|
||||||
// The header's own side padding would shift its gutter off the rows': the section button
|
|
||||||
// carries the same left inset as NavItemContent instead.
|
|
||||||
'&&': { paddingLeft: 0 },
|
|
||||||
},
|
|
||||||
'@media': {
|
'@media': {
|
||||||
// A phone's section header is as tall as its 44 px button, so it does not overlap the next row.
|
|
||||||
'screen and (max-width: 750px)': {
|
|
||||||
selectors: { '&&': { minHeight: toRem(44), height: toRem(44) } },
|
|
||||||
},
|
|
||||||
// Category labels sit on the scene like the rows do, and need a wash to be read (wide screens;
|
// Category labels sit on the scene like the rows do, and need a wash to be read (wide screens;
|
||||||
// a phone's list is plain text).
|
// a phone's list is plain text).
|
||||||
'screen and (min-width: 751px)': {
|
'screen and (min-width: 751px)': {
|
||||||
|
|
@ -121,8 +87,6 @@ const NavItemBase = style({
|
||||||
[PHONE]: {
|
[PHONE]: {
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
borderRadius: 0,
|
borderRadius: 0,
|
||||||
// The row is as tall as the 44 px tap target inside it (EstateNavigator.css.ts), so neighbours do not overlap.
|
|
||||||
minHeight: toRem(44),
|
|
||||||
},
|
},
|
||||||
[WIDE]: {
|
[WIDE]: {
|
||||||
selectors: {
|
selectors: {
|
||||||
|
|
@ -272,18 +236,22 @@ globalStyle(`:root[data-scene] ${NavItemBase} svg, :root[data-scene] ${NavItemBa
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// The Join action is a plain underlined word on every screen, not a pill.
|
// The Join action on a phone is a plain underlined word, not a pill.
|
||||||
export const NavAction = style({
|
export const NavAction = style({
|
||||||
selectors: {
|
'@media': {
|
||||||
'&&': {
|
[PHONE]: {
|
||||||
backgroundColor: 'transparent',
|
selectors: {
|
||||||
boxShadow: 'none',
|
'&&': {
|
||||||
border: 'none',
|
backgroundColor: 'transparent',
|
||||||
borderRadius: 0,
|
boxShadow: 'none',
|
||||||
minWidth: 0,
|
border: 'none',
|
||||||
textDecoration: 'underline',
|
borderRadius: 0,
|
||||||
textUnderlineOffset: toRem(4),
|
minWidth: 0,
|
||||||
flexShrink: 0,
|
textDecoration: 'underline',
|
||||||
|
textUnderlineOffset: toRem(4),
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import React, { ComponentProps, forwardRef } from 'react';
|
import React, { ComponentProps, forwardRef } from 'react';
|
||||||
import { Icon, IconButton, Input, config, Icons } from 'folds';
|
import { Icon, IconButton, Input, config, Icons } from 'folds';
|
||||||
import { UseStateProvider } from '../UseStateProvider';
|
import { UseStateProvider } from '../UseStateProvider';
|
||||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
|
||||||
|
|
||||||
type PasswordInputProps = Omit<ComponentProps<typeof Input>, 'type' | 'size'> & {
|
type PasswordInputProps = Omit<ComponentProps<typeof Input>, 'type' | 'size'> & {
|
||||||
size: '400' | '500';
|
size: '400' | '500';
|
||||||
|
|
@ -24,7 +23,6 @@ export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
|
||||||
<>
|
<>
|
||||||
{after}
|
{after}
|
||||||
<IconButton
|
<IconButton
|
||||||
className={PhoneHit}
|
|
||||||
onClick={() => setVisible(!visible)}
|
onClick={() => setVisible(!visible)}
|
||||||
type="button"
|
type="button"
|
||||||
variant={visible ? 'Warning' : variant}
|
variant={visible ? 'Warning' : variant}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import classNames from 'classnames';
|
||||||
import Linkify from 'linkify-react';
|
import Linkify from 'linkify-react';
|
||||||
import * as css from './style.css';
|
import * as css from './style.css';
|
||||||
import { LINKIFY_OPTS, scaleSystemEmoji } from '../../plugins/react-custom-html-parser';
|
import { LINKIFY_OPTS, scaleSystemEmoji } from '../../plugins/react-custom-html-parser';
|
||||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
|
||||||
|
|
||||||
export const RoomTopicViewer = as<
|
export const RoomTopicViewer = as<
|
||||||
'div',
|
'div',
|
||||||
|
|
@ -27,7 +26,7 @@ export const RoomTopicViewer = as<
|
||||||
{name}
|
{name}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton className={PhoneHit} size="300" onClick={requestClose} radii="300">
|
<IconButton size="300" onClick={requestClose} radii="300">
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Header>
|
</Header>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import { Box, Chip, Header, Icon, IconButton, Icons, Scroll, Text, as } from 'fo
|
||||||
import { ErrorBoundary } from 'react-error-boundary';
|
import { ErrorBoundary } from 'react-error-boundary';
|
||||||
import * as css from './TextViewer.css';
|
import * as css from './TextViewer.css';
|
||||||
import { copyToClipboard } from '../../utils/dom';
|
import { copyToClipboard } from '../../utils/dom';
|
||||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
|
||||||
|
|
||||||
const ReactPrism = lazy(() => import('../../plugins/react-prism/ReactPrism'));
|
const ReactPrism = lazy(() => import('../../plugins/react-prism/ReactPrism'));
|
||||||
|
|
||||||
|
|
@ -54,7 +53,7 @@ export const TextViewer = as<'div', TextViewerProps>(
|
||||||
>
|
>
|
||||||
<Header className={css.TextViewerHeader} size="400">
|
<Header className={css.TextViewerHeader} size="400">
|
||||||
<Box grow="Yes" alignItems="Center" gap="200">
|
<Box grow="Yes" alignItems="Center" gap="200">
|
||||||
<IconButton className={PhoneHit} size="300" radii="300" onClick={requestClose}>
|
<IconButton size="300" radii="300" onClick={requestClose}>
|
||||||
<Icon size="50" src={Icons.ArrowLeft} />
|
<Icon size="50" src={Icons.ArrowLeft} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<Text size="T300" truncate>
|
<Text size="T300" truncate>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { AuthType } from 'matrix-js-sdk';
|
||||||
import { StageComponentProps } from './types';
|
import { StageComponentProps } from './types';
|
||||||
import { ErrorCode } from '../../cs-errorcode';
|
import { ErrorCode } from '../../cs-errorcode';
|
||||||
import { PasswordInput } from '../password-input';
|
import { PasswordInput } from '../password-input';
|
||||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
|
||||||
|
|
||||||
export function PasswordStage({
|
export function PasswordStage({
|
||||||
stageData,
|
stageData,
|
||||||
|
|
@ -46,7 +45,7 @@ export function PasswordStage({
|
||||||
<Box grow="Yes">
|
<Box grow="Yes">
|
||||||
<Text size="H4">Account Password</Text>
|
<Text size="H4">Account Password</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton className={PhoneHit} size="300" onClick={onCancel} radii="300">
|
<IconButton size="300" onClick={onCancel} radii="300">
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Header>
|
</Header>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { Box, Button, color, config, Dialog, Header, Icon, IconButton, Icons, Text } from 'folds';
|
import { Box, Button, color, config, Dialog, Header, Icon, IconButton, Icons, Text } from 'folds';
|
||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { StageComponentProps } from './types';
|
import { StageComponentProps } from './types';
|
||||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
|
||||||
|
|
||||||
export function SSOStage({
|
export function SSOStage({
|
||||||
ssoRedirectURL,
|
ssoRedirectURL,
|
||||||
|
|
@ -57,7 +56,7 @@ export function SSOStage({
|
||||||
<Box grow="Yes">
|
<Box grow="Yes">
|
||||||
<Text size="H4">SSO Login</Text>
|
<Text size="H4">SSO Login</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton className={PhoneHit} size="300" onClick={onCancel} radii="300">
|
<IconButton size="300" onClick={onCancel} radii="300">
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Header>
|
</Header>
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,7 @@ export function UnreadBadge({ highlight, count }: UnreadBadgeProps) {
|
||||||
<Badge
|
<Badge
|
||||||
variant={highlight ? 'Success' : 'Secondary'}
|
variant={highlight ? 'Success' : 'Secondary'}
|
||||||
size={count > 0 ? '400' : '200'}
|
size={count > 0 ? '400' : '200'}
|
||||||
// A mention and the bare unread dot are solid (a soft dot measured 1.6:1 against the ground); a plain unread
|
fill="Solid"
|
||||||
// count is a soft tint, so a row's count is never its loudest part.
|
|
||||||
fill={highlight || count === 0 ? 'Solid' : 'Soft'}
|
|
||||||
radii="Pill"
|
radii="Pill"
|
||||||
outlined={false}
|
outlined={false}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { TUploadContent } from '../../utils/matrix';
|
import { TUploadContent } from '../../utils/matrix';
|
||||||
import { bytesToSize, getFileTypeIcon } from '../../utils/common';
|
import { bytesToSize, getFileTypeIcon } from '../../utils/common';
|
||||||
import { useMediaConfig } from '../../hooks/useMediaConfig';
|
import { useMediaConfig } from '../../hooks/useMediaConfig';
|
||||||
import { PhoneHitSpread28 } from '../../styles/PhoneHit.css';
|
|
||||||
|
|
||||||
type CompactUploadCardRendererProps = {
|
type CompactUploadCardRendererProps = {
|
||||||
isEncrypted?: boolean;
|
isEncrypted?: boolean;
|
||||||
|
|
@ -64,7 +63,6 @@ export function CompactUploadCardRenderer({
|
||||||
</Chip>
|
</Chip>
|
||||||
)}
|
)}
|
||||||
<IconButton
|
<IconButton
|
||||||
className={PhoneHitSpread28}
|
|
||||||
onClick={removeUpload}
|
onClick={removeUpload}
|
||||||
aria-label="Cancel Upload"
|
aria-label="Cancel Upload"
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import {
|
||||||
} from '../../state/room/roomInputDrafts';
|
} from '../../state/room/roomInputDrafts';
|
||||||
import { useObjectURL } from '../../hooks/useObjectURL';
|
import { useObjectURL } from '../../hooks/useObjectURL';
|
||||||
import { useMediaConfig } from '../../hooks/useMediaConfig';
|
import { useMediaConfig } from '../../hooks/useMediaConfig';
|
||||||
import { PhoneHitSpread28 } from '../../styles/PhoneHit.css';
|
|
||||||
|
|
||||||
type PreviewImageProps = {
|
type PreviewImageProps = {
|
||||||
fileItem: TUploadItem;
|
fileItem: TUploadItem;
|
||||||
|
|
@ -161,7 +160,6 @@ export function UploadCardRenderer({
|
||||||
</Chip>
|
</Chip>
|
||||||
)}
|
)}
|
||||||
<IconButton
|
<IconButton
|
||||||
className={PhoneHitSpread28}
|
|
||||||
onClick={removeUpload}
|
onClick={removeUpload}
|
||||||
aria-label="Cancel Upload"
|
aria-label="Cancel Upload"
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import classNames from 'classnames';
|
|
||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { IPreviewUrlResponse } from 'matrix-js-sdk';
|
import { IPreviewUrlResponse } from 'matrix-js-sdk';
|
||||||
import { Box, Icon, IconButton, Icons, Scroll, Spinner, Text, as, color, config } from 'folds';
|
import { Box, Icon, IconButton, Icons, Scroll, Spinner, Text, as, color, config } from 'folds';
|
||||||
|
|
@ -11,7 +10,6 @@ import {
|
||||||
useIntersectionObserver,
|
useIntersectionObserver,
|
||||||
} from '../../hooks/useIntersectionObserver';
|
} from '../../hooks/useIntersectionObserver';
|
||||||
import * as css from './UrlPreviewCard.css';
|
import * as css from './UrlPreviewCard.css';
|
||||||
import { PhoneHitInPlace } from '../../styles/PhoneHit.css';
|
|
||||||
import { tryDecodeURIComponent } from '../../utils/dom';
|
import { tryDecodeURIComponent } from '../../utils/dom';
|
||||||
import { mxcUrlToHttp } from '../../utils/matrix';
|
import { mxcUrlToHttp } from '../../utils/matrix';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
|
|
@ -183,10 +181,7 @@ export const UrlPreviewHolder = as<'div'>(({ children, ...props }, ref) => {
|
||||||
<>
|
<>
|
||||||
<div className={css.UrlPreviewHolderGradient({ position: 'Left' })} />
|
<div className={css.UrlPreviewHolderGradient({ position: 'Left' })} />
|
||||||
<IconButton
|
<IconButton
|
||||||
className={classNames(
|
className={css.UrlPreviewHolderBtn({ position: 'Left' })}
|
||||||
css.UrlPreviewHolderBtn({ position: 'Left' }),
|
|
||||||
PhoneHitInPlace
|
|
||||||
)}
|
|
||||||
variant="Secondary"
|
variant="Secondary"
|
||||||
radii="Pill"
|
radii="Pill"
|
||||||
size="300"
|
size="300"
|
||||||
|
|
@ -204,10 +199,7 @@ export const UrlPreviewHolder = as<'div'>(({ children, ...props }, ref) => {
|
||||||
<>
|
<>
|
||||||
<div className={css.UrlPreviewHolderGradient({ position: 'Right' })} />
|
<div className={css.UrlPreviewHolderGradient({ position: 'Right' })} />
|
||||||
<IconButton
|
<IconButton
|
||||||
className={classNames(
|
className={css.UrlPreviewHolderBtn({ position: 'Right' })}
|
||||||
css.UrlPreviewHolderBtn({ position: 'Right' }),
|
|
||||||
PhoneHitInPlace
|
|
||||||
)}
|
|
||||||
variant="Primary"
|
variant="Primary"
|
||||||
radii="Pill"
|
radii="Pill"
|
||||||
size="300"
|
size="300"
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,6 @@ import { getPowerTagIconSrc, useGetMemberPowerTag } from '../../hooks/useMemberP
|
||||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||||
import { useMemberPowerCompare } from '../../hooks/useMemberPowerCompare';
|
import { useMemberPowerCompare } from '../../hooks/useMemberPowerCompare';
|
||||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
|
||||||
|
|
||||||
type SelfDemoteAlertProps = {
|
type SelfDemoteAlertProps = {
|
||||||
power: number;
|
power: number;
|
||||||
|
|
@ -71,7 +70,7 @@ function SelfDemoteAlert({ power, onCancel, onChange }: SelfDemoteAlertProps) {
|
||||||
<Box grow="Yes">
|
<Box grow="Yes">
|
||||||
<Text size="H4">Self Demotion</Text>
|
<Text size="H4">Self Demotion</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton className={PhoneHit} size="300" onClick={onCancel} radii="300">
|
<IconButton size="300" onClick={onCancel} radii="300">
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Header>
|
</Header>
|
||||||
|
|
@ -121,7 +120,7 @@ function SharedPowerAlert({ power, onCancel, onChange }: SharedPowerAlertProps)
|
||||||
<Box grow="Yes">
|
<Box grow="Yes">
|
||||||
<Text size="H4">Shared Power</Text>
|
<Text size="H4">Shared Power</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton className={PhoneHit} size="300" onClick={onCancel} radii="300">
|
<IconButton size="300" onClick={onCancel} radii="300">
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Header>
|
</Header>
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,6 @@ import { StateEvent } from '../../../types/matrix/room';
|
||||||
import { getViaServers } from '../../plugins/via-servers';
|
import { getViaServers } from '../../plugins/via-servers';
|
||||||
import { rateLimitedActions } from '../../utils/matrix';
|
import { rateLimitedActions } from '../../utils/matrix';
|
||||||
import { useAlive } from '../../hooks/useAlive';
|
import { useAlive } from '../../hooks/useAlive';
|
||||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
|
||||||
|
|
||||||
const SEARCH_OPTS: UseAsyncSearchOptions = {
|
const SEARCH_OPTS: UseAsyncSearchOptions = {
|
||||||
limit: 500,
|
limit: 500,
|
||||||
|
|
@ -202,7 +201,7 @@ export function AddExistingModal({ parentId, space, requestClose }: AddExistingM
|
||||||
<Text size="H4">Add Existing</Text>
|
<Text size="H4">Add Existing</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box shrink="No">
|
<Box shrink="No">
|
||||||
<IconButton className={PhoneHit} size="300" radii="300" onClick={requestClose}>
|
<IconButton size="300" radii="300" onClick={requestClose}>
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -49,23 +49,3 @@ export const FilesWide = style({
|
||||||
});
|
});
|
||||||
|
|
||||||
export const Dim = style({ opacity: config.opacity.P300 });
|
export const Dim = style({ opacity: config.opacity.P300 });
|
||||||
|
|
||||||
/** The share panel: idle it is one row; the title, names and Send mount only once files are picked. */
|
|
||||||
export const SharePanel = style({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: config.space.S200,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const ShareTitle = style({
|
|
||||||
all: 'unset',
|
|
||||||
boxSizing: 'border-box',
|
|
||||||
minHeight: 44,
|
|
||||||
width: '100%',
|
|
||||||
borderBottom: `1px solid ${color.Background.ContainerLine}`,
|
|
||||||
color: color.Background.OnContainer,
|
|
||||||
':focus-visible': { borderBottomColor: color.Background.OnContainer },
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Picked names and send notes wrap, even a long unbroken name. */
|
|
||||||
export const ShareNames = style({ overflowWrap: 'anywhere' });
|
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,6 @@ import { getBlobSafeMimeType } from '../../utils/mimeTypes';
|
||||||
import { timeDayMonYear } from '../../utils/time';
|
import { timeDayMonYear } from '../../utils/time';
|
||||||
import { getMemberDisplayName } from '../../utils/room';
|
import { getMemberDisplayName } from '../../utils/room';
|
||||||
import { Album, AlbumFile } from './buildAlbums';
|
import { Album, AlbumFile } from './buildAlbums';
|
||||||
import { FileReadBy } from './FileReadBy';
|
|
||||||
import { ShareSet, useShareEnabled } from './ShareSet';
|
|
||||||
import { useAlbums } from './useAlbums';
|
import { useAlbums } from './useAlbums';
|
||||||
import * as css from './AlbumView.css';
|
import * as css from './AlbumView.css';
|
||||||
|
|
||||||
|
|
@ -49,7 +47,6 @@ function FileRow({ room, file, author }: FileRowProps) {
|
||||||
<Text size="T300" truncate>
|
<Text size="T300" truncate>
|
||||||
{file.name}
|
{file.name}
|
||||||
</Text>
|
</Text>
|
||||||
<FileReadBy room={room} fileId={file.id} content={file.content} also={file.readBy} />
|
|
||||||
{audio ? (
|
{audio ? (
|
||||||
<AudioContent {...audio} renderMediaControl={(p) => <MediaControl {...p} />} />
|
<AudioContent {...audio} renderMediaControl={(p) => <MediaControl {...p} />} />
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -94,7 +91,6 @@ export function AlbumView({ room, onTimeline }: AlbumViewProps) {
|
||||||
const open: Album | undefined = albums.find((a) => a.id === openId);
|
const open: Album | undefined = albums.find((a) => a.id === openId);
|
||||||
const authorOf = (a: Album) => getMemberDisplayName(room, a.author) ?? a.author;
|
const authorOf = (a: Album) => getMemberDisplayName(room, a.author) ?? a.author;
|
||||||
const encrypted = room.hasEncryptionStateEvent();
|
const encrypted = room.hasEncryptionStateEvent();
|
||||||
const canShare = useShareEnabled(room);
|
|
||||||
|
|
||||||
let body: React.ReactNode;
|
let body: React.ReactNode;
|
||||||
if (loading) body = <Text>Loading albums…</Text>;
|
if (loading) body = <Text>Loading albums…</Text>;
|
||||||
|
|
@ -136,12 +132,6 @@ export function AlbumView({ room, onTimeline }: AlbumViewProps) {
|
||||||
<Scroll hideTrack visibility="Hover">
|
<Scroll hideTrack visibility="Hover">
|
||||||
<div className={css.Column}>
|
<div className={css.Column}>
|
||||||
{body}
|
{body}
|
||||||
{/* Stays mounted while an album is open, so a running send keeps its progress. */}
|
|
||||||
{canShare && (
|
|
||||||
<div hidden={!!open}>
|
|
||||||
<ShareSet room={room} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<button type="button" className={css.Row} onClick={onTimeline}>
|
<button type="button" className={css.Row} onClick={onTimeline}>
|
||||||
<Text size="B400">Timeline ›</Text>
|
<Text size="B400">Timeline ›</Text>
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { MatrixEvent, Room } from 'matrix-js-sdk';
|
|
||||||
import { Text } from 'folds';
|
|
||||||
import { getMemberDisplayName } from '../../utils/room';
|
|
||||||
import { FileReader, readLine } from './fileReceipt';
|
|
||||||
import { useFileReaders } from './useFileReaders';
|
|
||||||
import * as css from './AlbumView.css';
|
|
||||||
|
|
||||||
type FileReadByProps = {
|
|
||||||
room: Room;
|
|
||||||
fileId: string;
|
|
||||||
content: Record<string, unknown>;
|
|
||||||
/** Receipts already read elsewhere (an album's thread replies): merged with the timeline's, a Mind once. */
|
|
||||||
also?: FileReader[];
|
|
||||||
};
|
|
||||||
/** "fetched for <Mind>" under a file: the Minds whose verified receipts are in the room's timeline. Nothing when none. */
|
|
||||||
export function FileReadBy({ room, fileId, content, also = [] }: FileReadByProps) {
|
|
||||||
const seen = useFileReaders(room, fileId, content);
|
|
||||||
const readers = [...new Map([...also, ...seen].map((r) => [r.sender, r])).values()].sort(
|
|
||||||
(a, b) => a.ts - b.ts
|
|
||||||
);
|
|
||||||
if (readers.length === 0) return null;
|
|
||||||
return (
|
|
||||||
<Text size="T200" className={css.Dim}>
|
|
||||||
{readLine(readers, (id) => getMemberDisplayName(room, id) ?? id)}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A timeline message that is a file: audio, image, video or file. */
|
|
||||||
export const isFileMessage = (mEvent: MatrixEvent): boolean =>
|
|
||||||
['m.audio', 'm.image', 'm.video', 'm.file'].includes(mEvent.getContent().msgtype);
|
|
||||||
|
|
@ -1,108 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { renderToStaticMarkup } from 'react-dom/server';
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { ClientConfigProvider } from '../../hooks/useClientConfig';
|
|
||||||
import type { ClientConfig } from '../../hooks/useClientConfig';
|
|
||||||
import { ShareState } from './shareController';
|
|
||||||
import { ShareSetView, shareEnabled, useShareEnabled } from './ShareSet';
|
|
||||||
|
|
||||||
const text = (html: string) =>
|
|
||||||
html
|
|
||||||
.replace(/<[^>]*>/g, ' ')
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.trim();
|
|
||||||
const noop = () => undefined;
|
|
||||||
const file = (name: string) => ({ name, type: 'audio/ogg' } as File);
|
|
||||||
|
|
||||||
const view = (over: Partial<ShareState<File>> = {}) =>
|
|
||||||
renderToStaticMarkup(
|
|
||||||
<ShareSetView
|
|
||||||
state={{ files: [], title: '', phase: { kind: 'idle' }, epoch: 0, ...over }}
|
|
||||||
onPickClick={noop}
|
|
||||||
onPicked={noop}
|
|
||||||
onTitle={noop}
|
|
||||||
onSend={noop}
|
|
||||||
onCancel={noop}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
describe('the share panel, drawn', () => {
|
|
||||||
it('idle: one "Share files" row and no title, names, Send or Cancel', () => {
|
|
||||||
const html = view();
|
|
||||||
expect(text(html)).toBe('Share files ›');
|
|
||||||
expect(html).not.toContain('aria-label="Album title"');
|
|
||||||
expect(html).toContain('type="file"');
|
|
||||||
expect(html).toContain('multiple');
|
|
||||||
expect(html).toContain('hidden');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('picked: the title field, the names on one line, Send N files and Cancel; no pick row', () => {
|
|
||||||
const html = view({ files: [file('a.ogg'), file('b.ogg')] });
|
|
||||||
expect(html).toContain('aria-label="Album title"');
|
|
||||||
expect(html).toContain('placeholder="Shared files"');
|
|
||||||
expect(text(html)).toBe('a.ogg · b.ogg Send 2 files Cancel');
|
|
||||||
expect(text(html)).not.toContain('Share files');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('one picked file reads "Send 1 file"', () => {
|
|
||||||
expect(text(view({ files: [file('a.ogg')] }))).toContain('Send 1 file Cancel');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sending: progress replaces Send and Cancel, and the title is locked', () => {
|
|
||||||
const html = view({
|
|
||||||
files: [file('a'), file('b'), file('c')],
|
|
||||||
phase: { kind: 'sending', done: 2 },
|
|
||||||
});
|
|
||||||
expect(text(html)).toContain('Sending 2 of 3…');
|
|
||||||
expect(text(html)).not.toContain('Send 3 files');
|
|
||||||
expect(text(html)).not.toContain('Cancel');
|
|
||||||
expect(html).toMatch(
|
|
||||||
/aria-label="Album title"[^>]*disabled|disabled[^>]*aria-label="Album title"/
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('the encrypted note shows above the kept pick', () => {
|
|
||||||
const html = view({
|
|
||||||
files: [file('a.ogg')],
|
|
||||||
phase: { kind: 'done', text: 'Not shared: this room is encrypted.' },
|
|
||||||
});
|
|
||||||
expect(text(html)).toBe('Not shared: this room is encrypted. a.ogg Send 1 file Cancel');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('after a full success the idle row and the note show together', () => {
|
|
||||||
expect(text(view({ phase: { kind: 'done', text: 'Shared 2 files.' } }))).toBe(
|
|
||||||
'Share files › Shared 2 files.'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('the flag', () => {
|
|
||||||
it('off, missing or empty offers no share', () => {
|
|
||||||
expect(shareEnabled({}, false)).toBe(false);
|
|
||||||
expect(shareEnabled({ fileZoneShare: {} }, false)).toBe(false);
|
|
||||||
expect(shareEnabled({ fileZoneShare: { enabled: false } }, false)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('on offers it, except in an encrypted room', () => {
|
|
||||||
expect(shareEnabled({ fileZoneShare: { enabled: true } }, false)).toBe(true);
|
|
||||||
expect(shareEnabled({ fileZoneShare: { enabled: true } }, true)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('the hook reads the client config and the room, as AlbumView mounts it', () => {
|
|
||||||
const room = (encrypted: boolean) => ({ hasEncryptionStateEvent: () => encrypted } as never);
|
|
||||||
function Probe({ encrypted }: { encrypted: boolean }) {
|
|
||||||
return <span>{String(useShareEnabled(room(encrypted)))}</span>;
|
|
||||||
}
|
|
||||||
const probe = (config: ClientConfig, encrypted: boolean) =>
|
|
||||||
text(
|
|
||||||
renderToStaticMarkup(
|
|
||||||
<ClientConfigProvider value={config}>
|
|
||||||
<Probe encrypted={encrypted} />
|
|
||||||
</ClientConfigProvider>
|
|
||||||
)
|
|
||||||
);
|
|
||||||
expect(probe({}, false)).toBe('false');
|
|
||||||
expect(probe({ fileZoneShare: { enabled: true } }, false)).toBe('true');
|
|
||||||
expect(probe({ fileZoneShare: { enabled: true } }, true)).toBe('false');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,160 +0,0 @@
|
||||||
import React, { useRef, useState, useSyncExternalStore } from 'react';
|
|
||||||
import { Text } from 'folds';
|
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { Room } from 'matrix-js-sdk';
|
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|
||||||
import { useClientConfig } from '../../hooks/useClientConfig';
|
|
||||||
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 * as css from './AlbumView.css';
|
|
||||||
|
|
||||||
/** Off unless `fileZoneShare.enabled` is true. An encrypted zone is never offered the share. */
|
|
||||||
export const shareEnabled = (
|
|
||||||
config: { fileZoneShare?: { enabled?: boolean } },
|
|
||||||
encrypted: boolean
|
|
||||||
) => !!config.fileZoneShare?.enabled && !encrypted;
|
|
||||||
|
|
||||||
export const useShareEnabled = (room: Room): boolean =>
|
|
||||||
shareEnabled(useClientConfig(), room.hasEncryptionStateEvent());
|
|
||||||
|
|
||||||
type ShareViewProps = {
|
|
||||||
state: ShareState<File>;
|
|
||||||
onPickClick: () => void;
|
|
||||||
onPicked: (files: File[]) => void;
|
|
||||||
onTitle: (title: string) => void;
|
|
||||||
onSend: () => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
inputRef?: React.Ref<HTMLInputElement>;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One row when idle. Picking files (any number, one picker) opens the panel: a title, the picked
|
|
||||||
* names, Send. Nothing but the row is visible until files are picked.
|
|
||||||
*/
|
|
||||||
export function ShareSetView({
|
|
||||||
state,
|
|
||||||
onPickClick,
|
|
||||||
onPicked,
|
|
||||||
onTitle,
|
|
||||||
onSend,
|
|
||||||
onCancel,
|
|
||||||
inputRef,
|
|
||||||
}: ShareViewProps) {
|
|
||||||
const { files, title, phase, epoch } = state;
|
|
||||||
return (
|
|
||||||
<div className={css.SharePanel}>
|
|
||||||
<input
|
|
||||||
key={epoch}
|
|
||||||
ref={inputRef}
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
hidden
|
|
||||||
onChange={(e) => onPicked(Array.from(e.target.files ?? []))}
|
|
||||||
/>
|
|
||||||
{files.length === 0 && phase.kind !== 'sending' && (
|
|
||||||
<button type="button" className={css.Row} onClick={onPickClick}>
|
|
||||||
<Text size="B400">Share files ›</Text>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{phase.kind === 'done' && (
|
|
||||||
<Text size="T200" className={css.ShareNames}>
|
|
||||||
{phase.text}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{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)}
|
|
||||||
/>
|
|
||||||
<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>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<button type="button" className={css.Row} onClick={onSend}>
|
|
||||||
<Text size="B400">
|
|
||||||
Send {files.length} {files.length === 1 ? 'file' : 'files'}
|
|
||||||
</Text>
|
|
||||||
</button>
|
|
||||||
<button type="button" className={css.Row} onClick={onCancel}>
|
|
||||||
<Text size="B400">Cancel</Text>
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ShareSet({ room }: { room: Room }) {
|
|
||||||
const mx = useMatrixClient();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
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 [controller]: [ShareController<File>, unknown] = useState(() =>
|
|
||||||
createShareController<File>({
|
|
||||||
roomId: room.roomId,
|
|
||||||
isEncrypted: () => live.current.room.hasEncryptionStateEvent(),
|
|
||||||
onSent: () =>
|
|
||||||
live.current.queryClient.invalidateQueries({ queryKey: ['albums', room.roomId] }),
|
|
||||||
run: (files, title, onFile) =>
|
|
||||||
sendShareSet(
|
|
||||||
{
|
|
||||||
send: async (content) =>
|
|
||||||
(await live.current.mx.sendMessage(room.roomId, content as any)).event_id,
|
|
||||||
contentOf: async (f) => {
|
|
||||||
const file = safeFile(f.file);
|
|
||||||
const mxc = await new Promise<string>((resolve, reject) => {
|
|
||||||
uploadContent(live.current.mx, file, { onSuccess: resolve, onError: reject });
|
|
||||||
});
|
|
||||||
const content = await getMsgContent(
|
|
||||||
live.current.mx,
|
|
||||||
{
|
|
||||||
file,
|
|
||||||
originalFile: f.file,
|
|
||||||
metadata: { markedAsSpoiler: false },
|
|
||||||
encInfo: undefined,
|
|
||||||
},
|
|
||||||
mxc
|
|
||||||
);
|
|
||||||
const sha256 = await hashForSend(f.file);
|
|
||||||
if (sha256) content.info = { ...content.info, [FILE_HASH_KEY]: sha256 };
|
|
||||||
return content;
|
|
||||||
},
|
|
||||||
onFile,
|
|
||||||
},
|
|
||||||
title,
|
|
||||||
files.map((file) => ({ name: file.name, mimeType: file.type, file }))
|
|
||||||
),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
const state = useSyncExternalStore(controller.subscribe, controller.getState);
|
|
||||||
return (
|
|
||||||
<ShareSetView
|
|
||||||
state={state}
|
|
||||||
inputRef={input}
|
|
||||||
onPickClick={() => input.current?.click()}
|
|
||||||
onPicked={controller.pick}
|
|
||||||
onTitle={controller.setTitle}
|
|
||||||
onSend={controller.send}
|
|
||||||
onCancel={controller.cancel}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -214,87 +214,3 @@ describe('buildAlbums', () => {
|
||||||
expect(one(root('$q', 1), [threaded]).count).toBe(1);
|
expect(one(root('$q', 1), [threaded]).count).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Mind receipts', () => {
|
|
||||||
const HASH = 'a'.repeat(64);
|
|
||||||
const file = (id: string, sha?: string) =>
|
|
||||||
reply(id, 10, 'm.audio', id, { info: sha ? { sha256: sha } : {} });
|
|
||||||
const receipt = (
|
|
||||||
id: string,
|
|
||||||
ts: number,
|
|
||||||
sender: string,
|
|
||||||
fileId: unknown,
|
|
||||||
sha?: unknown,
|
|
||||||
extra: Record<string, unknown> = {}
|
|
||||||
) =>
|
|
||||||
({
|
|
||||||
event_id: id,
|
|
||||||
type: 'org.gamecult.file_received',
|
|
||||||
sender,
|
|
||||||
origin_server_ts: ts,
|
|
||||||
content: { 'org.gamecult.file_received': { event_id: fileId, sha256: sha, ...extra } },
|
|
||||||
} as ZoneEvent);
|
|
||||||
|
|
||||||
const oneX = (r: ZoneEvent, replies: ZoneEvent[]) => buildAlbums([{ root: r, replies }], 'x')[0];
|
|
||||||
|
|
||||||
it('shows who read a file when the receipt carries the file event hash', () => {
|
|
||||||
const a = oneX(root('$r', 1), [file('$f', HASH), receipt('$x', 20, '@void:x', '$f', HASH)]);
|
|
||||||
expect(a.files[0].readBy).toEqual([{ sender: '@void:x', ts: 20 }]);
|
|
||||||
expect(a.count).toBe(1);
|
|
||||||
});
|
|
||||||
it('counts nothing for a hash that differs, a missing hash on either side, or a bad hash', () => {
|
|
||||||
const a = oneX(root('$r', 1), [
|
|
||||||
file('$f', HASH),
|
|
||||||
file('$g'),
|
|
||||||
receipt('$x', 20, '@void:x', '$f', 'b'.repeat(64)),
|
|
||||||
receipt('$y', 21, '@void:x', '$g', HASH),
|
|
||||||
receipt('$z', 22, '@void:x', '$f', undefined),
|
|
||||||
receipt('$w', 23, '@void:x', '$f', 7),
|
|
||||||
]);
|
|
||||||
expect(a.files.every((f) => f.readBy.length === 0)).toBe(true);
|
|
||||||
});
|
|
||||||
it('trusts the event sender, never a Mind field inside the content', () => {
|
|
||||||
const a = oneX(root('$r', 1), [
|
|
||||||
file('$f', HASH),
|
|
||||||
receipt('$x', 20, '@mallory:x', '$f', HASH, { mind: 'void', sender: '@void:x' }),
|
|
||||||
]);
|
|
||||||
expect(a.files[0].readBy).toEqual([]);
|
|
||||||
});
|
|
||||||
it('counts only a Mind of this homeserver: not another server, a lookalike, or a bare name', () => {
|
|
||||||
const a = oneX(root('$r', 1), [
|
|
||||||
file('$f', HASH),
|
|
||||||
receipt('$x', 20, '@void-fan:x', '$f', HASH),
|
|
||||||
receipt('$y', 21, '@x:void', '$f', HASH),
|
|
||||||
receipt('$z', 22, '@aqua:x', '$f', HASH),
|
|
||||||
receipt('$e', 23, '@void:evil.example', '$f', HASH),
|
|
||||||
receipt('$b', 24, 'void:x', '$f', HASH),
|
|
||||||
]);
|
|
||||||
expect(a.files[0].readBy.map((r) => r.sender)).toEqual(['@aqua:x']);
|
|
||||||
});
|
|
||||||
it('keeps one receipt per Mind, the latest, and a second Mind beside it', () => {
|
|
||||||
const a = oneX(root('$r', 1), [
|
|
||||||
file('$f', HASH),
|
|
||||||
receipt('$1', 20, '@void:x', '$f', HASH),
|
|
||||||
receipt('$2', 30, '@void:x', '$f', HASH),
|
|
||||||
receipt('$3', 25, '@eureka:x', '$f', HASH),
|
|
||||||
]);
|
|
||||||
expect(a.files[0].readBy).toEqual([
|
|
||||||
{ sender: '@eureka:x', ts: 25 },
|
|
||||||
{ sender: '@void:x', ts: 30 },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
it('ignores a receipt for a file not in the album, a malformed one, and a redacted one', () => {
|
|
||||||
const redacted = {
|
|
||||||
...receipt('$d', 20, '@void:x', '$f', HASH),
|
|
||||||
unsigned: { redacted_because: {} },
|
|
||||||
};
|
|
||||||
const a = oneX(root('$r', 1), [
|
|
||||||
file('$f', HASH),
|
|
||||||
receipt('$a', 20, '@void:x', '$elsewhere', HASH),
|
|
||||||
receipt('$b', 21, '@void:x', 42, HASH),
|
|
||||||
redacted,
|
|
||||||
]);
|
|
||||||
expect(a.files[0].readBy).toEqual([]);
|
|
||||||
expect(a.count).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
// Pure: turns the thread roots of a file-zone room and each root's thread replies, in the JSON
|
// Pure: turns the thread roots of a file-zone room and each root's thread replies, in the JSON
|
||||||
// Synapse returns from GET /rooms/{roomId}/threads and GET /rooms/{roomId}/relations/{root}/m.thread,
|
// Synapse returns from GET /rooms/{roomId}/threads and GET /rooms/{roomId}/relations/{root}/m.thread,
|
||||||
// into albums. See docs/file-zone.md for the rulings this encodes.
|
// into albums. See docs/file-zone.md for the rulings this encodes.
|
||||||
import { readersOf } from './fileReceipt';
|
|
||||||
import type { FileReader } from './fileReceipt';
|
|
||||||
|
|
||||||
/** The slice of a Matrix event this module reads. Every field may be missing. */
|
/** The slice of a Matrix event this module reads. Every field may be missing. */
|
||||||
export type ZoneEvent = {
|
export type ZoneEvent = {
|
||||||
|
|
@ -25,8 +23,6 @@ export type AlbumFile = {
|
||||||
ts: number;
|
ts: number;
|
||||||
/** The event content, as RenderMessageContent reads it. */
|
/** The event content, as RenderMessageContent reads it. */
|
||||||
content: Record<string, unknown>;
|
content: Record<string, unknown>;
|
||||||
/** Receipts in this album that name this file, one per sender (the latest), oldest first. */
|
|
||||||
readBy: FileReader[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Album = {
|
export type Album = {
|
||||||
|
|
@ -74,7 +70,6 @@ const toFile = (e: ZoneEvent): AlbumFile | undefined => {
|
||||||
name: asString(e.content.filename) || asString(e.content.body),
|
name: asString(e.content.filename) || asString(e.content.body),
|
||||||
ts: tsOf(e.origin_server_ts),
|
ts: tsOf(e.origin_server_ts),
|
||||||
content: e.content,
|
content: e.content,
|
||||||
readBy: [],
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -89,20 +84,18 @@ const titleOf = (root: ZoneEvent & { content: Record<string, unknown> }): string
|
||||||
return asString(edited) || asString(root.content.body) || UNTITLED;
|
return asString(edited) || asString(root.content.body) || UNTITLED;
|
||||||
};
|
};
|
||||||
|
|
||||||
const toAlbum = (input: AlbumInput, server: string): Album | undefined => {
|
const toAlbum = (input: AlbumInput): Album | undefined => {
|
||||||
const { root, replies } = input ?? {};
|
const { root, replies } = input ?? {};
|
||||||
if (!live(root)) return undefined;
|
if (!live(root)) return undefined;
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const list = Array.isArray(replies) ? replies : [];
|
const files = (Array.isArray(replies) ? replies : [])
|
||||||
const files = list
|
|
||||||
.map(toFile)
|
.map(toFile)
|
||||||
.filter((f): f is AlbumFile => {
|
.filter((f): f is AlbumFile => {
|
||||||
if (!f || seen.has(f.id)) return false;
|
if (!f || seen.has(f.id)) return false;
|
||||||
seen.add(f.id);
|
seen.add(f.id);
|
||||||
return true;
|
return true;
|
||||||
})
|
})
|
||||||
.sort((a, b) => a.ts - b.ts)
|
.sort((a, b) => a.ts - b.ts);
|
||||||
.map((f) => ({ ...f, readBy: readersOf(list, f.id, f.content, server) }));
|
|
||||||
return {
|
return {
|
||||||
id: root.event_id,
|
id: root.event_id,
|
||||||
title: titleOf(root),
|
title: titleOf(root),
|
||||||
|
|
@ -115,11 +108,10 @@ const toAlbum = (input: AlbumInput, server: string): Album | undefined => {
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Newest album first; files oldest first. Loose room messages never reach this function. */
|
/** Newest album first; files oldest first. Loose room messages never reach this function. */
|
||||||
/** `server` is the homeserver's own domain: only its Minds' receipts count. */
|
export const buildAlbums = (inputs: AlbumInput[]): Album[] => {
|
||||||
export const buildAlbums = (inputs: AlbumInput[], server = ''): Album[] => {
|
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
return inputs
|
return inputs
|
||||||
.map((i) => toAlbum(i, server))
|
.map(toAlbum)
|
||||||
.filter((a): a is Album => {
|
.filter((a): a is Album => {
|
||||||
if (!a || seen.has(a.id)) return false;
|
if (!a || seen.has(a.id)) return false;
|
||||||
seen.add(a.id);
|
seen.add(a.id);
|
||||||
|
|
|
||||||
|
|
@ -1,165 +0,0 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { ReceiptEvent, hashForSend, readLine, readersOf, sha256Hex } from './fileReceipt';
|
|
||||||
|
|
||||||
describe('sha256Hex', () => {
|
|
||||||
it('hashes the bytes: the FIPS 180 vector for "abc"', async () => {
|
|
||||||
expect(await sha256Hex(new Blob(['abc']))).toBe(
|
|
||||||
'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
it('hashes empty input', async () => {
|
|
||||||
expect(await sha256Hex(new Blob([]))).toBe(
|
|
||||||
'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('hashForSend', () => {
|
|
||||||
it('gives no hash, and does not throw, when the blob cannot be read', async () => {
|
|
||||||
const broken = { arrayBuffer: () => Promise.reject(new Error('gone')) } as unknown as Blob;
|
|
||||||
expect(await hashForSend(broken)).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('readLine', () => {
|
|
||||||
it('names each reader', () => {
|
|
||||||
const name = (s: string) => s.slice(1, s.indexOf(':'));
|
|
||||||
expect(
|
|
||||||
readLine(
|
|
||||||
[
|
|
||||||
{ sender: '@void:x', ts: 1 },
|
|
||||||
{ sender: '@eureka:x', ts: 2 },
|
|
||||||
],
|
|
||||||
name
|
|
||||||
)
|
|
||||||
).toBe('fetched for void, eureka');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('readersOf: receipts anywhere in the room, thread reply or plain event', () => {
|
|
||||||
const HASH = 'a'.repeat(64);
|
|
||||||
const file = { info: { sha256: HASH } };
|
|
||||||
const receipt = (
|
|
||||||
id: string,
|
|
||||||
ts: number,
|
|
||||||
sender: string,
|
|
||||||
sha: unknown | undefined,
|
|
||||||
fileId: string | undefined,
|
|
||||||
relates?: object
|
|
||||||
): ReceiptEvent => ({
|
|
||||||
event_id: id,
|
|
||||||
type: 'org.gamecult.file_received',
|
|
||||||
sender,
|
|
||||||
origin_server_ts: ts,
|
|
||||||
content: {
|
|
||||||
...(relates ? { 'm.relates_to': relates } : {}),
|
|
||||||
'org.gamecult.file_received': {
|
|
||||||
event_id: fileId ?? '$f',
|
|
||||||
sha256: sha === undefined ? HASH : sha,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const readers = (events: ReceiptEvent[], content: Record<string, unknown> | undefined) =>
|
|
||||||
readersOf(events, '$f', content, 'gamecult.org').map((r) => r.sender);
|
|
||||||
|
|
||||||
it('counts a threadless receipt, as a composer file with no album root gets', () => {
|
|
||||||
expect(readers([receipt('$1', 5, '@void:gamecult.org', undefined, undefined)], file)).toEqual([
|
|
||||||
'@void:gamecult.org',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
it('counts a thread reply the same way', () => {
|
|
||||||
const t = { rel_type: 'm.thread', event_id: '$root' };
|
|
||||||
expect(readers([receipt('$1', 5, '@eureka:gamecult.org', HASH, '$f', t)], file)).toEqual([
|
|
||||||
'@eureka:gamecult.org',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
it('refuses lookalike localparts and foreign servers', () => {
|
|
||||||
expect(
|
|
||||||
readers(
|
|
||||||
[
|
|
||||||
receipt('$1', 1, '@void2:gamecult.org', undefined, undefined),
|
|
||||||
receipt('$2', 2, '@voidd:gamecult.org', undefined, undefined),
|
|
||||||
receipt('$3', 3, '@Void:gamecult.org', undefined, undefined),
|
|
||||||
receipt('$4', 4, '@void:evil.example', undefined, undefined),
|
|
||||||
receipt('$5', 5, '@void:gamecult.org.evil.example', undefined, undefined),
|
|
||||||
receipt('$6', 6, '@mallory:gamecult.org', undefined, undefined),
|
|
||||||
],
|
|
||||||
file
|
|
||||||
)
|
|
||||||
).toEqual([]);
|
|
||||||
});
|
|
||||||
it('shows nothing for a mismatched hash, a file with no hash, or another file', () => {
|
|
||||||
expect(
|
|
||||||
readers([receipt('$1', 1, '@void:gamecult.org', 'b'.repeat(64), undefined)], file)
|
|
||||||
).toEqual([]);
|
|
||||||
expect(
|
|
||||||
readers([receipt('$1', 1, '@void:gamecult.org', undefined, undefined)], { info: {} })
|
|
||||||
).toEqual([]);
|
|
||||||
expect(
|
|
||||||
readers([receipt('$1', 1, '@void:gamecult.org', undefined, undefined)], undefined)
|
|
||||||
).toEqual([]);
|
|
||||||
expect(readers([receipt('$1', 1, '@void:gamecult.org', HASH, '$other')], file)).toEqual([]);
|
|
||||||
});
|
|
||||||
it('keeps one entry per Mind, the latest, oldest first', () => {
|
|
||||||
expect(
|
|
||||||
readersOf(
|
|
||||||
[
|
|
||||||
receipt('$1', 30, '@void:gamecult.org', undefined, undefined),
|
|
||||||
receipt('$2', 10, '@void:gamecult.org', undefined, undefined),
|
|
||||||
receipt('$3', 20, '@aqua:gamecult.org', undefined, undefined),
|
|
||||||
],
|
|
||||||
'$f',
|
|
||||||
file,
|
|
||||||
'gamecult.org'
|
|
||||||
)
|
|
||||||
).toEqual([
|
|
||||||
{ sender: '@aqua:gamecult.org', ts: 20 },
|
|
||||||
{ sender: '@void:gamecult.org', ts: 30 },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
it('ignores a redacted receipt and a malformed one', () => {
|
|
||||||
const redacted = {
|
|
||||||
...receipt('$1', 1, '@void:gamecult.org', undefined, undefined),
|
|
||||||
unsigned: { redacted_because: {} },
|
|
||||||
};
|
|
||||||
expect(
|
|
||||||
readers(
|
|
||||||
[
|
|
||||||
redacted,
|
|
||||||
receipt('$2', 2, '@void:gamecult.org', 7, undefined),
|
|
||||||
{
|
|
||||||
...receipt('$3', 3, '@void:gamecult.org', undefined, undefined),
|
|
||||||
type: 'm.room.message',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
file
|
|
||||||
)
|
|
||||||
).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('an m.reference to the file', () => {
|
|
||||||
const ref = (id: string) => ({ rel_type: 'm.reference', event_id: id });
|
|
||||||
it('counts when it names the same event as the content', () => {
|
|
||||||
expect(
|
|
||||||
readers([receipt('$1', 1, '@void:gamecult.org', HASH, '$f', ref('$f'))], file)
|
|
||||||
).toEqual(['@void:gamecult.org']);
|
|
||||||
});
|
|
||||||
it('counts for nothing when it names another event', () => {
|
|
||||||
expect(
|
|
||||||
readers([receipt('$1', 1, '@void:gamecult.org', HASH, '$f', ref('$other'))], file)
|
|
||||||
).toEqual([]);
|
|
||||||
});
|
|
||||||
it('draws one entry per Mind however many receipts exist', () => {
|
|
||||||
expect(
|
|
||||||
readers(
|
|
||||||
[
|
|
||||||
receipt('$1', 1, '@void:gamecult.org', HASH, '$f', ref('$f')),
|
|
||||||
receipt('$2', 2, '@void:gamecult.org', HASH, '$f'),
|
|
||||||
receipt('$3', 3, '@void:gamecult.org', HASH, '$f', ref('$f')),
|
|
||||||
],
|
|
||||||
file
|
|
||||||
)
|
|
||||||
).toEqual(['@void:gamecult.org']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
// The file-zone Mind contract (docs/file-zone-mind-contract.md): the plaintext hash a sender writes
|
|
||||||
// into a file event, and the receipt a Mind answers with.
|
|
||||||
|
|
||||||
import { MINDS } from '../../components/message/pose/poses';
|
|
||||||
|
|
||||||
/** A Mind's verified receipt for one file: its sender, who the Matrix server says posted it. */
|
|
||||||
export type FileReader = { sender: string; ts: number };
|
|
||||||
|
|
||||||
/** The slice of a Matrix event a receipt is read from. Every field may be missing. */
|
|
||||||
export type ReceiptEvent = {
|
|
||||||
event_id?: string;
|
|
||||||
type?: string;
|
|
||||||
sender?: string;
|
|
||||||
origin_server_ts?: number;
|
|
||||||
content?: Record<string, unknown>;
|
|
||||||
unsigned?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Hex sha256 of the plaintext bytes, written at `content.info[FILE_HASH_KEY]` in unencrypted rooms. */
|
|
||||||
export const FILE_HASH_KEY = 'sha256';
|
|
||||||
|
|
||||||
/** A Mind's receipt: event type, and the content key holding { event_id, sha256, size }. Inert: nothing in it is fetched or trusted but the sender and the hash. */
|
|
||||||
export const FILE_RECEIVED = 'org.gamecult.file_received';
|
|
||||||
|
|
||||||
export const sha256Hex = async (data: Blob): Promise<string> => {
|
|
||||||
const digest = await crypto.subtle.digest('SHA-256', await data.arrayBuffer());
|
|
||||||
return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('');
|
|
||||||
};
|
|
||||||
|
|
||||||
/** The hash to write for a file about to be sent in an unencrypted room; none when it cannot be computed. */
|
|
||||||
export const hashForSend = async (data: Blob): Promise<string | undefined> => {
|
|
||||||
try {
|
|
||||||
return await sha256Hex(data);
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const readLine = (readBy: FileReader[], name: (sender: string) => string): string =>
|
|
||||||
`fetched for ${readBy.map((r) => name(r.sender)).join(', ')}`;
|
|
||||||
|
|
||||||
const receiptOf = (e: ReceiptEvent) => {
|
|
||||||
if (!e || typeof e !== 'object' || e.type !== FILE_RECEIVED || !e.event_id) return undefined;
|
|
||||||
if (e.unsigned?.redacted_because) return undefined;
|
|
||||||
const body = e.content?.[FILE_RECEIVED] as { event_id?: unknown; sha256?: unknown } | undefined;
|
|
||||||
if (typeof body?.event_id !== 'string' || typeof body.sha256 !== 'string') return undefined;
|
|
||||||
if (typeof e.sender !== 'string') return undefined;
|
|
||||||
// A reference to the file (Eureka's dedup, via /relations) must name the same event as the content, or the
|
|
||||||
// receipt counts for nothing. Matching is by content.event_id; other relations (a thread) are not read.
|
|
||||||
const rel = e.content?.['m.relates_to'] as { rel_type?: unknown; event_id?: unknown } | undefined;
|
|
||||||
if (rel?.rel_type === 'm.reference' && rel.event_id !== body.event_id) return undefined;
|
|
||||||
const ts = e.origin_server_ts;
|
|
||||||
return {
|
|
||||||
file: body.event_id,
|
|
||||||
sender: e.sender,
|
|
||||||
ts: typeof ts === 'number' && Number.isFinite(ts) ? ts : 0,
|
|
||||||
sha256: body.sha256,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Only the event's sender counts, never a name inside the content, and only a Mind's own Matrix
|
|
||||||
// identity on this homeserver (`@<mind>:<server>`, a mind named in the Persona catalogue).
|
|
||||||
const isMind = (sender: string, server: string) => MINDS.some((m) => sender === `@${m}:${server}`);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Who has read the file `fileId`, from receipts found anywhere in `events` (an album's thread replies, or the room's
|
|
||||||
* timeline: a receipt is a thread reply when the file has an album root, otherwise a plain room event; both count).
|
|
||||||
* "Read" means verified: the receipt's sha256 must equal the file event's `info.sha256`, so a file with no hash, or a
|
|
||||||
* mismatch, shows nothing. One entry per Mind (its latest), oldest first.
|
|
||||||
*/
|
|
||||||
export const readersOf = (
|
|
||||||
events: ReceiptEvent[],
|
|
||||||
fileId: string,
|
|
||||||
fileContent: Record<string, unknown> | undefined,
|
|
||||||
server: string
|
|
||||||
): FileReader[] => {
|
|
||||||
const sent = (fileContent?.info as Record<string, unknown> | undefined)?.[FILE_HASH_KEY];
|
|
||||||
if (typeof sent !== 'string') return [];
|
|
||||||
const latest = new Map<string, FileReader>();
|
|
||||||
events
|
|
||||||
.map(receiptOf)
|
|
||||||
.filter(
|
|
||||||
(r): r is NonNullable<typeof r> =>
|
|
||||||
!!r && r.file === fileId && r.sha256 === sent && isMind(r.sender, server)
|
|
||||||
)
|
|
||||||
.sort((a, b) => a.ts - b.ts)
|
|
||||||
.forEach((r) => latest.set(r.sender, { sender: r.sender, ts: r.ts }));
|
|
||||||
return [...latest.values()].sort((a, b) => a.ts - b.ts);
|
|
||||||
};
|
|
||||||
|
|
@ -1,116 +0,0 @@
|
||||||
import { EventEmitter } from 'events';
|
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
import { MatrixEvent, Room, RoomEvent } from 'matrix-js-sdk';
|
|
||||||
import { receiptStoreOf } from './receiptStore';
|
|
||||||
import { readersOf } from './fileReceipt';
|
|
||||||
|
|
||||||
const HASH = 'a'.repeat(64);
|
|
||||||
|
|
||||||
type Fake = {
|
|
||||||
id: string;
|
|
||||||
type: string;
|
|
||||||
sender: string;
|
|
||||||
ts: number;
|
|
||||||
content: object;
|
|
||||||
redacted?: boolean;
|
|
||||||
};
|
|
||||||
const event = (f: Fake) =>
|
|
||||||
({
|
|
||||||
getId: () => f.id,
|
|
||||||
getType: () => f.type,
|
|
||||||
getSender: () => f.sender,
|
|
||||||
getTs: () => f.ts,
|
|
||||||
getContent: () => f.content,
|
|
||||||
isRedacted: () => !!f.redacted,
|
|
||||||
} as unknown as MatrixEvent);
|
|
||||||
const receipt = (id: string, sender: string, fileId = '$f', over: Partial<Fake> = {}): Fake => ({
|
|
||||||
id,
|
|
||||||
type: 'org.gamecult.file_received',
|
|
||||||
sender,
|
|
||||||
ts: 1,
|
|
||||||
content: { 'org.gamecult.file_received': { event_id: fileId, sha256: HASH } },
|
|
||||||
...over,
|
|
||||||
});
|
|
||||||
|
|
||||||
const fakeRoom = (initial: Fake[] = []) => {
|
|
||||||
const emitter = new EventEmitter();
|
|
||||||
emitter.setMaxListeners(0);
|
|
||||||
let events = initial;
|
|
||||||
const room = Object.assign(emitter, {
|
|
||||||
getUnfilteredTimelineSet: () => ({
|
|
||||||
getLiveTimeline: () => ({ getEvents: () => events.map(event) }),
|
|
||||||
}),
|
|
||||||
}) as unknown as Room;
|
|
||||||
return {
|
|
||||||
room,
|
|
||||||
emitter,
|
|
||||||
set: (next: Fake[]) => {
|
|
||||||
events = next;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
const listeners = (e: EventEmitter) =>
|
|
||||||
[RoomEvent.Timeline, RoomEvent.Redaction, RoomEvent.TimelineReset].map((n) => e.listenerCount(n));
|
|
||||||
|
|
||||||
describe('receiptStoreOf: one subscription per room', () => {
|
|
||||||
it('registers one listener per event for 200 file rows, and none once they are gone', () => {
|
|
||||||
const { room, emitter } = fakeRoom();
|
|
||||||
const store = receiptStoreOf(room);
|
|
||||||
const off = Array.from({ length: 200 }, () => store.subscribe(() => undefined));
|
|
||||||
expect(listeners(emitter)).toEqual([1, 1, 1]);
|
|
||||||
off.forEach((o) => o());
|
|
||||||
expect(listeners(emitter)).toEqual([0, 0, 0]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is one store per room', () => {
|
|
||||||
const a = fakeRoom();
|
|
||||||
const b = fakeRoom();
|
|
||||||
expect(receiptStoreOf(a.room)).toBe(receiptStoreOf(a.room));
|
|
||||||
expect(receiptStoreOf(a.room)).not.toBe(receiptStoreOf(b.room));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('notifies rows when a receipt arrives, not for unrelated timeline events, and gives readersOf what it needs', () => {
|
|
||||||
const { room, emitter, set } = fakeRoom();
|
|
||||||
const store = receiptStoreOf(room);
|
|
||||||
const cb = vi.fn();
|
|
||||||
store.subscribe(cb);
|
|
||||||
expect(store.getSnapshot()).toEqual([]);
|
|
||||||
set([{ id: '$m', type: 'm.room.message', sender: '@x:gamecult.org', ts: 1, content: {} }]);
|
|
||||||
emitter.emit(RoomEvent.Timeline);
|
|
||||||
expect(cb).not.toHaveBeenCalled();
|
|
||||||
set([receipt('$r', '@void:gamecult.org')]);
|
|
||||||
emitter.emit(RoomEvent.Timeline);
|
|
||||||
expect(cb).toHaveBeenCalledTimes(1);
|
|
||||||
const snap = store.getSnapshot();
|
|
||||||
expect(
|
|
||||||
readersOf(snap, '$f', { info: { sha256: HASH } }, 'gamecult.org').map((r) => r.sender)
|
|
||||||
).toEqual(['@void:gamecult.org']);
|
|
||||||
emitter.emit(RoomEvent.Timeline);
|
|
||||||
expect(store.getSnapshot()).toBe(snap);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rescans on a redaction and on a timeline reset', () => {
|
|
||||||
const { room, emitter, set } = fakeRoom([receipt('$r', '@void:gamecult.org')]);
|
|
||||||
const store = receiptStoreOf(room);
|
|
||||||
const cb = vi.fn();
|
|
||||||
store.subscribe(cb);
|
|
||||||
expect(store.getSnapshot()).toHaveLength(1);
|
|
||||||
set([receipt('$r', '@void:gamecult.org', '$f', { redacted: true })]);
|
|
||||||
emitter.emit(RoomEvent.Redaction);
|
|
||||||
expect(cb).toHaveBeenCalledTimes(1);
|
|
||||||
expect(
|
|
||||||
readersOf(store.getSnapshot(), '$f', { info: { sha256: HASH } }, 'gamecult.org')
|
|
||||||
).toEqual([]);
|
|
||||||
set([]);
|
|
||||||
emitter.emit(RoomEvent.TimelineReset);
|
|
||||||
expect(cb).toHaveBeenCalledTimes(2);
|
|
||||||
expect(store.getSnapshot()).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('picks up what is already loaded when the first row subscribes', () => {
|
|
||||||
const { room } = fakeRoom([receipt('$r', '@aqua:gamecult.org')]);
|
|
||||||
const store = receiptStoreOf(room);
|
|
||||||
store.subscribe(() => undefined);
|
|
||||||
expect(store.getSnapshot()).toHaveLength(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
import type { MatrixEvent, Room } from 'matrix-js-sdk';
|
|
||||||
import { RoomEvent } from 'matrix-js-sdk';
|
|
||||||
import { FILE_RECEIVED, ReceiptEvent } from './fileReceipt';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One subscription per room to the file receipts in its loaded timeline, shared by every file row: a row costs a
|
|
||||||
* callback in a Set, not a Room listener (matrix-js-sdk warns past 100 listeners). The snapshot is the receipt events
|
|
||||||
* themselves; each row derives its own readers from it with `readersOf`, so the rules (sender, hash, reference, one per
|
|
||||||
* Mind) live in one place and the store holds no verdicts. The snapshot changes identity only when the set of receipts
|
|
||||||
* or one's redaction state does, so rows re-render for a receipt, not for every timeline event.
|
|
||||||
*/
|
|
||||||
export type ReceiptStore = {
|
|
||||||
subscribe: (onChange: () => void) => () => void;
|
|
||||||
getSnapshot: () => ReceiptEvent[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const asReceiptEvent = (e: MatrixEvent): ReceiptEvent => ({
|
|
||||||
event_id: e.getId(),
|
|
||||||
type: e.getType(),
|
|
||||||
sender: e.getSender(),
|
|
||||||
origin_server_ts: e.getTs(),
|
|
||||||
content: e.getContent(),
|
|
||||||
unsigned: e.isRedacted() ? { redacted_because: true } : undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const same = (a: ReceiptEvent[], b: ReceiptEvent[]): boolean =>
|
|
||||||
a.length === b.length &&
|
|
||||||
a.every(
|
|
||||||
(r, i) =>
|
|
||||||
r.event_id === b[i].event_id &&
|
|
||||||
!r.unsigned?.redacted_because === !b[i].unsigned?.redacted_because
|
|
||||||
);
|
|
||||||
|
|
||||||
// A redaction removes a tick and a reset drops the window: both rescan, not only a new event.
|
|
||||||
const EVENTS = [RoomEvent.Timeline, RoomEvent.Redaction, RoomEvent.TimelineReset] as const;
|
|
||||||
|
|
||||||
const make = (room: Room): ReceiptStore => {
|
|
||||||
const subscribers = new Set<() => void>();
|
|
||||||
let snapshot: ReceiptEvent[] = [];
|
|
||||||
const read = (): boolean => {
|
|
||||||
const next = room
|
|
||||||
.getUnfilteredTimelineSet()
|
|
||||||
.getLiveTimeline()
|
|
||||||
.getEvents()
|
|
||||||
.filter((e) => e.getType() === FILE_RECEIVED)
|
|
||||||
.map(asReceiptEvent);
|
|
||||||
if (same(snapshot, next)) return false;
|
|
||||||
snapshot = next;
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
const scan = () => {
|
|
||||||
if (read()) subscribers.forEach((cb) => cb());
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
subscribe: (onChange) => {
|
|
||||||
subscribers.add(onChange);
|
|
||||||
if (subscribers.size === 1) {
|
|
||||||
EVENTS.forEach((ev) => room.on(ev, scan));
|
|
||||||
read(); // the first row sees what is already loaded; it reads the snapshot after subscribing
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
subscribers.delete(onChange);
|
|
||||||
if (subscribers.size === 0) EVENTS.forEach((ev) => room.off(ev, scan));
|
|
||||||
};
|
|
||||||
},
|
|
||||||
getSnapshot: () => snapshot,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const stores = new WeakMap<Room, ReceiptStore>();
|
|
||||||
|
|
||||||
export const receiptStoreOf = (room: Room): ReceiptStore => {
|
|
||||||
let store = stores.get(room);
|
|
||||||
if (!store) {
|
|
||||||
store = make(room);
|
|
||||||
stores.set(room, store);
|
|
||||||
}
|
|
||||||
return store;
|
|
||||||
};
|
|
||||||
|
|
@ -1,261 +0,0 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { Picked, ShareDeps, createShareController } from './shareController';
|
|
||||||
import { ShareResult } from './shareSet';
|
|
||||||
|
|
||||||
const file = (name: string): Picked => ({ name, type: 'audio/ogg' });
|
|
||||||
const ok = (n: number): ShareResult => ({ albumId: '$r', sent: n, failed: [], failedAt: [] });
|
|
||||||
|
|
||||||
/** A run the test settles by hand, so "in flight" is a state the test can stand in. */
|
|
||||||
const hand = () => {
|
|
||||||
const calls: { files: Picked[]; title: string; onFile: () => void }[] = [];
|
|
||||||
let release: (r: ShareResult) => void = () => undefined;
|
|
||||||
let fail: (e: Error) => void = () => undefined;
|
|
||||||
const run: ShareDeps<Picked>['run'] = (files, title, onFile) => {
|
|
||||||
calls.push({ files, title, onFile });
|
|
||||||
return new Promise<ShareResult>((resolve, reject) => {
|
|
||||||
release = resolve;
|
|
||||||
fail = reject;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
return { calls, run, release: (r: ShareResult) => release(r), fail: (e: Error) => fail(e) };
|
|
||||||
};
|
|
||||||
|
|
||||||
const make = (over: Partial<ShareDeps<Picked>> = {}, roomId = '!zone:x') => {
|
|
||||||
const h = hand();
|
|
||||||
const sentTo: string[] = [];
|
|
||||||
const c = createShareController<Picked>({
|
|
||||||
roomId,
|
|
||||||
isEncrypted: () => false,
|
|
||||||
run: h.run,
|
|
||||||
onSent: () => sentTo.push(roomId),
|
|
||||||
...over,
|
|
||||||
});
|
|
||||||
return { c, h, sentTo };
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('picking', () => {
|
|
||||||
it('starts idle with nothing picked, and a pick holds the files in order', () => {
|
|
||||||
const { c } = make();
|
|
||||||
expect(c.getState().files).toEqual([]);
|
|
||||||
expect(c.getState().phase).toEqual({ kind: 'idle' });
|
|
||||||
c.pick([file('a'), file('b')]);
|
|
||||||
expect(c.getState().files.map((f) => f.name)).toEqual(['a', 'b']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Cancel empties the pick and the title and bumps the input epoch', () => {
|
|
||||||
const { c } = make();
|
|
||||||
c.pick([file('a')]);
|
|
||||||
c.setTitle('Take 4');
|
|
||||||
const before = c.getState().epoch;
|
|
||||||
c.cancel();
|
|
||||||
expect(c.getState()).toMatchObject({ files: [], title: '', epoch: before + 1 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('subscribers hear every change and stop hearing after unsubscribe', () => {
|
|
||||||
const { c } = make();
|
|
||||||
let heard = 0;
|
|
||||||
const off = c.subscribe(() => {
|
|
||||||
heard += 1;
|
|
||||||
});
|
|
||||||
c.pick([file('a')]);
|
|
||||||
off();
|
|
||||||
c.pick([]);
|
|
||||||
expect(heard).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('the Send guard', () => {
|
|
||||||
it('a double tap starts one send', async () => {
|
|
||||||
const { c, h } = make();
|
|
||||||
c.pick([file('a'), file('b')]);
|
|
||||||
const first = c.send();
|
|
||||||
const second = c.send();
|
|
||||||
expect(h.calls).toHaveLength(1);
|
|
||||||
h.release(ok(2));
|
|
||||||
await Promise.all([first, second]);
|
|
||||||
expect(h.calls).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a re-mounted panel for the same room cannot start a second send while one is in flight', async () => {
|
|
||||||
const { c, h } = make();
|
|
||||||
c.pick([file('a')]);
|
|
||||||
const first = c.send();
|
|
||||||
// The page was left and returned to: a new controller, nothing picked in it but a new pick.
|
|
||||||
const again = make({}, '!zone:x');
|
|
||||||
again.c.pick([file('b')]);
|
|
||||||
await again.c.send();
|
|
||||||
expect(again.h.calls).toHaveLength(0);
|
|
||||||
h.release(ok(1));
|
|
||||||
await first;
|
|
||||||
// Once the first has settled the room is free again.
|
|
||||||
const next = again.c.send();
|
|
||||||
expect(again.h.calls).toHaveLength(1);
|
|
||||||
again.h.release(ok(1));
|
|
||||||
await next;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('another room is not blocked by a send in flight', async () => {
|
|
||||||
const a = make({}, '!a:x');
|
|
||||||
const b = make({}, '!b:x');
|
|
||||||
a.c.pick([file('a')]);
|
|
||||||
b.c.pick([file('b')]);
|
|
||||||
const sendA = a.c.send();
|
|
||||||
const sendB = b.c.send();
|
|
||||||
expect(b.h.calls).toHaveLength(1);
|
|
||||||
a.h.release(ok(1));
|
|
||||||
b.h.release(ok(1));
|
|
||||||
await Promise.all([sendA, sendB]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sends nothing when nothing is picked', async () => {
|
|
||||||
const { c, h } = make();
|
|
||||||
await c.send();
|
|
||||||
expect(h.calls).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a send that throws frees the room and says so', async () => {
|
|
||||||
const { c, h } = make();
|
|
||||||
c.pick([file('a')]);
|
|
||||||
const p = c.send();
|
|
||||||
h.fail(new Error('M_FORBIDDEN'));
|
|
||||||
await p;
|
|
||||||
expect(c.getState().phase).toEqual({ kind: 'done', text: 'Could not share: M_FORBIDDEN' });
|
|
||||||
expect(c.getState().files).toHaveLength(1);
|
|
||||||
const retry = c.send();
|
|
||||||
expect(h.calls).toHaveLength(2);
|
|
||||||
h.release(ok(1));
|
|
||||||
await retry;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('progress and results', () => {
|
|
||||||
it('counts files as they settle, passing the title through', async () => {
|
|
||||||
const { c, h } = make();
|
|
||||||
c.pick([file('a'), file('b')]);
|
|
||||||
c.setTitle('Take 4');
|
|
||||||
const p = c.send();
|
|
||||||
expect(c.getState().phase).toEqual({ kind: 'sending', done: 0 });
|
|
||||||
expect(h.calls[0].title).toBe('Take 4');
|
|
||||||
h.calls[0].onFile();
|
|
||||||
expect(c.getState().phase).toEqual({ kind: 'sending', done: 1 });
|
|
||||||
h.release(ok(2));
|
|
||||||
await p;
|
|
||||||
expect(c.getState().phase).toEqual({ kind: 'done', text: 'Shared 2 files.' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('full success clears the pick, tells the room list, and remounts the input', async () => {
|
|
||||||
const { c, h, sentTo } = make();
|
|
||||||
c.pick([file('a')]);
|
|
||||||
const { epoch } = c.getState();
|
|
||||||
const p = c.send();
|
|
||||||
h.release(ok(1));
|
|
||||||
await p;
|
|
||||||
expect(c.getState()).toMatchObject({ files: [], title: '', epoch: epoch + 1 });
|
|
||||||
expect(sentTo).toEqual(['!zone:x']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('failed files stay picked by position, so a same-named twin that went is not re-sent', async () => {
|
|
||||||
const { c, h } = make();
|
|
||||||
const twins = [file('a.ogg'), file('a.ogg'), file('b.ogg')];
|
|
||||||
c.pick(twins);
|
|
||||||
const p = c.send();
|
|
||||||
h.release({ albumId: '$r', sent: 2, failed: ['a.ogg'], failedAt: [1] });
|
|
||||||
await p;
|
|
||||||
expect(c.getState().files).toEqual([twins[1]]);
|
|
||||||
expect(c.getState().phase).toEqual({ kind: 'done', text: 'Shared 2. Not sent: a.ogg' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('when every upload failed everything stays picked and the note says nothing was shared', async () => {
|
|
||||||
const { c, h } = make();
|
|
||||||
const picked = [file('a'), file('b')];
|
|
||||||
c.pick(picked);
|
|
||||||
const p = c.send();
|
|
||||||
h.release({ albumId: '$r', sent: 0, failed: ['a', 'b'], failedAt: [0, 1] });
|
|
||||||
await p;
|
|
||||||
expect(c.getState().files).toEqual(picked);
|
|
||||||
expect(c.getState().phase).toEqual({
|
|
||||||
kind: 'done',
|
|
||||||
text: 'Nothing was shared. Not sent: a, b',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a new pick after a result drops the result note', async () => {
|
|
||||||
const { c, h } = make();
|
|
||||||
c.pick([file('a')]);
|
|
||||||
const p = c.send();
|
|
||||||
h.release(ok(1));
|
|
||||||
await p;
|
|
||||||
c.pick([file('b')]);
|
|
||||||
expect(c.getState().phase).toEqual({ kind: 'idle' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('what a send leaves behind', () => {
|
|
||||||
it('keeps the title when some files failed, so the retry has the same name', async () => {
|
|
||||||
const { c, h } = make();
|
|
||||||
c.pick([file('a'), file('b')]);
|
|
||||||
c.setTitle('Take 4');
|
|
||||||
const p = c.send();
|
|
||||||
h.release({ albumId: '$r', sent: 1, failed: ['b'], failedAt: [1] });
|
|
||||||
await p;
|
|
||||||
expect(c.getState().title).toBe('Take 4');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('tells the room list only after the send settled, and not when it threw', async () => {
|
|
||||||
const { c, h, sentTo } = make();
|
|
||||||
c.pick([file('a')]);
|
|
||||||
const p = c.send();
|
|
||||||
expect(sentTo).toEqual([]);
|
|
||||||
h.release(ok(1));
|
|
||||||
await p;
|
|
||||||
expect(sentTo).toEqual(['!zone:x']);
|
|
||||||
|
|
||||||
const bad = make();
|
|
||||||
bad.c.pick([file('a')]);
|
|
||||||
const q = bad.c.send();
|
|
||||||
bad.h.fail(new Error('boom'));
|
|
||||||
await q;
|
|
||||||
expect(bad.sentTo).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a success lands in one notification: the pick is never empty while still sending', async () => {
|
|
||||||
const { c, h } = make();
|
|
||||||
c.pick([file('a')]);
|
|
||||||
const p = c.send();
|
|
||||||
const seen: string[] = [];
|
|
||||||
c.subscribe(() => {
|
|
||||||
const st = c.getState();
|
|
||||||
seen.push(`${st.files.length}:${st.phase.kind}`);
|
|
||||||
});
|
|
||||||
h.release(ok(1));
|
|
||||||
await p;
|
|
||||||
expect(seen).toEqual(['0:done']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('an encrypted room', () => {
|
|
||||||
it('says so, sends nothing and keeps the pick', async () => {
|
|
||||||
const { c, h } = make({ isEncrypted: () => true });
|
|
||||||
c.pick([file('a')]);
|
|
||||||
await c.send();
|
|
||||||
expect(c.getState().phase).toEqual({
|
|
||||||
kind: 'done',
|
|
||||||
text: 'Not shared: this room is encrypted.',
|
|
||||||
});
|
|
||||||
expect(c.getState().files).toHaveLength(1);
|
|
||||||
expect(h.calls).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is checked at Send, not at mount: a room that turns encrypted after the pick is refused', async () => {
|
|
||||||
let encrypted = false;
|
|
||||||
const { c, h } = make({ isEncrypted: () => encrypted });
|
|
||||||
c.pick([file('a')]);
|
|
||||||
encrypted = true;
|
|
||||||
await c.send();
|
|
||||||
expect(h.calls).toHaveLength(0);
|
|
||||||
expect(c.getState().phase).toMatchObject({
|
|
||||||
kind: 'done',
|
|
||||||
text: 'Not shared: this room is encrypted.',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
// The share panel's state machine, apart from React and Matrix: what is picked, what phase the
|
|
||||||
// send is in, and the rules that keep a room to one send at a time. ShareSet.tsx draws it.
|
|
||||||
import { ShareResult, remainingAfter, resultNote } from './shareSet';
|
|
||||||
|
|
||||||
export type Picked = { name: string; type: string };
|
|
||||||
|
|
||||||
export type SharePhase =
|
|
||||||
| { kind: 'idle' }
|
|
||||||
| { kind: 'sending'; done: number }
|
|
||||||
| { kind: 'done'; text: string };
|
|
||||||
|
|
||||||
export type ShareState<F extends Picked> = {
|
|
||||||
files: F[];
|
|
||||||
title: string;
|
|
||||||
phase: SharePhase;
|
|
||||||
/** Bumps whenever the pick is emptied, so the file input can be remounted and re-pick the same files. */
|
|
||||||
epoch: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ENCRYPTED_NOTE = 'Not shared: this room is encrypted.';
|
|
||||||
|
|
||||||
export type ShareDeps<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>;
|
|
||||||
/** Called after a send that reached the room, whatever it left unsent. */
|
|
||||||
onSent?: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Rooms with a share in flight. Module-level: leaving the page unmounts the panel, not the send. */
|
|
||||||
const sending = new Set<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>();
|
|
||||||
const set = (patch: Partial<ShareState<F>>) => {
|
|
||||||
state = { ...state, ...patch };
|
|
||||||
listeners.forEach((l) => l());
|
|
||||||
};
|
|
||||||
|
|
||||||
const clear = () => set({ files: [], title: '', epoch: state.epoch + 1 });
|
|
||||||
|
|
||||||
const send = async () => {
|
|
||||||
if (sending.has(deps.roomId) || state.files.length === 0) return;
|
|
||||||
if (deps.isEncrypted()) {
|
|
||||||
set({ phase: { kind: 'done', text: ENCRYPTED_NOTE } });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
sending.add(deps.roomId);
|
|
||||||
const { files, title } = state;
|
|
||||||
set({ phase: { kind: 'sending', done: 0 } });
|
|
||||||
let settled = 0;
|
|
||||||
try {
|
|
||||||
const res = await deps.run(files, title, () => {
|
|
||||||
settled += 1;
|
|
||||||
set({ phase: { kind: 'sending', done: settled } });
|
|
||||||
});
|
|
||||||
deps.onSent?.();
|
|
||||||
const left = remainingAfter(files, res);
|
|
||||||
const phase: SharePhase = { kind: 'done', text: resultNote(res) };
|
|
||||||
// One notification: no state is ever drawn with the pick emptied and the send still running.
|
|
||||||
if (left.length === 0) set({ files: [], title: '', epoch: state.epoch + 1, phase });
|
|
||||||
else set({ files: left, phase });
|
|
||||||
} catch (e: any) {
|
|
||||||
set({ phase: { kind: 'done', text: `Could not share: ${e?.message ?? 'send failed'}` } });
|
|
||||||
} finally {
|
|
||||||
sending.delete(deps.roomId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
getState: () => state,
|
|
||||||
subscribe: (l: () => void) => {
|
|
||||||
listeners.add(l);
|
|
||||||
return () => {
|
|
||||||
listeners.delete(l);
|
|
||||||
};
|
|
||||||
},
|
|
||||||
pick: (files: F[]) => set({ files, phase: { kind: 'idle' } }),
|
|
||||||
setTitle: (title: string) => set({ title }),
|
|
||||||
cancel: clear,
|
|
||||||
send,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ShareController<F extends Picked> = ReturnType<typeof createShareController<F>>;
|
|
||||||
|
|
@ -1,160 +0,0 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import config from '../../../../config.json';
|
|
||||||
import { ZoneEvent, buildAlbums } from './buildAlbums';
|
|
||||||
import {
|
|
||||||
DEFAULT_TITLE,
|
|
||||||
ShareFile,
|
|
||||||
SharePort,
|
|
||||||
remainingAfter,
|
|
||||||
resultNote,
|
|
||||||
sendShareSet,
|
|
||||||
threadRelation,
|
|
||||||
} from './shareSet';
|
|
||||||
|
|
||||||
const kindOf = (f: ShareFile) => (f.mimeType.startsWith('audio') ? 'm.audio' : 'm.file');
|
|
||||||
|
|
||||||
/** A fake room: records what is sent, as the events Synapse would return from /relations. */
|
|
||||||
const room = (failOn: string[] = []) => {
|
|
||||||
const events: ZoneEvent[] = [];
|
|
||||||
const port: SharePort<ShareFile> = {
|
|
||||||
send: async (content) => {
|
|
||||||
const id = `$e${events.length + 1}`;
|
|
||||||
events.push({
|
|
||||||
event_id: id,
|
|
||||||
type: 'm.room.message',
|
|
||||||
sender: '@aqua:chat.gamecult.org',
|
|
||||||
origin_server_ts: 1000 + events.length,
|
|
||||||
content,
|
|
||||||
});
|
|
||||||
return id;
|
|
||||||
},
|
|
||||||
contentOf: async (f) => {
|
|
||||||
if (failOn.includes(f.name)) throw new Error('upload failed');
|
|
||||||
return {
|
|
||||||
msgtype: kindOf(f),
|
|
||||||
body: f.name,
|
|
||||||
filename: f.name,
|
|
||||||
url: `mxc://x/${f.name}`,
|
|
||||||
info: { mimetype: f.mimeType },
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const album = () => {
|
|
||||||
const [root, ...replies] = events;
|
|
||||||
return buildAlbums([{ root, replies }], 'chat.gamecult.org')[0];
|
|
||||||
};
|
|
||||||
return { events, port, album };
|
|
||||||
};
|
|
||||||
|
|
||||||
const clips: ShareFile[] = [
|
|
||||||
{ name: 'a.ogg', mimeType: 'audio/ogg' },
|
|
||||||
{ name: 'b.ogg', mimeType: 'audio/ogg' },
|
|
||||||
{ name: 'c.txt', mimeType: 'text/plain' },
|
|
||||||
];
|
|
||||||
|
|
||||||
describe('sendShareSet', () => {
|
|
||||||
it('reads back as one album of the files in pick order', async () => {
|
|
||||||
const r = room();
|
|
||||||
const res = await sendShareSet(r.port, 'Take 4', clips);
|
|
||||||
expect(res).toEqual({ albumId: '$e1', sent: 3, failed: [], failedAt: [] });
|
|
||||||
const album = r.album();
|
|
||||||
expect(album.title).toBe('Take 4');
|
|
||||||
expect(album.files.map((f) => [f.name, f.kind])).toEqual([
|
|
||||||
['a.ogg', 'audio'],
|
|
||||||
['b.ogg', 'audio'],
|
|
||||||
['c.txt', 'file'],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('threads every file under the root, each falling back to the previous event', async () => {
|
|
||||||
const r = room();
|
|
||||||
await sendShareSet(r.port, 't', clips);
|
|
||||||
const rels = r.events.slice(1).map((e) => e.content?.['m.relates_to']);
|
|
||||||
expect(rels).toEqual([
|
|
||||||
threadRelation('$e1', '$e1'),
|
|
||||||
threadRelation('$e1', '$e2'),
|
|
||||||
threadRelation('$e1', '$e3'),
|
|
||||||
]);
|
|
||||||
expect(r.events[0].content?.['m.relates_to']).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('titles an untitled share', async () => {
|
|
||||||
const r = room();
|
|
||||||
await sendShareSet(r.port, ' ', clips.slice(0, 1));
|
|
||||||
expect(r.album().title).toBe(DEFAULT_TITLE);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('names a failed file and still sends the rest', async () => {
|
|
||||||
const r = room(['b.ogg']);
|
|
||||||
const res = await sendShareSet(r.port, 't', clips);
|
|
||||||
expect(res.failed).toEqual(['b.ogg']);
|
|
||||||
expect(res.sent).toBe(2);
|
|
||||||
expect(r.album().files.map((f) => f.name)).toEqual(['a.ogg', 'c.txt']);
|
|
||||||
// the next file falls back to the last event that exists, not to the failed one
|
|
||||||
expect(r.events[2].content?.['m.relates_to']).toEqual(threadRelation('$e1', '$e2'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws when the root cannot be sent, uploading nothing', async () => {
|
|
||||||
let uploads = 0;
|
|
||||||
const port: SharePort<ShareFile> = {
|
|
||||||
send: async () => {
|
|
||||||
throw new Error('M_FORBIDDEN');
|
|
||||||
},
|
|
||||||
contentOf: async () => {
|
|
||||||
uploads += 1;
|
|
||||||
return {};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
await expect(sendShareSet(port, 't', clips)).rejects.toThrow('M_FORBIDDEN');
|
|
||||||
expect(uploads).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('the share flag', () => {
|
|
||||||
it('ships off', () => {
|
|
||||||
expect(config.fileZoneShare).toEqual({ enabled: false });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('after a send', () => {
|
|
||||||
it('keeps the failed one of two same-named files, not both', async () => {
|
|
||||||
const twins: ShareFile[] = [
|
|
||||||
{ name: 'a.ogg', mimeType: 'audio/ogg' },
|
|
||||||
{ name: 'a.ogg', mimeType: 'audio/ogg' },
|
|
||||||
];
|
|
||||||
let n = 0;
|
|
||||||
const r = room();
|
|
||||||
const port: SharePort<ShareFile> = {
|
|
||||||
...r.port,
|
|
||||||
contentOf: async (f) => {
|
|
||||||
n += 1;
|
|
||||||
if (n === 2) throw new Error('second upload failed');
|
|
||||||
return r.port.contentOf(f);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const res = await sendShareSet(port, 't', twins);
|
|
||||||
expect(res.failedAt).toEqual([1]);
|
|
||||||
expect(remainingAfter(twins, res)).toEqual([twins[1]]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps only the failed files picked, so a retry is one tap', async () => {
|
|
||||||
const r = room(['b.ogg']);
|
|
||||||
const res = await sendShareSet(r.port, 't', clips);
|
|
||||||
expect(remainingAfter(clips, res).map((f) => f.name)).toEqual(['b.ogg']);
|
|
||||||
expect(resultNote(res)).toBe('Shared 2. Not sent: b.ogg');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps everything and says nothing was shared when every upload failed', async () => {
|
|
||||||
const r = room(clips.map((c) => c.name));
|
|
||||||
const res = await sendShareSet(r.port, 't', clips);
|
|
||||||
expect(remainingAfter(clips, res)).toEqual(clips);
|
|
||||||
expect(resultNote(res)).toBe('Nothing was shared. Not sent: a.ogg, b.ogg, c.txt');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('clears the pick on full success', async () => {
|
|
||||||
const r = room();
|
|
||||||
const res = await sendShareSet(r.port, 't', clips);
|
|
||||||
expect(remainingAfter(clips, res)).toEqual([]);
|
|
||||||
expect(resultNote(res)).toBe('Shared 3 files.');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
// Share a set of files at once: one album root (a text message) and one thread reply per file.
|
|
||||||
// That is exactly the shape buildAlbums reads, so a set shared here and a set a Mind's mouth posts
|
|
||||||
// are the same thing to every viewer. Ports are injected so the order and the failure rules are
|
|
||||||
// testable without a homeserver. See docs/file-zone.md, "Share a set".
|
|
||||||
|
|
||||||
export type ShareFile = { name: string; mimeType: string };
|
|
||||||
|
|
||||||
/** What sendShareSet needs from the outside world. */
|
|
||||||
export type SharePort<F extends ShareFile> = {
|
|
||||||
/** Send an event and return its id. */
|
|
||||||
send: (content: Record<string, unknown>) => Promise<string>;
|
|
||||||
/** Upload one file and build its message content (msgtype, url, info incl. sha256). */
|
|
||||||
contentOf: (file: F) => Promise<Record<string, unknown>>;
|
|
||||||
/** Called after each file settles, for progress. */
|
|
||||||
onFile?: (index: number, result: 'sent' | 'failed') => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ShareResult = {
|
|
||||||
/** The album root's event id; present as soon as the root is sent. */
|
|
||||||
albumId: string;
|
|
||||||
sent: number;
|
|
||||||
failed: string[];
|
|
||||||
/** Positions in the files array of those that failed; names can repeat, positions cannot. */
|
|
||||||
failedAt: number[];
|
|
||||||
};
|
|
||||||
|
|
||||||
/** The reply relation Matrix clients expect for a thread message: root, with the last event as fallback reply. */
|
|
||||||
export const threadRelation = (rootId: string, lastId: string) => ({
|
|
||||||
rel_type: 'm.thread',
|
|
||||||
event_id: rootId,
|
|
||||||
is_falling_back: true,
|
|
||||||
'm.in_reply_to': { event_id: lastId },
|
|
||||||
});
|
|
||||||
|
|
||||||
export const DEFAULT_TITLE = 'Shared files';
|
|
||||||
|
|
||||||
export const titleOrDefault = (title: string): string => title.trim() || DEFAULT_TITLE;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Files go one at a time, in the order given, so thread order is pick order. A file that fails
|
|
||||||
* to upload is reported by name and the rest still go; the root is already out, so stopping would
|
|
||||||
* leave an empty album and the sender nothing to retry from. A root that fails to send throws:
|
|
||||||
* nothing exists yet.
|
|
||||||
*/
|
|
||||||
export const sendShareSet = async <F extends ShareFile>(
|
|
||||||
port: SharePort<F>,
|
|
||||||
title: string,
|
|
||||||
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;
|
|
||||||
for (let i = 0; i < files.length; i += 1) {
|
|
||||||
try {
|
|
||||||
// eslint-disable-next-line no-await-in-loop
|
|
||||||
const content = await port.contentOf(files[i]);
|
|
||||||
// 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 {
|
|
||||||
result.failed.push(files[i].name);
|
|
||||||
result.failedAt.push(i);
|
|
||||||
port.onFile?.(i, 'failed');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What stays picked after a send: only the files that did not go, so a retry is one tap. A retry
|
|
||||||
* makes a second album (adding to an existing one is not built), and a share where nothing went
|
|
||||||
* leaves its root as a lone message the album list does not show (it lists threads with replies).
|
|
||||||
*/
|
|
||||||
export const remainingAfter = <T>(files: T[], result: ShareResult): T[] =>
|
|
||||||
files.filter((_, i) => result.failedAt.includes(i));
|
|
||||||
|
|
||||||
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(', ')}`;
|
|
||||||
};
|
|
||||||
|
|
@ -59,7 +59,7 @@ export const useAlbums = (room: Room): AlbumsState => {
|
||||||
)
|
)
|
||||||
).chunk ?? [],
|
).chunk ?? [],
|
||||||
}));
|
}));
|
||||||
return buildAlbums(inputs, mx.getDomain() ?? '');
|
return buildAlbums(inputs);
|
||||||
},
|
},
|
||||||
retry: 1,
|
retry: 1,
|
||||||
refetchInterval: REFRESH_MS,
|
refetchInterval: REFRESH_MS,
|
||||||
|
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
import { useMemo, useSyncExternalStore } from 'react';
|
|
||||||
import { Room } from 'matrix-js-sdk';
|
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|
||||||
import { FileReader, readersOf } from './fileReceipt';
|
|
||||||
import { receiptStoreOf } from './receiptStore';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Who has read a file, from the receipts in the room's loaded timeline: a plain room event or a thread reply, both
|
|
||||||
* count (docs/file-zone-mind-contract.md). `content` is the file event's content, which carries its `info.sha256`.
|
|
||||||
* The room's one shared subscription (receiptStore.ts) feeds every file row.
|
|
||||||
*/
|
|
||||||
export const useFileReaders = (
|
|
||||||
room: Room,
|
|
||||||
fileId: string,
|
|
||||||
content: Record<string, unknown>
|
|
||||||
): FileReader[] => {
|
|
||||||
const mx = useMatrixClient();
|
|
||||||
const server = mx.getDomain() ?? '';
|
|
||||||
const store = receiptStoreOf(room);
|
|
||||||
const receipts = useSyncExternalStore(store.subscribe, store.getSnapshot);
|
|
||||||
return useMemo(
|
|
||||||
() => readersOf(receipts, fileId, content, server),
|
|
||||||
[receipts, fileId, content, server]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -5,7 +5,6 @@ import { StatusDivider } from './components';
|
||||||
import { CallEmbed, useCallControlState } from '../../plugins/call';
|
import { CallEmbed, useCallControlState } from '../../plugins/call';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
import { callEmbedAtom } from '../../state/callEmbed';
|
import { callEmbedAtom } from '../../state/callEmbed';
|
||||||
import { PhoneHitSpread26 } from '../../styles/PhoneHit.css';
|
|
||||||
|
|
||||||
type MicrophoneButtonProps = {
|
type MicrophoneButtonProps = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
|
@ -27,7 +26,6 @@ function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps
|
||||||
>
|
>
|
||||||
{(anchorRef) => (
|
{(anchorRef) => (
|
||||||
<IconButton
|
<IconButton
|
||||||
className={PhoneHitSpread26}
|
|
||||||
ref={anchorRef}
|
ref={anchorRef}
|
||||||
variant={enabled ? 'Surface' : 'Warning'}
|
variant={enabled ? 'Surface' : 'Warning'}
|
||||||
fill="Soft"
|
fill="Soft"
|
||||||
|
|
@ -61,7 +59,6 @@ function SoundButton({ enabled, onToggle, disabled }: SoundButtonProps) {
|
||||||
>
|
>
|
||||||
{(anchorRef) => (
|
{(anchorRef) => (
|
||||||
<IconButton
|
<IconButton
|
||||||
className={PhoneHitSpread26}
|
|
||||||
ref={anchorRef}
|
ref={anchorRef}
|
||||||
variant={enabled ? 'Surface' : 'Warning'}
|
variant={enabled ? 'Surface' : 'Warning'}
|
||||||
fill="Soft"
|
fill="Soft"
|
||||||
|
|
@ -102,7 +99,6 @@ function VideoButton({ enabled, onToggle, disabled }: VideoButtonProps) {
|
||||||
>
|
>
|
||||||
{(anchorRef) => (
|
{(anchorRef) => (
|
||||||
<IconButton
|
<IconButton
|
||||||
className={PhoneHitSpread26}
|
|
||||||
ref={anchorRef}
|
ref={anchorRef}
|
||||||
variant={enabled ? 'Success' : 'Surface'}
|
variant={enabled ? 'Success' : 'Surface'}
|
||||||
fill="Soft"
|
fill="Soft"
|
||||||
|
|
@ -140,7 +136,6 @@ function ScreenShareButton({ enabled, onToggle, disabled }: ScreenShareButtonPro
|
||||||
>
|
>
|
||||||
{(anchorRef) => (
|
{(anchorRef) => (
|
||||||
<IconButton
|
<IconButton
|
||||||
className={PhoneHitSpread26}
|
|
||||||
ref={anchorRef}
|
ref={anchorRef}
|
||||||
variant={enabled ? 'Success' : 'Surface'}
|
variant={enabled ? 'Success' : 'Surface'}
|
||||||
fill="Soft"
|
fill="Soft"
|
||||||
|
|
@ -169,10 +164,7 @@ export function CallControl({
|
||||||
const { microphone, video, sound, screenshare } = useCallControlState(callEmbed.control);
|
const { microphone, video, sound, screenshare } = useCallControlState(callEmbed.control);
|
||||||
const setCallEmbed = useSetAtom(callEmbedAtom);
|
const setCallEmbed = useSetAtom(callEmbedAtom);
|
||||||
|
|
||||||
const handleMicrophoneToggle = useCallback(
|
const handleMicrophoneToggle = useCallback(() => callEmbed.control.toggleMicrophone(), [callEmbed]);
|
||||||
() => callEmbed.control.toggleMicrophone(),
|
|
||||||
[callEmbed]
|
|
||||||
);
|
|
||||||
const handleVideoToggle = useCallback(() => callEmbed.control.toggleVideo(), [callEmbed]);
|
const handleVideoToggle = useCallback(() => callEmbed.control.toggleVideo(), [callEmbed]);
|
||||||
|
|
||||||
const [hangupState, hangup] = useAsyncCallback(
|
const [hangupState, hangup] = useAsyncCallback(
|
||||||
|
|
@ -203,7 +195,11 @@ export function CallControl({
|
||||||
disabled={!callJoined}
|
disabled={!callJoined}
|
||||||
/>
|
/>
|
||||||
{!compact && <StatusDivider />}
|
{!compact && <StatusDivider />}
|
||||||
<VideoButton enabled={video} onToggle={handleVideoToggle} disabled={!callJoined} />
|
<VideoButton
|
||||||
|
enabled={video}
|
||||||
|
onToggle={handleVideoToggle}
|
||||||
|
disabled={!callJoined}
|
||||||
|
/>
|
||||||
{!compact && (
|
{!compact && (
|
||||||
<ScreenShareButton
|
<ScreenShareButton
|
||||||
enabled={screenshare}
|
enabled={screenshare}
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@ import {
|
||||||
SoundButton,
|
SoundButton,
|
||||||
VideoButton,
|
VideoButton,
|
||||||
} from './Controls';
|
} from './Controls';
|
||||||
import { PhoneHitSpread40 } from '../../styles/PhoneHit.css';
|
|
||||||
import { CallEmbed, useCallControlState } from '../../plugins/call';
|
import { CallEmbed, useCallControlState } from '../../plugins/call';
|
||||||
import { useResizeObserver } from '../../hooks/useResizeObserver';
|
import { useResizeObserver } from '../../hooks/useResizeObserver';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
|
@ -72,10 +71,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||||
setCords(undefined);
|
setCords(undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMicrophoneToggle = useCallback(
|
const handleMicrophoneToggle = useCallback(() => callEmbed.control.toggleMicrophone(), [callEmbed]);
|
||||||
() => callEmbed.control.toggleMicrophone(),
|
|
||||||
[callEmbed]
|
|
||||||
);
|
|
||||||
const handleVideoToggle = useCallback(() => callEmbed.control.toggleVideo(), [callEmbed]);
|
const handleVideoToggle = useCallback(() => callEmbed.control.toggleVideo(), [callEmbed]);
|
||||||
|
|
||||||
const [hangupState, hangup] = useAsyncCallback(
|
const [hangupState, hangup] = useAsyncCallback(
|
||||||
|
|
@ -101,7 +97,10 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||||
>
|
>
|
||||||
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}>
|
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}>
|
||||||
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
||||||
<MicrophoneButton enabled={microphone} onToggle={handleMicrophoneToggle} />
|
<MicrophoneButton
|
||||||
|
enabled={microphone}
|
||||||
|
onToggle={handleMicrophoneToggle}
|
||||||
|
/>
|
||||||
<SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} />
|
<SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} />
|
||||||
</Box>
|
</Box>
|
||||||
{!compact && <ControlDivider />}
|
{!compact && <ControlDivider />}
|
||||||
|
|
@ -170,7 +169,6 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
className={PhoneHitSpread40}
|
|
||||||
variant="Surface"
|
variant="Surface"
|
||||||
fill="Soft"
|
fill="Soft"
|
||||||
radii="400"
|
radii="400"
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { useAtom } from 'jotai';
|
||||||
import * as css from './styles.css';
|
import * as css from './styles.css';
|
||||||
import { callChatAtom } from '../../state/callEmbed';
|
import { callChatAtom } from '../../state/callEmbed';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
import { PhoneHitSpread40 } from '../../styles/PhoneHit.css';
|
|
||||||
|
|
||||||
export function ControlDivider() {
|
export function ControlDivider() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -32,7 +31,6 @@ export function MicrophoneButton({ enabled, onToggle }: MicrophoneButtonProps) {
|
||||||
>
|
>
|
||||||
{(anchorRef) => (
|
{(anchorRef) => (
|
||||||
<IconButton
|
<IconButton
|
||||||
className={PhoneHitSpread40}
|
|
||||||
ref={anchorRef}
|
ref={anchorRef}
|
||||||
variant={enabled ? 'Surface' : 'Warning'}
|
variant={enabled ? 'Surface' : 'Warning'}
|
||||||
fill="Soft"
|
fill="Soft"
|
||||||
|
|
@ -66,7 +64,6 @@ export function SoundButton({ enabled, onToggle }: SoundButtonProps) {
|
||||||
>
|
>
|
||||||
{(anchorRef) => (
|
{(anchorRef) => (
|
||||||
<IconButton
|
<IconButton
|
||||||
className={PhoneHitSpread40}
|
|
||||||
ref={anchorRef}
|
ref={anchorRef}
|
||||||
variant={enabled ? 'Surface' : 'Warning'}
|
variant={enabled ? 'Surface' : 'Warning'}
|
||||||
fill="Soft"
|
fill="Soft"
|
||||||
|
|
@ -106,7 +103,6 @@ export function VideoButton({ enabled, onToggle }: VideoButtonProps) {
|
||||||
>
|
>
|
||||||
{(anchorRef) => (
|
{(anchorRef) => (
|
||||||
<IconButton
|
<IconButton
|
||||||
className={PhoneHitSpread40}
|
|
||||||
ref={anchorRef}
|
ref={anchorRef}
|
||||||
variant={enabled ? 'Success' : 'Surface'}
|
variant={enabled ? 'Success' : 'Surface'}
|
||||||
fill="Soft"
|
fill="Soft"
|
||||||
|
|
@ -144,7 +140,6 @@ export function ScreenShareButton({ enabled, onToggle }: ScreenShareButtonProps)
|
||||||
>
|
>
|
||||||
{(anchorRef) => (
|
{(anchorRef) => (
|
||||||
<IconButton
|
<IconButton
|
||||||
className={PhoneHitSpread40}
|
|
||||||
ref={anchorRef}
|
ref={anchorRef}
|
||||||
variant={enabled ? 'Success' : 'Surface'}
|
variant={enabled ? 'Success' : 'Surface'}
|
||||||
fill="Soft"
|
fill="Soft"
|
||||||
|
|
@ -175,7 +170,6 @@ export function ChatButton() {
|
||||||
>
|
>
|
||||||
{(anchorRef) => (
|
{(anchorRef) => (
|
||||||
<IconButton
|
<IconButton
|
||||||
className={PhoneHitSpread40}
|
|
||||||
ref={anchorRef}
|
ref={anchorRef}
|
||||||
variant={chat ? 'Success' : 'Surface'}
|
variant={chat ? 'Success' : 'Surface'}
|
||||||
fill="Soft"
|
fill="Soft"
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ export function PrescreenControls({ canJoin }: PrescreenControlsProps) {
|
||||||
const { microphone, video, sound, toggleMicrophone, toggleVideo, toggleSound } =
|
const { microphone, video, sound, toggleMicrophone, toggleVideo, toggleSound } =
|
||||||
useCallPreferences();
|
useCallPreferences();
|
||||||
|
|
||||||
|
|
||||||
const handleMicrophoneToggle = useCallback(async () => toggleMicrophone(), [toggleMicrophone]);
|
const handleMicrophoneToggle = useCallback(async () => toggleMicrophone(), [toggleMicrophone]);
|
||||||
const handleVideoToggle = useCallback(async () => toggleVideo(), [toggleVideo]);
|
const handleVideoToggle = useCallback(async () => toggleVideo(), [toggleVideo]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,6 @@ import {
|
||||||
AccountDataSubmitCallback,
|
AccountDataSubmitCallback,
|
||||||
} from '../../../components/AccountDataEditor';
|
} from '../../../components/AccountDataEditor';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { PhoneHit } from '../../../styles/PhoneHit.css';
|
|
||||||
|
|
||||||
type DeveloperToolsProps = {
|
type DeveloperToolsProps = {
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
|
|
@ -93,7 +92,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box shrink="No">
|
<Box shrink="No">
|
||||||
<IconButton className={PhoneHit} onClick={requestClose} variant="Surface">
|
<IconButton onClick={requestClose} variant="Surface">
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ import { useTextAreaCodeEditor } from '../../../hooks/useTextAreaCodeEditor';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
import { syntaxErrorPosition } from '../../../utils/dom';
|
import { syntaxErrorPosition } from '../../../utils/dom';
|
||||||
import { Cursor } from '../../../plugins/text-area';
|
import { Cursor } from '../../../plugins/text-area';
|
||||||
import { PhoneHit } from '../../../styles/PhoneHit.css';
|
|
||||||
|
|
||||||
const EDITOR_INTENT_SPACE_COUNT = 2;
|
const EDITOR_INTENT_SPACE_COUNT = 2;
|
||||||
|
|
||||||
|
|
@ -119,7 +118,7 @@ export function SendRoomEvent({ type, stateKey, requestClose }: SendRoomEventPro
|
||||||
</Chip>
|
</Chip>
|
||||||
</Box>
|
</Box>
|
||||||
<Box shrink="No">
|
<Box shrink="No">
|
||||||
<IconButton className={PhoneHit} onClick={requestClose} variant="Surface">
|
<IconButton onClick={requestClose} variant="Surface">
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,6 @@ import { usePowerLevels } from '../../../hooks/usePowerLevels';
|
||||||
import { useTextAreaCodeEditor } from '../../../hooks/useTextAreaCodeEditor';
|
import { useTextAreaCodeEditor } from '../../../hooks/useTextAreaCodeEditor';
|
||||||
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||||
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
|
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
|
||||||
import { PhoneHit } from '../../../styles/PhoneHit.css';
|
|
||||||
|
|
||||||
const EDITOR_INTENT_SPACE_COUNT = 2;
|
const EDITOR_INTENT_SPACE_COUNT = 2;
|
||||||
|
|
||||||
|
|
@ -276,7 +275,7 @@ export function StateEventEditor({ type, stateKey, requestClose }: StateEventEdi
|
||||||
</Chip>
|
</Chip>
|
||||||
</Box>
|
</Box>
|
||||||
<Box shrink="No">
|
<Box shrink="No">
|
||||||
<IconButton className={PhoneHit} onClick={requestClose} variant="Surface">
|
<IconButton onClick={requestClose} variant="Surface">
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||||
import { ImagePack } from '../../../plugins/custom-emoji';
|
import { ImagePack } from '../../../plugins/custom-emoji';
|
||||||
import { ImagePackView } from '../../../components/image-pack-view';
|
import { ImagePackView } from '../../../components/image-pack-view';
|
||||||
import { RoomPacks } from './RoomPacks';
|
import { RoomPacks } from './RoomPacks';
|
||||||
import { PhoneHit } from '../../../styles/PhoneHit.css';
|
|
||||||
|
|
||||||
type EmojisStickersProps = {
|
type EmojisStickersProps = {
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
|
|
@ -30,7 +29,7 @@ export function EmojisStickers({ requestClose }: EmojisStickersProps) {
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box shrink="No">
|
<Box shrink="No">
|
||||||
<IconButton className={PhoneHit} onClick={requestClose} variant="Surface">
|
<IconButton onClick={requestClose} variant="Surface">
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,6 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
import { useAlive } from '../../../hooks/useAlive';
|
import { useAlive } from '../../../hooks/useAlive';
|
||||||
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||||
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
|
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
|
||||||
import { PhoneHit } from '../../../styles/PhoneHit.css';
|
|
||||||
|
|
||||||
type CreatePackTileProps = {
|
type CreatePackTileProps = {
|
||||||
packs: ImagePack[];
|
packs: ImagePack[];
|
||||||
|
|
@ -216,7 +215,6 @@ export function RoomPacks({ onViewPack }: RoomPacksProps) {
|
||||||
{canEdit &&
|
{canEdit &&
|
||||||
(removed ? (
|
(removed ? (
|
||||||
<IconButton
|
<IconButton
|
||||||
className={PhoneHit}
|
|
||||||
size="300"
|
size="300"
|
||||||
radii="Pill"
|
radii="Pill"
|
||||||
variant="Critical"
|
variant="Critical"
|
||||||
|
|
@ -227,7 +225,6 @@ export function RoomPacks({ onViewPack }: RoomPacksProps) {
|
||||||
</IconButton>
|
</IconButton>
|
||||||
) : (
|
) : (
|
||||||
<IconButton
|
<IconButton
|
||||||
className={PhoneHit}
|
|
||||||
size="300"
|
size="300"
|
||||||
radii="Pill"
|
radii="Pill"
|
||||||
variant="Secondary"
|
variant="Secondary"
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,6 @@ import { useRoom } from '../../../hooks/useRoom';
|
||||||
import { useStateEvent } from '../../../hooks/useStateEvent';
|
import { useStateEvent } from '../../../hooks/useStateEvent';
|
||||||
import { stopPropagation } from '../../../utils/keyboard';
|
import { stopPropagation } from '../../../utils/keyboard';
|
||||||
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
|
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
|
||||||
import { PhoneHit } from '../../../styles/PhoneHit.css';
|
|
||||||
|
|
||||||
const ROOM_ENC_ALGO = 'm.megolm.v1.aes-sha2';
|
const ROOM_ENC_ALGO = 'm.megolm.v1.aes-sha2';
|
||||||
|
|
||||||
|
|
@ -124,12 +123,7 @@ export function RoomEncryption({ permissions }: RoomEncryptionProps) {
|
||||||
<Box grow="Yes">
|
<Box grow="Yes">
|
||||||
<Text size="H4">Enable Encryption</Text>
|
<Text size="H4">Enable Encryption</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton
|
<IconButton size="300" onClick={() => setPrompt(false)} radii="300">
|
||||||
className={PhoneHit}
|
|
||||||
size="300"
|
|
||||||
onClick={() => setPrompt(false)}
|
|
||||||
radii="300"
|
|
||||||
>
|
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Header>
|
</Header>
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,6 @@ import { useAlive } from '../../../hooks/useAlive';
|
||||||
import { creatorsSupported } from '../../../utils/matrix';
|
import { creatorsSupported } from '../../../utils/matrix';
|
||||||
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||||
import { BreakWord } from '../../../styles/Text.css';
|
import { BreakWord } from '../../../styles/Text.css';
|
||||||
import { PhoneHit } from '../../../styles/PhoneHit.css';
|
|
||||||
|
|
||||||
function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) {
|
function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
|
@ -106,7 +105,7 @@ function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) {
|
||||||
<Box grow="Yes">
|
<Box grow="Yes">
|
||||||
<Text size="H4">{room.isSpaceRoom() ? 'Space Upgrade' : 'Room Upgrade'}</Text>
|
<Text size="H4">{room.isSpaceRoom() ? 'Space Upgrade' : 'Room Upgrade'}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton className={PhoneHit} size="300" onClick={requestClose} radii="300">
|
<IconButton size="300" onClick={requestClose} radii="300">
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Header>
|
</Header>
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,6 @@ import { useSpaceOptionally } from '../../../hooks/useSpace';
|
||||||
import { useFlattenPowerTagMembers, useGetMemberPowerTag } from '../../../hooks/useMemberPowerTag';
|
import { useFlattenPowerTagMembers, useGetMemberPowerTag } from '../../../hooks/useMemberPowerTag';
|
||||||
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||||
import { getMouseEventCords } from '../../../utils/dom';
|
import { getMouseEventCords } from '../../../utils/dom';
|
||||||
import { PhoneHit } from '../../../styles/PhoneHit.css';
|
|
||||||
|
|
||||||
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
|
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
|
||||||
limit: 1000,
|
limit: 1000,
|
||||||
|
|
@ -162,7 +161,7 @@ export function Members({ requestClose }: MembersProps) {
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box shrink="No">
|
<Box shrink="No">
|
||||||
<IconButton className={PhoneHit} onClick={requestClose} variant="Surface">
|
<IconButton onClick={requestClose} variant="Surface">
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -278,7 +277,6 @@ export function Members({ requestClose }: MembersProps) {
|
||||||
anchorRef={scrollTopAnchorRef}
|
anchorRef={scrollTopAnchorRef}
|
||||||
>
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
className={PhoneHit}
|
|
||||||
onClick={() => virtualizer.scrollToOffset(0)}
|
onClick={() => virtualizer.scrollToOffset(0)}
|
||||||
variant="Surface"
|
variant="Surface"
|
||||||
radii="Pill"
|
radii="Pill"
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,6 @@ import { useAlive } from '../../../hooks/useAlive';
|
||||||
import { BetaNoticeBadge } from '../../../components/BetaNoticeBadge';
|
import { BetaNoticeBadge } from '../../../components/BetaNoticeBadge';
|
||||||
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
|
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
|
||||||
import { creatorsSupported } from '../../../utils/matrix';
|
import { creatorsSupported } from '../../../utils/matrix';
|
||||||
import { PhoneHit } from '../../../styles/PhoneHit.css';
|
|
||||||
|
|
||||||
type EditPowerProps = {
|
type EditPowerProps = {
|
||||||
maxPower: number;
|
maxPower: number;
|
||||||
|
|
@ -370,7 +369,7 @@ export function PowersEditor({ powerLevels, requestClose }: PowersEditorProps) {
|
||||||
</Chip>
|
</Chip>
|
||||||
</Box>
|
</Box>
|
||||||
<Box shrink="No">
|
<Box shrink="No">
|
||||||
<IconButton className={PhoneHit} onClick={requestClose} variant="Surface">
|
<IconButton onClick={requestClose} variant="Surface">
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,6 @@ import {
|
||||||
import { CreateRoomModalState } from '../../state/createRoomModal';
|
import { CreateRoomModalState } from '../../state/createRoomModal';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import { CreateRoomType } from '../../components/create-room/types';
|
import { CreateRoomType } from '../../components/create-room/types';
|
||||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
|
||||||
|
|
||||||
type CreateRoomModalProps = {
|
type CreateRoomModalProps = {
|
||||||
state: CreateRoomModalState;
|
state: CreateRoomModalState;
|
||||||
|
|
@ -64,7 +63,7 @@ function CreateRoomModal({ state }: CreateRoomModalProps) {
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box shrink="No">
|
<Box shrink="No">
|
||||||
<IconButton className={PhoneHit} size="300" radii="300" onClick={closeDialog}>
|
<IconButton size="300" radii="300" onClick={closeDialog}>
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ import {
|
||||||
} from '../../state/hooks/createSpaceModal';
|
} from '../../state/hooks/createSpaceModal';
|
||||||
import { CreateSpaceModalState } from '../../state/createSpaceModal';
|
import { CreateSpaceModalState } from '../../state/createSpaceModal';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
|
||||||
|
|
||||||
type CreateSpaceModalProps = {
|
type CreateSpaceModalProps = {
|
||||||
state: CreateSpaceModalState;
|
state: CreateSpaceModalState;
|
||||||
|
|
@ -62,7 +61,7 @@ function CreateSpaceModal({ state }: CreateSpaceModalProps) {
|
||||||
<Text size="H4">New Space</Text>
|
<Text size="H4">New Space</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box shrink="No">
|
<Box shrink="No">
|
||||||
<IconButton className={PhoneHit} size="300" radii="300" onClick={closeDialog}>
|
<IconButton size="300" radii="300" onClick={closeDialog}>
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds';
|
import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds';
|
||||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
|
||||||
import { Page, PageContent, PageContentCenter, PageHeader } from '../../components/page';
|
import { Page, PageContent, PageContentCenter, PageHeader } from '../../components/page';
|
||||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||||
import { getHomePath } from '../../pages/pathUtils';
|
import { getHomePath } from '../../pages/pathUtils';
|
||||||
|
|
@ -34,11 +33,7 @@ export function Desk() {
|
||||||
<Box grow="Yes" alignItems="Center" gap="200">
|
<Box grow="Yes" alignItems="Center" gap="200">
|
||||||
<Box grow="Yes" basis="No">
|
<Box grow="Yes" basis="No">
|
||||||
{phone && (
|
{phone && (
|
||||||
<IconButton
|
<IconButton aria-label="Back to rooms" onClick={() => navigate(getHomePath())}>
|
||||||
className={PhoneHit}
|
|
||||||
aria-label="Back to rooms"
|
|
||||||
onClick={() => navigate(getHomePath())}
|
|
||||||
>
|
|
||||||
<Icon src={Icons.ArrowLeft} />
|
<Icon src={Icons.ArrowLeft} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,16 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Box, Icon, Icons, Text } from 'folds';
|
import { Link } from 'react-router-dom';
|
||||||
import { NavGutter, NavItem, NavItemContent, NavLink } from '../../components/nav';
|
import { Text } from 'folds';
|
||||||
import { NAV_ROOM_ICON_REST_OPACITY, NavLabel } from '../../components/nav/styles.css';
|
|
||||||
import { getDeskPath } from '../../pages/pathUtils';
|
import { getDeskPath } from '../../pages/pathUtils';
|
||||||
import { deskFor } from './source';
|
import { deskFor } from './source';
|
||||||
|
|
||||||
/** A Mind's desk, pinned at the top of its navigator group: always there, with the open count when there is one. */
|
/** The way into a Mind's desk, set in its navigator group header; nothing at all when nothing is open. */
|
||||||
export function DeskEntry({ mind }: { mind: string }) {
|
export function DeskEntry({ mind }: { mind: string }) {
|
||||||
const open = deskFor(mind)?.open_count ?? 0;
|
const open = deskFor(mind)?.open_count ?? 0;
|
||||||
|
if (open === 0) return null;
|
||||||
return (
|
return (
|
||||||
<NavItem variant="Background" radii="400">
|
<Text as={Link} to={getDeskPath(mind)} size="T200" priority="300">
|
||||||
<NavLink to={getDeskPath(mind)}>
|
{`Desk · ${open} open`}
|
||||||
<NavItemContent>
|
</Text>
|
||||||
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
|
||||||
<NavGutter>
|
|
||||||
<Icon
|
|
||||||
src={Icons.Bookmark}
|
|
||||||
size="100"
|
|
||||||
style={{ opacity: NAV_ROOM_ICON_REST_OPACITY }}
|
|
||||||
/>
|
|
||||||
</NavGutter>
|
|
||||||
<Text className={NavLabel} as="span" size="Inherit" truncate>
|
|
||||||
{open > 0 ? `Desk · ${open} open` : 'Desk'}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</NavItemContent>
|
|
||||||
</NavLink>
|
|
||||||
</NavItem>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,29 +30,13 @@ export const Title = style({
|
||||||
export const Fold = style({
|
export const Fold = style({
|
||||||
all: 'unset',
|
all: 'unset',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
|
display: 'block',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
minHeight: 44,
|
minHeight: 44,
|
||||||
boxSizing: 'border-box',
|
boxSizing: 'border-box',
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
gap: config.space.S200,
|
|
||||||
// A folded row says it can be opened: a chevron at the end, turned down while the row is open.
|
|
||||||
selectors: {
|
|
||||||
'&::after': {
|
|
||||||
content: '"›"',
|
|
||||||
flexShrink: 0,
|
|
||||||
fontSize: '1.5em',
|
|
||||||
lineHeight: 1,
|
|
||||||
transition: 'transform 120ms',
|
|
||||||
},
|
|
||||||
'&[aria-expanded=true]::after': { transform: 'rotate(90deg)' },
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Quiet lines (age, who it waits on, helper text) were measured at 4.5 over the day sky and 3.9-4.0 at dusk and night
|
export const Quiet = style({ opacity: config.opacity.P300, margin: `${config.space.S100} 0` });
|
||||||
// with the theme's 0.75 (taps2/desk-REPORT.md); 0.9 clears 4.5 in all three.
|
|
||||||
export const Quiet = style({ opacity: 0.9, margin: `${config.space.S100} 0` });
|
|
||||||
export const Body = style({ whiteSpace: 'pre-wrap', margin: `${config.space.S200} 0` });
|
export const Body = style({ whiteSpace: 'pre-wrap', margin: `${config.space.S200} 0` });
|
||||||
export const Options = style({ margin: 0, padding: 0, listStyle: 'none' });
|
export const Options = style({ margin: 0, padding: 0, listStyle: 'none' });
|
||||||
export const Option = style({ padding: `${config.space.S100} 0` });
|
export const Option = style({ padding: `${config.space.S100} 0` });
|
||||||
|
|
@ -66,9 +50,8 @@ export const Note = style({
|
||||||
font: 'inherit',
|
font: 'inherit',
|
||||||
color: 'inherit',
|
color: 'inherit',
|
||||||
background: 'none',
|
background: 'none',
|
||||||
// The note is disabled (slice 1). Its border is a UI boundary (3:1 wanted; the faint line under the 0.38 disabled
|
border: `1px solid ${color.Background.ContainerLine}`,
|
||||||
// opacity measured 2.0), so it is drawn dashed in the full ink and not faded.
|
opacity: config.opacity.Disabled,
|
||||||
border: '1px dashed currentColor',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Sticky beside the rows on wide screens; a small fixed corner image on phones. */
|
/** Sticky beside the rows on wide screens; a small fixed corner image on phones. */
|
||||||
|
|
@ -114,9 +97,3 @@ export const BoothLine = style({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/** The recorded rulings sit on the dark ridge in every sky; they carry their own ground (scrimStyle). */
|
|
||||||
export const Recorded = style({
|
|
||||||
marginTop: config.space.S400,
|
|
||||||
padding: `${config.space.S200} ${config.space.S300}`,
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,13 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { renderToStaticMarkup } from 'react-dom/server';
|
import { renderToStaticMarkup } from 'react-dom/server';
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
import { ScreenSize, ScreenSizeProvider } from '../../hooks/useScreenSize';
|
import { ScreenSize, ScreenSizeProvider } from '../../hooks/useScreenSize';
|
||||||
import { SkyInkContextProvider } from '../../hooks/useTheme';
|
|
||||||
import { SCRIM_GROUND, SCRIM_INK } from '../scene/skyInk';
|
|
||||||
import { DeskEntry } from './DeskEntry';
|
import { DeskEntry } from './DeskEntry';
|
||||||
import { DeskView, SIGN_IN_NOTE } from './DeskView';
|
import { DeskView, SIGN_IN_NOTE } from './DeskView';
|
||||||
import { ForkList } from './forkContract';
|
|
||||||
import { STATES } from './fixtures';
|
import { STATES } from './fixtures';
|
||||||
import { setDeskSource } from './source';
|
import { setDeskSource } from './source';
|
||||||
|
|
||||||
// useTheme reads the stored settings when it loads.
|
|
||||||
vi.hoisted(() => vi.stubGlobal('localStorage', { getItem: () => null, setItem: () => undefined }));
|
|
||||||
|
|
||||||
const view = (
|
const view = (
|
||||||
state: keyof typeof STATES | 'unpublished',
|
state: keyof typeof STATES | 'unpublished',
|
||||||
size: ScreenSize,
|
size: ScreenSize,
|
||||||
|
|
@ -71,19 +65,6 @@ describe('DeskView', () => {
|
||||||
expect(html).toContain('Question done (ruling:done-1)');
|
expect(html).toContain('Question done (ruling:done-1)');
|
||||||
expect(html).not.toContain('Question shut');
|
expect(html).not.toContain('Question shut');
|
||||||
});
|
});
|
||||||
it('gives the recorded rulings their own ground and ink while a scene runs, none without one', () => {
|
|
||||||
const withScene = renderToStaticMarkup(
|
|
||||||
<ScreenSizeProvider value={ScreenSize.Desktop}>
|
|
||||||
<SkyInkContextProvider value={{ state: 'dusk', phone: false }}>
|
|
||||||
<DeskView mind="void" label="Void" list={STATES.settled} unverified={new Set()} />
|
|
||||||
</SkyInkContextProvider>
|
|
||||||
</ScreenSizeProvider>
|
|
||||||
);
|
|
||||||
// renderToStaticMarkup writes the inline style as the browser would read it back
|
|
||||||
expect(withScene).toContain(SCRIM_GROUND.replace(/, /g, ', '));
|
|
||||||
expect(withScene).toContain(`color:${SCRIM_INK}`);
|
|
||||||
expect(view('settled', ScreenSize.Desktop)).not.toContain('background-color');
|
|
||||||
});
|
|
||||||
it('shows each outcome quietly, marks the counted answer, and renders a note only when sent', () => {
|
it('shows each outcome quietly, marks the counted answer, and renders a note only when sent', () => {
|
||||||
const html = view('answered', ScreenSize.Desktop);
|
const html = view('answered', ScreenSize.Desktop);
|
||||||
expect(html).toContain(
|
expect(html).toContain(
|
||||||
|
|
@ -140,96 +121,6 @@ describe('DeskView', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("a question's age", () => {
|
|
||||||
const NOW = Date.parse('2026-11-11T12:00:00Z');
|
|
||||||
const listRaised = (raisedAt: string | null): ForkList => {
|
|
||||||
const entry = STATES.noAnswers.forks[0];
|
|
||||||
return {
|
|
||||||
...STATES.noAnswers,
|
|
||||||
forks: [{ ...entry, card: { ...entry.card, raised_at: raisedAt } }],
|
|
||||||
};
|
|
||||||
};
|
|
||||||
const html = (raisedAt: string | null) =>
|
|
||||||
renderToStaticMarkup(
|
|
||||||
<ScreenSizeProvider value={ScreenSize.Desktop}>
|
|
||||||
<DeskView mind="void" label="Void" list={listRaised(raisedAt)} now={NOW} />
|
|
||||||
</ScreenSizeProvider>
|
|
||||||
);
|
|
||||||
it('reads days since the card was first admitted', () => {
|
|
||||||
expect(html('2026-11-06T12:00:00Z')).toContain('raised 5d ago');
|
|
||||||
});
|
|
||||||
it('reads hours inside a day, and just now inside an hour', () => {
|
|
||||||
expect(html('2026-11-11T07:30:00Z')).toContain('raised 4h ago');
|
|
||||||
expect(html('2026-11-11T11:40:00Z')).toContain('raised just now');
|
|
||||||
});
|
|
||||||
it('shows no age when the card carries none, or an instant that does not parse or is ahead of the clock', () => {
|
|
||||||
[
|
|
||||||
'null',
|
|
||||||
'yesterday',
|
|
||||||
'2027-01-01T00:00:00Z',
|
|
||||||
'2026-11-06',
|
|
||||||
'Nov 6 2026',
|
|
||||||
'0',
|
|
||||||
'2026-02-30T00:00:00Z',
|
|
||||||
].forEach((v) => {
|
|
||||||
expect(html(v === 'null' ? null : v)).not.toContain('raised ');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('counts when the ids are withheld', () => {
|
|
||||||
const withheld = (): ForkList => {
|
|
||||||
const entry = STATES.noAnswers.forks[0];
|
|
||||||
return {
|
|
||||||
...STATES.noAnswers,
|
|
||||||
forks: [
|
|
||||||
{
|
|
||||||
...entry,
|
|
||||||
card: { ...entry.card, deciders: [], decider_count: 3 },
|
|
||||||
record: { ...entry.record, answerer_count: 2 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
};
|
|
||||||
it('shows how many deciders and answerers there are, naming none', () => {
|
|
||||||
const html = renderToStaticMarkup(
|
|
||||||
<ScreenSizeProvider value={ScreenSize.Desktop}>
|
|
||||||
<DeskView mind="void" label="Void" list={withheld()} />
|
|
||||||
</ScreenSizeProvider>
|
|
||||||
);
|
|
||||||
expect(html).toContain('3 deciders · 2 answerers');
|
|
||||||
});
|
|
||||||
it('shows no count line for a served count outside the contract', () => {
|
|
||||||
const bad = (card: object, record: object): string => {
|
|
||||||
const entry = STATES.noAnswers.forks[0];
|
|
||||||
const list = {
|
|
||||||
...STATES.noAnswers,
|
|
||||||
forks: [{ card: { ...entry.card, ...card }, record: { ...entry.record, ...record } }],
|
|
||||||
} as ForkList;
|
|
||||||
return renderToStaticMarkup(
|
|
||||||
<ScreenSizeProvider value={ScreenSize.Desktop}>
|
|
||||||
<DeskView mind="void" label="Void" list={list} />
|
|
||||||
</ScreenSizeProvider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
[
|
|
||||||
[{ decider_count: 0 }, {}],
|
|
||||||
[{ decider_count: 65 }, {}],
|
|
||||||
[{ decider_count: undefined }, {}],
|
|
||||||
[{}, { answerer_count: -1 }],
|
|
||||||
[{}, { answerer_count: 1.5 }],
|
|
||||||
].forEach(([c, r]) => {
|
|
||||||
const out = bad(c, r);
|
|
||||||
expect(out).not.toContain('undefined');
|
|
||||||
expect(out).not.toMatch(/deciders? ·/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
it('says the singular for one', () => {
|
|
||||||
const html = view('noAnswers', ScreenSize.Desktop);
|
|
||||||
expect(html).toContain('1 decider · 0 answerers');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('DeskEntry', () => {
|
describe('DeskEntry', () => {
|
||||||
afterEach(() => setDeskSource(() => undefined));
|
afterEach(() => setDeskSource(() => undefined));
|
||||||
const entry = (mind: string) =>
|
const entry = (mind: string) =>
|
||||||
|
|
@ -248,14 +139,12 @@ describe('DeskEntry', () => {
|
||||||
setDeskSource(() => ({ ...STATES.empty, open_count: 5 }));
|
setDeskSource(() => ({ ...STATES.empty, open_count: 5 }));
|
||||||
expect(entry('void')).toContain('Desk · 5 open');
|
expect(entry('void')).toContain('Desk · 5 open');
|
||||||
});
|
});
|
||||||
it('is pinned even at zero open, when nothing is published, and when only settled forks exist', () => {
|
it('is absent at zero open, when nothing is published, and when only settled forks exist', () => {
|
||||||
const bare = (html: string) => html.replace(/<[^>]*>/g, '');
|
setDeskSource(() => STATES.empty);
|
||||||
const states = [STATES.empty, undefined, { ...STATES.settled, open_count: 0 }];
|
expect(entry('void')).toBe('');
|
||||||
states.forEach((state) => {
|
setDeskSource(() => undefined);
|
||||||
setDeskSource(() => state);
|
expect(entry('void')).toBe('');
|
||||||
const html = entry('void');
|
setDeskSource(() => ({ ...STATES.settled, open_count: 0 }));
|
||||||
expect(bare(html)).toBe('Desk');
|
expect(entry('void')).toBe('');
|
||||||
expect(html).toContain('href="/desk/void"');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,18 +4,13 @@ import { PoseAvatar } from '../../components/message/pose/PoseAvatar';
|
||||||
import { MINDS } from '../../components/message/pose/poses';
|
import { MINDS } from '../../components/message/pose/poses';
|
||||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||||
import { AnswerRow, Desk, buildDesk, DeskRow } from './buildDesk';
|
import { AnswerRow, Desk, buildDesk, DeskRow } from './buildDesk';
|
||||||
import { useSkyInk } from '../../hooks/useTheme';
|
|
||||||
import { scrimStyle } from '../scene/skyInk';
|
|
||||||
import { ForkList } from './forkContract';
|
import { ForkList } from './forkContract';
|
||||||
import { deskMood } from './deskMood';
|
import { deskMood } from './deskMood';
|
||||||
import { raisedAge } from './age';
|
|
||||||
import * as css from './DeskView.css';
|
import * as css from './DeskView.css';
|
||||||
|
|
||||||
/** Slice 1 has no session: every viewer is signed out, and the note says why it cannot be used. */
|
/** Slice 1 has no session: every viewer is signed out, and the note says why it cannot be used. */
|
||||||
export const SIGN_IN_NOTE = 'Answering opens with sign-in. Until then you can read the questions.';
|
export const SIGN_IN_NOTE = 'Answering opens with sign-in. Until then you can read the questions.';
|
||||||
|
|
||||||
const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
||||||
|
|
||||||
const date = (iso: string) => iso.slice(0, 10);
|
const date = (iso: string) => iso.slice(0, 10);
|
||||||
|
|
||||||
const OUTCOME: Record<AnswerRow['outcome'], string> = {
|
const OUTCOME: Record<AnswerRow['outcome'], string> = {
|
||||||
|
|
@ -50,7 +45,7 @@ function Answers({ answers }: { answers: AnswerRow[] }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Row({ row, now }: { row: DeskRow; now: number }) {
|
function Row({ row }: { row: DeskRow }) {
|
||||||
const phone = useScreenSizeContext() === ScreenSize.Mobile;
|
const phone = useScreenSizeContext() === ScreenSize.Mobile;
|
||||||
// Phones list titles; a tap opens one. Wide screens show every row open.
|
// Phones list titles; a tap opens one. Wide screens show every row open.
|
||||||
const [open, setOpen] = useState(!phone);
|
const [open, setOpen] = useState(!phone);
|
||||||
|
|
@ -82,14 +77,6 @@ function Row({ row, now }: { row: DeskRow; now: number }) {
|
||||||
</h3>
|
</h3>
|
||||||
{open && (
|
{open && (
|
||||||
<>
|
<>
|
||||||
{raisedAge(row.raisedAt, now) && (
|
|
||||||
<p className={css.Quiet}>{raisedAge(row.raisedAt, now)}</p>
|
|
||||||
)}
|
|
||||||
{row.deciderCount !== undefined && row.answererCount !== undefined && (
|
|
||||||
<p className={css.Quiet}>
|
|
||||||
{`${plural(row.deciderCount, 'decider')} · ${plural(row.answererCount, 'answerer')}`}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<p className={css.Body}>{row.context}</p>
|
<p className={css.Body}>{row.context}</p>
|
||||||
<ul className={css.Options}>
|
<ul className={css.Options}>
|
||||||
{row.options.map((o) => (
|
{row.options.map((o) => (
|
||||||
|
|
@ -139,14 +126,11 @@ type DeskViewProps = {
|
||||||
list?: ForkList;
|
list?: ForkList;
|
||||||
/** Fork ids whose options_hash failed the recompute (optionsHash.ts). */
|
/** Fork ids whose options_hash failed the recompute (optionsHash.ts). */
|
||||||
unverified?: ReadonlySet<string>;
|
unverified?: ReadonlySet<string>;
|
||||||
/** The clock the ages are read against; the page's own, a test's fixed one. */
|
|
||||||
now?: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** One Mind's desk, read-only: a stream of rows, the recorded rulings, and the booth Persona. */
|
/** One Mind's desk, read-only: a stream of rows, the recorded rulings, and the booth Persona. */
|
||||||
export function DeskView({ mind, label, list, unverified, now = Date.now() }: DeskViewProps) {
|
export function DeskView({ mind, label, list, unverified }: DeskViewProps) {
|
||||||
const desk = buildDesk(list, unverified);
|
const desk = buildDesk(list, unverified);
|
||||||
const skyInk = useSkyInk();
|
|
||||||
// Slice 1 has no session, so nothing on the desk is the viewer's answer yet.
|
// Slice 1 has no session, so nothing on the desk is the viewer's answer yet.
|
||||||
const mood = deskMood(desk.counts.open, 0);
|
const mood = deskMood(desk.counts.open, 0);
|
||||||
return (
|
return (
|
||||||
|
|
@ -155,7 +139,7 @@ export function DeskView({ mind, label, list, unverified, now = Date.now() }: De
|
||||||
<div className={css.Layout}>
|
<div className={css.Layout}>
|
||||||
<ol className={css.Rows}>
|
<ol className={css.Rows}>
|
||||||
{desk.open.map((row) => (
|
{desk.open.map((row) => (
|
||||||
<Row key={row.forkId} row={row} now={now} />
|
<Row key={row.forkId} row={row} />
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
{/* Only Minds with art in the catalogue get a booth; none gets another Mind's face. */}
|
{/* Only Minds with art in the catalogue get a booth; none gets another Mind's face. */}
|
||||||
|
|
@ -169,7 +153,7 @@ export function DeskView({ mind, label, list, unverified, now = Date.now() }: De
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{desk.recorded.length > 0 && (
|
{desk.recorded.length > 0 && (
|
||||||
<div className={css.Recorded} style={scrimStyle(skyInk)}>
|
<>
|
||||||
<h3 className={css.Title}>Recorded as rulings</h3>
|
<h3 className={css.Title}>Recorded as rulings</h3>
|
||||||
<ul className={css.Options}>
|
<ul className={css.Options}>
|
||||||
{desk.recorded.map((r) => (
|
{desk.recorded.map((r) => (
|
||||||
|
|
@ -178,7 +162,7 @@ export function DeskView({ mind, label, list, unverified, now = Date.now() }: De
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
const MINUTE = 60 * 1000;
|
|
||||||
const HOUR = 60 * MINUTE;
|
|
||||||
const DAY = 24 * HOUR;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How long ago a question was raised, from the card's `raised_at` (an RFC3339 instant) and the clock the caller
|
|
||||||
* holds. Nothing when there is no instant, it does not parse, or it is ahead of the clock: the desk shows no age
|
|
||||||
* rather than a wrong one.
|
|
||||||
*/
|
|
||||||
const RFC3339 = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
||||||
|
|
||||||
/** Date.parse is lenient ("2026-11-06", "Nov 6 2026", "0"); the card's instant is RFC3339 and a real calendar day. */
|
|
||||||
const parseRfc3339 = (s: string): number => {
|
|
||||||
const m = RFC3339.exec(s);
|
|
||||||
if (!m) return NaN;
|
|
||||||
const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
||||||
const day = new Date(Date.UTC(y, mo - 1, d));
|
|
||||||
if (day.getUTCMonth() !== mo - 1 || day.getUTCDate() !== d) return NaN;
|
|
||||||
return Date.parse(s);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const raisedAge = (raisedAt: string | undefined, now: number): string | undefined => {
|
|
||||||
const then = raisedAt === undefined ? NaN : parseRfc3339(raisedAt);
|
|
||||||
const ms = now - then;
|
|
||||||
if (Number.isNaN(ms) || ms < 0) return undefined;
|
|
||||||
if (ms < HOUR) return 'raised just now';
|
|
||||||
if (ms < DAY) return `raised ${Math.floor(ms / HOUR)}h ago`;
|
|
||||||
return `raised ${Math.floor(ms / DAY)}d ago`;
|
|
||||||
};
|
|
||||||
|
|
@ -28,11 +28,6 @@ export type DeskRow =
|
||||||
/** The recommendation resolved to its option's text; absent if it names no option. */
|
/** The recommendation resolved to its option's text; absent if it names no option. */
|
||||||
recommended?: string;
|
recommended?: string;
|
||||||
depends: string[];
|
depends: string[];
|
||||||
/** When the question was first admitted, as served; absent when the card carries none. */
|
|
||||||
raisedAt?: string;
|
|
||||||
/** Counts as served: the same in the decider and the withheld view, so they show either way. Absent when the served number is outside the contract's range or not an integer. */
|
|
||||||
deciderCount?: number;
|
|
||||||
answererCount?: number;
|
|
||||||
/** In admission order, as served. */
|
/** In admission order, as served. */
|
||||||
answers: AnswerRow[];
|
answers: AnswerRow[];
|
||||||
};
|
};
|
||||||
|
|
@ -62,9 +57,6 @@ const answerRow = (a: DeskAnswer, entry: ForkEntry): AnswerRow => ({
|
||||||
counted: entry.record.counted_answer_id === a.answer_id,
|
counted: entry.record.counted_answer_id === a.answer_id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const validCount = (n: number, min: number, max: number): number | undefined =>
|
|
||||||
Number.isInteger(n) && n >= min && n <= max ? n : undefined;
|
|
||||||
|
|
||||||
export const buildDesk = (
|
export const buildDesk = (
|
||||||
list: ForkList | undefined,
|
list: ForkList | undefined,
|
||||||
unverified: ReadonlySet<string> = new Set()
|
unverified: ReadonlySet<string> = new Set()
|
||||||
|
|
@ -91,9 +83,6 @@ export const buildDesk = (
|
||||||
options: card.options.map((o) => ({ ...o, recommended: o.id === card.recommendation })),
|
options: card.options.map((o) => ({ ...o, recommended: o.id === card.recommendation })),
|
||||||
recommended: card.options.find((o) => o.id === card.recommendation)?.text,
|
recommended: card.options.find((o) => o.id === card.recommendation)?.text,
|
||||||
depends: card.depends,
|
depends: card.depends,
|
||||||
raisedAt: card.raised_at ?? undefined,
|
|
||||||
deciderCount: validCount(card.decider_count, 1, 64),
|
|
||||||
answererCount: validCount(record.answerer_count, 0, Infinity),
|
|
||||||
answers: record.answers.map((a) => answerRow(a, entry)),
|
answers: record.answers.map((a) => answerRow(a, entry)),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,7 @@ export const card = (label: string, over: Partial<ForkCard> = {}): ForkCard => (
|
||||||
leans: null,
|
leans: null,
|
||||||
flag: null,
|
flag: null,
|
||||||
raised_in: null,
|
raised_in: null,
|
||||||
raised_at: null,
|
|
||||||
deciders: ['d1'],
|
deciders: ['d1'],
|
||||||
decider_count: 1,
|
|
||||||
status: 'open',
|
status: 'open',
|
||||||
ruling: null,
|
ruling: null,
|
||||||
ruled_from: [],
|
ruled_from: [],
|
||||||
|
|
@ -62,7 +60,6 @@ export const entry = (
|
||||||
fork_id: c.fork_id,
|
fork_id: c.fork_id,
|
||||||
options_hash: c.options_hash,
|
options_hash: c.options_hash,
|
||||||
counted_answer_id: counted,
|
counted_answer_id: counted,
|
||||||
answerer_count: new Set(answers.map((a) => a.answerer)).size,
|
|
||||||
answers,
|
answers,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -24,19 +24,7 @@ export type ForkCard = {
|
||||||
/** Not shown by the desk. */
|
/** Not shown by the desk. */
|
||||||
flag: string | null;
|
flag: string | null;
|
||||||
raised_in: { kind: string; id: string } | null;
|
raised_in: { kind: string; id: string } | null;
|
||||||
/**
|
|
||||||
* When the question was first admitted (an RFC3339 instant, as `answered_at`), or nil when the mind holds no
|
|
||||||
* admission time. Always written; outside `options_hash`; it never moves when the question is edited. Age is
|
|
||||||
* the client's: now minus `raised_at`.
|
|
||||||
*/
|
|
||||||
raised_at: string | null;
|
|
||||||
/**
|
|
||||||
* Who may answer. Empty when this reader is not a decider (the ids are withheld); otherwise it holds exactly
|
|
||||||
* `decider_count` ids.
|
|
||||||
*/
|
|
||||||
deciders: string[];
|
deciders: string[];
|
||||||
/** How many deciders there are, 1..64: the same number in both reader views. */
|
|
||||||
decider_count: number;
|
|
||||||
status: 'open' | 'closed';
|
status: 'open' | 'closed';
|
||||||
/** The ruling id when a ruling closed the fork; nil when open, and nil on a closed card withdrawn with no ruling. */
|
/** The ruling id when a ruling closed the fork; nil when open, and nil on a closed card withdrawn with no ruling. */
|
||||||
ruling: string | null;
|
ruling: string | null;
|
||||||
|
|
@ -70,8 +58,7 @@ export type DeskAnswer = {
|
||||||
answered_at: string;
|
answered_at: string;
|
||||||
received_at: string;
|
received_at: string;
|
||||||
claim_in_future: boolean;
|
claim_in_future: boolean;
|
||||||
/** Who answered; nil when this reader is not a decider (withheld). */
|
answerer: string;
|
||||||
answerer: string | null;
|
|
||||||
authority: string;
|
authority: string;
|
||||||
request_id: string;
|
request_id: string;
|
||||||
};
|
};
|
||||||
|
|
@ -82,8 +69,6 @@ export type ForkRecord = {
|
||||||
options_hash: string;
|
options_hash: string;
|
||||||
/** Derived by the receiver; the page renders it and never recomputes it. */
|
/** Derived by the receiver; the page renders it and never recomputes it. */
|
||||||
counted_answer_id: string | null;
|
counted_answer_id: string | null;
|
||||||
/** Distinct answerers over every stored answer, refusals included, at least 0: the same number in both views. */
|
|
||||||
answerer_count: number;
|
|
||||||
/** In admission order. */
|
/** In admission order. */
|
||||||
answers: DeskAnswer[];
|
answers: DeskAnswer[];
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -109,10 +109,7 @@ describe('v4 manifest against the parser', () => {
|
||||||
|
|
||||||
it("every voice file is Aqua's v4 render (sha256 prefix)", () => {
|
it("every voice file is Aqua's v4 render (sha256 prefix)", () => {
|
||||||
const hash = (file: string) =>
|
const hash = (file: string) =>
|
||||||
createHash('sha256')
|
createHash('sha256').update(readFileSync(join(DINGS, file))).digest('hex').slice(0, 12);
|
||||||
.update(readFileSync(join(DINGS, file)))
|
|
||||||
.digest('hex')
|
|
||||||
.slice(0, 12);
|
|
||||||
expect(Object.keys(V4_SHA).length).toBe(VOICES.length * MOTIFS.length);
|
expect(Object.keys(V4_SHA).length).toBe(VOICES.length * MOTIFS.length);
|
||||||
Object.entries(V4_SHA).forEach(([k, want]) => expect(hash(`senders/${k}.ogg`)).toBe(want));
|
Object.entries(V4_SHA).forEach(([k, want]) => expect(hash(`senders/${k}.ogg`)).toBe(want));
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,16 @@
|
||||||
import React, {
|
import React, { MouseEventHandler, ReactNode, useCallback, useMemo, useRef } from 'react';
|
||||||
ComponentProps,
|
|
||||||
MouseEventHandler,
|
|
||||||
ReactNode,
|
|
||||||
useCallback,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
} from 'react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
||||||
import { Avatar, Box, Chip, Icon, IconButton, Icons, Spinner, Text, color } from 'folds';
|
import { Avatar, Box, Chip, Icon, IconButton, Icons, Spinner, Text, color, config } from 'folds';
|
||||||
import { PageNav, PageNavContent, PageNavHeader } from '../../components/page';
|
import { PageNav, PageNavContent, PageNavHeader } from '../../components/page';
|
||||||
import {
|
import {
|
||||||
NavButton,
|
NavButton,
|
||||||
NavCategory,
|
NavCategory,
|
||||||
NavCategoryHeader,
|
NavCategoryHeader,
|
||||||
NavGutter,
|
|
||||||
NavItem,
|
NavItem,
|
||||||
NavItemContent,
|
NavItemContent,
|
||||||
NavLevel,
|
|
||||||
} from '../../components/nav';
|
} from '../../components/nav';
|
||||||
import { NAV_ROOM_ICON_REST_OPACITY, NavAction, NavLabel } from '../../components/nav/styles.css';
|
import { NavAction, NavLabel } from '../../components/nav/styles.css';
|
||||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||||
import { JoinAddressPrompt } from '../../components/join-address-prompt';
|
import { JoinAddressPrompt } from '../../components/join-address-prompt';
|
||||||
import { RoomNavCategoryButton, RoomNavItem } from '../room-nav';
|
import { RoomNavCategoryButton, RoomNavItem } from '../room-nav';
|
||||||
|
|
@ -36,9 +27,6 @@ import { _RoomSearchParams } from '../../pages/paths';
|
||||||
import { searchModalAtom } from '../../state/searchModal';
|
import { searchModalAtom } from '../../state/searchModal';
|
||||||
import { getCanonicalAliasOrRoomId } from '../../utils/matrix';
|
import { getCanonicalAliasOrRoomId } from '../../utils/matrix';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { MINDS } from '../../components/message/pose/poses';
|
|
||||||
import { MediaLink, MediaNote, useMindMedia } from './MediaRow';
|
|
||||||
import { MediaRow as MediaRowState } from './mediaRoom';
|
|
||||||
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
|
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
|
||||||
import { useCallEmbed } from '../../hooks/useCallEmbed';
|
import { useCallEmbed } from '../../hooks/useCallEmbed';
|
||||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||||
|
|
@ -57,8 +45,6 @@ import * as css from './EstateNavigator.css';
|
||||||
import { DeskEntry } from '../desk';
|
import { DeskEntry } from '../desk';
|
||||||
import { SkyStateControl } from '../scene/SkyStateControl';
|
import { SkyStateControl } from '../scene/SkyStateControl';
|
||||||
|
|
||||||
type IconSrc = ComponentProps<typeof Icon>['src'];
|
|
||||||
|
|
||||||
/** How long a tap waits for the client store to hold the room it just joined. */
|
/** How long a tap waits for the client store to hold the room it just joined. */
|
||||||
const JOIN_ARRIVAL_TIMEOUT_MS = 20000;
|
const JOIN_ARRIVAL_TIMEOUT_MS = 20000;
|
||||||
|
|
||||||
|
|
@ -72,7 +58,7 @@ const estateRoomPath = (mx: ReturnType<typeof useMatrixClient>, room: EstateRoom
|
||||||
return getHomeRoomPath(alias);
|
return getHomeRoomPath(alias);
|
||||||
};
|
};
|
||||||
|
|
||||||
function JoinRow({ room, icon = Icons.Hash }: { room: EstateRoom; icon?: IconSrc }) {
|
function JoinRow({ room }: { room: EstateRoom }) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [state, join] = useAsyncCallback(
|
const [state, join] = useAsyncCallback(
|
||||||
|
|
@ -91,14 +77,12 @@ function JoinRow({ room, icon = Icons.Hash }: { room: EstateRoom; icon?: IconSrc
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NavItem variant="Background" radii="400">
|
<NavItem variant="Background" radii="400">
|
||||||
<Box grow="Yes" alignItems="Center" gap="200" style={{ padding: '0 8px 0 0', minHeight: 38 }}>
|
<Box grow="Yes" alignItems="Center" gap="200" style={{ padding: '0 8px', minHeight: 38 }}>
|
||||||
<NavItemContent>
|
<NavItemContent>
|
||||||
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
||||||
<NavGutter>
|
<Avatar size="200" radii="400">
|
||||||
<Avatar size="200" radii="400">
|
<Icon src={Icons.Hash} size="100" style={{ opacity: config.opacity.P300 }} />
|
||||||
<Icon src={icon} size="100" style={{ opacity: NAV_ROOM_ICON_REST_OPACITY }} />
|
</Avatar>
|
||||||
</Avatar>
|
|
||||||
</NavGutter>
|
|
||||||
<Text className={NavLabel} as="span" size="Inherit" truncate>
|
<Text className={NavLabel} as="span" size="Inherit" truncate>
|
||||||
{room.name}
|
{room.name}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
@ -118,7 +102,7 @@ function JoinRow({ room, icon = Icons.Hash }: { room: EstateRoom; icon?: IconSrc
|
||||||
variant={room.state === 'invited' ? 'Primary' : 'Secondary'}
|
variant={room.state === 'invited' ? 'Primary' : 'Secondary'}
|
||||||
fill="Soft"
|
fill="Soft"
|
||||||
radii="Pill"
|
radii="Pill"
|
||||||
before={busy ? <Spinner size="100" /> : undefined}
|
before={busy ? <Spinner size="100" /> : <Icon src={Icons.Plus} size="50" />}
|
||||||
onClick={join}
|
onClick={join}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
aria-label={`Join ${room.name}`}
|
aria-label={`Join ${room.name}`}
|
||||||
|
|
@ -152,9 +136,11 @@ type CollapsibleProps = {
|
||||||
categoryId: string;
|
categoryId: string;
|
||||||
label: string;
|
label: string;
|
||||||
rooms: EstateRoom[];
|
rooms: EstateRoom[];
|
||||||
|
/** Sits in the header beside the label, so a collapsed section still shows it. */
|
||||||
|
trailing?: ReactNode;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
function Collapsible({ categoryId, label, rooms, children }: CollapsibleProps) {
|
function Collapsible({ categoryId, label, rooms, trailing, children }: CollapsibleProps) {
|
||||||
const mobile = useScreenSizeContext() === ScreenSize.Mobile;
|
const mobile = useScreenSizeContext() === ScreenSize.Mobile;
|
||||||
const selectedRoomId = useSelectedRoom();
|
const selectedRoomId = useSelectedRoom();
|
||||||
const roomToUnread = useAtomValue(roomToUnreadAtom);
|
const roomToUnread = useAtomValue(roomToUnreadAtom);
|
||||||
|
|
@ -178,33 +164,22 @@ function Collapsible({ categoryId, label, rooms, children }: CollapsibleProps) {
|
||||||
<RoomNavCategoryButton closed={closed} onClick={handleClick}>
|
<RoomNavCategoryButton closed={closed} onClick={handleClick}>
|
||||||
{label}
|
{label}
|
||||||
</RoomNavCategoryButton>
|
</RoomNavCategoryButton>
|
||||||
|
{trailing}
|
||||||
</NavCategoryHeader>
|
</NavCategoryHeader>
|
||||||
{!closed && <NavLevel>{children}</NavLevel>}
|
{!closed && children}
|
||||||
</NavCategory>
|
</NavCategory>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The Media row every Mind in the roster has: its file-zone room, or an honest note that there is none yet. */
|
|
||||||
function MindMedia({ media }: { media: MediaRowState }) {
|
|
||||||
const mx = useMatrixClient();
|
|
||||||
if (media.kind === 'checking') return <MediaNote note="looking" />;
|
|
||||||
if (media.kind === 'unset') return <MediaNote note="not set up" />;
|
|
||||||
if (media.kind === 'error') return <MediaNote note="unavailable" title={media.message} />;
|
|
||||||
if (media.room.state === 'joined') return <MediaLink to={estateRoomPath(mx, media.room)} />;
|
|
||||||
return <JoinRow room={media.room} icon={Icons.Photo} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function GroupSection({ tierId, group }: { tierId: string; group: EstateGroup }) {
|
function GroupSection({ tierId, group }: { tierId: string; group: EstateGroup }) {
|
||||||
const media = useMindMedia(group.mind, group.rooms);
|
|
||||||
const hasMedia = MINDS.includes(group.mind);
|
|
||||||
// The media room is the Media row; it is not listed a second time among the Mind's rooms.
|
|
||||||
const rooms =
|
|
||||||
media.kind === 'room' ? group.rooms.filter((r) => r.roomId !== media.room.roomId) : group.rooms;
|
|
||||||
return (
|
return (
|
||||||
<Collapsible categoryId={`${tierId}|${group.mind}`} label={group.label} rooms={group.rooms}>
|
<Collapsible
|
||||||
<DeskEntry mind={group.mind} />
|
categoryId={`${tierId}|${group.mind}`}
|
||||||
{hasMedia && <MindMedia media={media} />}
|
label={group.label}
|
||||||
{rooms.map((r) => (
|
rooms={group.rooms}
|
||||||
|
trailing={<DeskEntry mind={group.mind} />}
|
||||||
|
>
|
||||||
|
{group.rooms.map((r) => (
|
||||||
<EstateRow key={r.roomId} room={r} />
|
<EstateRow key={r.roomId} room={r} />
|
||||||
))}
|
))}
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
|
|
@ -213,14 +188,11 @@ function GroupSection({ tierId, group }: { tierId: string; group: EstateGroup })
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Groups of rooms the user has not joined, behind one quiet entry. Closed until opened, on every screen; the opening is
|
* Groups of rooms the user has not joined, behind one quiet entry. Closed until opened, on every screen; the opening is
|
||||||
* remembered per device and screen class like any other section.
|
* remembered per device like any other section.
|
||||||
*/
|
*/
|
||||||
function Browse({ tierId, groups }: { tierId: string; groups: EstateGroup[] }) {
|
function Browse({ tierId, groups }: { tierId: string; groups: EstateGroup[] }) {
|
||||||
const mobile = useScreenSizeContext() === ScreenSize.Mobile;
|
|
||||||
const [toggled, setToggled] = useAtom(useClosedNavCategoriesAtom());
|
const [toggled, setToggled] = useAtom(useClosedNavCategoriesAtom());
|
||||||
// The store holds sections flipped from their default (it keeps Cinny's name, and its localStorage key). Browse's
|
const key = makeNavCategoryId('estate', 'browse', tierId);
|
||||||
// default is closed, so a key here means open; like Collapsible it is kept per screen class.
|
|
||||||
const key = makeNavCategoryId('estate', mobile ? 'phone' : 'wide', 'browse', tierId);
|
|
||||||
const open = toggled.has(key);
|
const open = toggled.has(key);
|
||||||
const count = groups.reduce((n, g) => n + g.rooms.length, 0);
|
const count = groups.reduce((n, g) => n + g.rooms.length, 0);
|
||||||
|
|
||||||
|
|
@ -234,13 +206,7 @@ function Browse({ tierId, groups }: { tierId: string; groups: EstateGroup[] }) {
|
||||||
{`Browse ${count} more ${count === 1 ? 'room' : 'rooms'}`}
|
{`Browse ${count} more ${count === 1 ? 'room' : 'rooms'}`}
|
||||||
</RoomNavCategoryButton>
|
</RoomNavCategoryButton>
|
||||||
</NavCategoryHeader>
|
</NavCategoryHeader>
|
||||||
{open && (
|
{open && groups.map((g) => <GroupSection key={g.mind} tierId={tierId} group={g} />)}
|
||||||
<NavLevel>
|
|
||||||
{groups.map((g) => (
|
|
||||||
<GroupSection key={g.mind} tierId={tierId} group={g} />
|
|
||||||
))}
|
|
||||||
</NavLevel>
|
|
||||||
)}
|
|
||||||
</NavCategory>
|
</NavCategory>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -269,15 +235,9 @@ function EntryPoints() {
|
||||||
<NavButton onClick={() => navigate(getHomeCreatePath())}>
|
<NavButton onClick={() => navigate(getHomeCreatePath())}>
|
||||||
<NavItemContent>
|
<NavItemContent>
|
||||||
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
||||||
<NavGutter>
|
<Avatar size="200" radii="400">
|
||||||
<Avatar size="200" radii="400">
|
<Icon src={Icons.Plus} size="100" />
|
||||||
<Icon
|
</Avatar>
|
||||||
src={Icons.Plus}
|
|
||||||
size="100"
|
|
||||||
style={{ opacity: NAV_ROOM_ICON_REST_OPACITY }}
|
|
||||||
/>
|
|
||||||
</Avatar>
|
|
||||||
</NavGutter>
|
|
||||||
<Text as="span" size="Inherit" truncate>
|
<Text as="span" size="Inherit" truncate>
|
||||||
Create Room
|
Create Room
|
||||||
</Text>
|
</Text>
|
||||||
|
|
@ -292,15 +252,9 @@ function EntryPoints() {
|
||||||
<NavButton onClick={() => setOpen(true)}>
|
<NavButton onClick={() => setOpen(true)}>
|
||||||
<NavItemContent>
|
<NavItemContent>
|
||||||
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
||||||
<NavGutter>
|
<Avatar size="200" radii="400">
|
||||||
<Avatar size="200" radii="400">
|
<Icon src={Icons.Link} size="100" />
|
||||||
<Icon
|
</Avatar>
|
||||||
src={Icons.Link}
|
|
||||||
size="100"
|
|
||||||
style={{ opacity: NAV_ROOM_ICON_REST_OPACITY }}
|
|
||||||
/>
|
|
||||||
</Avatar>
|
|
||||||
</NavGutter>
|
|
||||||
<Text as="span" size="Inherit" truncate>
|
<Text as="span" size="Inherit" truncate>
|
||||||
Join with Address
|
Join with Address
|
||||||
</Text>
|
</Text>
|
||||||
|
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { renderToStaticMarkup } from 'react-dom/server';
|
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { MediaLink, MediaNote } from './MediaRow';
|
|
||||||
|
|
||||||
const text = (html: string) => html.replace(/<[^>]*>/g, '');
|
|
||||||
|
|
||||||
describe('Media row', () => {
|
|
||||||
it('a Mind without a media room reads "not set up" and links nowhere', () => {
|
|
||||||
const html = renderToStaticMarkup(
|
|
||||||
<MemoryRouter>
|
|
||||||
<MediaNote note="not set up" />
|
|
||||||
</MemoryRouter>
|
|
||||||
);
|
|
||||||
expect(text(html)).toBe('Media · not set up');
|
|
||||||
expect(html).not.toContain('href=');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a joined media room reads "Media" and opens the room', () => {
|
|
||||||
const html = renderToStaticMarkup(
|
|
||||||
<MemoryRouter>
|
|
||||||
<MediaLink to="/home/%23void-files%3Agamecult.org/" />
|
|
||||||
</MemoryRouter>
|
|
||||||
);
|
|
||||||
expect(text(html)).toBe('Media');
|
|
||||||
expect(html).toContain('href="/home/%23void-files%3Agamecult.org/"');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,107 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { useAtomValue } from 'jotai';
|
|
||||||
import { Avatar, 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 { EstateRoom } from './buildEstate';
|
|
||||||
import { isFileZone } from '../albums/isFileZone';
|
|
||||||
import { useClientConfig } from '../../hooks/useClientConfig';
|
|
||||||
import { MINDS } from '../../components/message/pose/poses';
|
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|
||||||
import { allRoomsAtom } from '../../state/room-list/roomList';
|
|
||||||
import { allInvitesAtom } from '../../state/room-list/inviteList';
|
|
||||||
import { useEstateRootId } from './useEstate';
|
|
||||||
import {
|
|
||||||
STEWARDSHIPS_KEY,
|
|
||||||
MediaRow as MediaRowState,
|
|
||||||
lookupMediaRoom,
|
|
||||||
mediaRoomIdFromStewardships,
|
|
||||||
mediaRow,
|
|
||||||
typedMediaRoom,
|
|
||||||
} from './mediaRoom';
|
|
||||||
|
|
||||||
const mediaIcon = (
|
|
||||||
<Avatar size="200" radii="400">
|
|
||||||
<Icon src={Icons.Photo} size="100" style={{ opacity: NAV_ROOM_ICON_REST_OPACITY }} />
|
|
||||||
</Avatar>
|
|
||||||
);
|
|
||||||
|
|
||||||
/** A Media row that is not a link: not set up, still looking, or the lookup failed. Says so; shows no data. */
|
|
||||||
export function MediaNote({ note, title }: { note: string; title?: string }) {
|
|
||||||
return (
|
|
||||||
<NavItem variant="Background" radii="400">
|
|
||||||
<NavItemContent>
|
|
||||||
<Box as="span" grow="Yes" alignItems="Center" gap="200" title={title}>
|
|
||||||
<NavGutter>{mediaIcon}</NavGutter>
|
|
||||||
<Text className={NavLabel} as="span" size="Inherit" truncate priority="300">
|
|
||||||
{`Media · ${note}`}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</NavItemContent>
|
|
||||||
</NavItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The joined Media row: opens the room, which shows its album view. */
|
|
||||||
export function MediaLink({ to }: { to: string }) {
|
|
||||||
return (
|
|
||||||
<NavItem variant="Background" radii="400">
|
|
||||||
<NavLink to={to}>
|
|
||||||
<NavItemContent>
|
|
||||||
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
|
||||||
<NavGutter>{mediaIcon}</NavGutter>
|
|
||||||
<Text className={NavLabel} as="span" size="Inherit" truncate>
|
|
||||||
Media
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</NavItemContent>
|
|
||||||
</NavLink>
|
|
||||||
</NavItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The Mind's media room as the navigator sees it right now. */
|
|
||||||
export const useMindMedia = (mind: string, rooms: readonly EstateRoom[]): MediaRowState => {
|
|
||||||
const mx = useMatrixClient();
|
|
||||||
const { rootId } = useEstateRootId();
|
|
||||||
const joined = useAtomValue(allRoomsAtom);
|
|
||||||
const invited = useAtomValue(allInvitesAtom);
|
|
||||||
const config = useClientConfig();
|
|
||||||
const server = mx.getDomain() ?? '';
|
|
||||||
// A joined room whose type says it is a file zone is the Media row; the alias or carried id only finds a
|
|
||||||
// room the client cannot type yet (not joined).
|
|
||||||
const typed = typedMediaRoom(rooms, (roomId) => {
|
|
||||||
const room = mx.getRoom(roomId);
|
|
||||||
return (
|
|
||||||
!!room &&
|
|
||||||
isFileZone(
|
|
||||||
{
|
|
||||||
roomId,
|
|
||||||
aliases: [room.getCanonicalAlias(), ...room.getAltAliases()].filter(
|
|
||||||
(a): a is string => !!a
|
|
||||||
),
|
|
||||||
createType: room.currentState.getStateEvents('m.room.create', '')?.getContent().type,
|
|
||||||
},
|
|
||||||
config
|
|
||||||
)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
const carried = mediaRoomIdFromStewardships(
|
|
||||||
rootId
|
|
||||||
? mx.getRoom(rootId)?.currentState.getStateEvents(STEWARDSHIPS_KEY, '')?.getContent()
|
|
||||||
: undefined,
|
|
||||||
mind
|
|
||||||
);
|
|
||||||
const lookup = useQuery({
|
|
||||||
queryKey: ['mind-media', mind, server, carried],
|
|
||||||
queryFn: () => lookupMediaRoom(mx, mind, server, carried),
|
|
||||||
staleTime: 60_000,
|
|
||||||
enabled: MINDS.includes(mind) && !typed,
|
|
||||||
});
|
|
||||||
const membership = new Map<string, 'join' | 'invite'>();
|
|
||||||
invited.forEach((id) => membership.set(id, 'invite'));
|
|
||||||
joined.forEach((id) => membership.set(id, 'join'));
|
|
||||||
if (typed) return { kind: 'room', room: { ...typed, name: 'Media' } };
|
|
||||||
return mediaRow(lookup.data, membership);
|
|
||||||
};
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue