Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16ccd2132e | ||
|
|
da395cc0bf |
206 changed files with 547 additions and 6195 deletions
|
|
@ -25,14 +25,6 @@
|
||||||
"dings": {
|
"dings": {
|
||||||
"enabled": false
|
"enabled": false
|
||||||
},
|
},
|
||||||
"fileZoneShare": {
|
|
||||||
"enabled": false
|
|
||||||
},
|
|
||||||
"push": {
|
|
||||||
"vapidPublicKey": "",
|
|
||||||
"gatewayUrl": "",
|
|
||||||
"appId": ""
|
|
||||||
},
|
|
||||||
"registration": {
|
"registration": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"url": ""
|
"url": ""
|
||||||
|
|
|
||||||
|
|
@ -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,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,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' },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -299,10 +299,7 @@ export const BubbleLeftArrow = style({
|
||||||
});
|
});
|
||||||
|
|
||||||
// Phone tap target: a sender name is 23 px tall, so the button grows 11 px above and below (44+ px to tap) and the
|
// 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 +309,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': {
|
||||||
|
|
|
||||||
|
|
@ -53,10 +53,6 @@ export const NavCategoryHeader = style({
|
||||||
'&&': { paddingLeft: 0 },
|
'&&': { 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 +117,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: {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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' });
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ 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 { 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';
|
||||||
|
|
||||||
|
|
@ -94,7 +93,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 +134,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,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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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(', ')}`;
|
|
||||||
};
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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,14 @@
|
||||||
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 { 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 +66,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(
|
||||||
|
|
@ -163,15 +145,7 @@ describe("a question's age", () => {
|
||||||
expect(html('2026-11-11T11:40:00Z')).toContain('raised just now');
|
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', () => {
|
it('shows no age when the card carries none, or an instant that does not parse or is ahead of the clock', () => {
|
||||||
[
|
['null', 'yesterday', '2027-01-01T00:00:00Z', '2026-11-06', 'Nov 6 2026', '0', '2026-02-30T00:00:00Z'].forEach((v) => {
|
||||||
'null',
|
|
||||||
'yesterday',
|
|
||||||
'2027-01-01T00:00:00Z',
|
|
||||||
'2026-11-06',
|
|
||||||
'Nov 6 2026',
|
|
||||||
'0',
|
|
||||||
'2026-02-30T00:00:00Z',
|
|
||||||
].forEach((v) => {
|
|
||||||
expect(html(v === 'null' ? null : v)).not.toContain('raised ');
|
expect(html(v === 'null' ? null : v)).not.toContain('raised ');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,6 @@ 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 { raisedAge } from './age';
|
||||||
|
|
@ -146,7 +144,6 @@ type DeskViewProps = {
|
||||||
/** One Mind's desk, read-only: a stream of rows, the recorded rulings, and the booth Persona. */
|
/** 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, now = Date.now() }: 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 (
|
||||||
|
|
@ -169,7 +166,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 +175,7 @@ export function DeskView({ mind, label, list, unverified, now = Date.now() }: De
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { allRoomsAtom } from '../../state/room-list/roomList';
|
import { allRoomsAtom } from '../../state/room-list/roomList';
|
||||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||||
import { BackRouteHandler } from '../../components/BackRouteHandler';
|
import { BackRouteHandler } from '../../components/BackRouteHandler';
|
||||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
|
||||||
|
|
||||||
type JoinBeforeNavigateProps = { roomIdOrAlias: string; eventId?: string; viaServers?: string[] };
|
type JoinBeforeNavigateProps = { roomIdOrAlias: string; eventId?: string; viaServers?: string[] };
|
||||||
export function JoinBeforeNavigate({
|
export function JoinBeforeNavigate({
|
||||||
|
|
@ -39,7 +38,7 @@ export function JoinBeforeNavigate({
|
||||||
{screenSize === ScreenSize.Mobile && (
|
{screenSize === ScreenSize.Mobile && (
|
||||||
<BackRouteHandler>
|
<BackRouteHandler>
|
||||||
{(onBack) => (
|
{(onBack) => (
|
||||||
<IconButton className={PhoneHit} onClick={onBack}>
|
<IconButton onClick={onBack}>
|
||||||
<Icon src={Icons.ArrowLeft} />
|
<Icon src={Icons.ArrowLeft} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,6 @@ import { IPowerLevels } from '../../hooks/usePowerLevels';
|
||||||
import { getRoomCreatorsForRoomId } from '../../hooks/useRoomCreators';
|
import { getRoomCreatorsForRoomId } from '../../hooks/useRoomCreators';
|
||||||
import { getRoomPermissionsAPI } from '../../hooks/useRoomPermissions';
|
import { getRoomPermissionsAPI } from '../../hooks/useRoomPermissions';
|
||||||
import { InviteUserPrompt } from '../../components/invite-user-prompt';
|
import { InviteUserPrompt } from '../../components/invite-user-prompt';
|
||||||
import { PhoneHitSpread24 } from '../../styles/PhoneHit.css';
|
|
||||||
|
|
||||||
type HierarchyItemWithParent = HierarchyItem & {
|
type HierarchyItemWithParent = HierarchyItem & {
|
||||||
parentId: string;
|
parentId: string;
|
||||||
|
|
@ -232,7 +231,6 @@ export function HierarchyItemMenu({
|
||||||
return (
|
return (
|
||||||
<Box gap="200" alignItems="Center" shrink="No">
|
<Box gap="200" alignItems="Center" shrink="No">
|
||||||
<IconButton
|
<IconButton
|
||||||
className={PhoneHitSpread24}
|
|
||||||
onClick={handleOpenMenu}
|
onClick={handleOpenMenu}
|
||||||
size="300"
|
size="300"
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,6 @@ import { useGetRoom } from '../../hooks/useGetRoom';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
import { getRoomPermissionsAPI } from '../../hooks/useRoomPermissions';
|
import { getRoomPermissionsAPI } from '../../hooks/useRoomPermissions';
|
||||||
import { getRoomCreatorsForRoomId } from '../../hooks/useRoomCreators';
|
import { getRoomCreatorsForRoomId } from '../../hooks/useRoomCreators';
|
||||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
|
||||||
|
|
||||||
const useCanDropLobbyItem = (
|
const useCanDropLobbyItem = (
|
||||||
space: Room,
|
space: Room,
|
||||||
|
|
@ -414,7 +413,6 @@ export function Lobby() {
|
||||||
onVisibilityChange={setOnTop}
|
onVisibilityChange={setOnTop}
|
||||||
>
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
className={PhoneHit}
|
|
||||||
onClick={() => virtualizer.scrollToOffset(0)}
|
onClick={() => virtualizer.scrollToOffset(0)}
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
radii="Pill"
|
radii="Pill"
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,6 @@ import { useOpenSpaceSettings } from '../../state/hooks/spaceSettings';
|
||||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||||
import { InviteUserPrompt } from '../../components/invite-user-prompt';
|
import { InviteUserPrompt } from '../../components/invite-user-prompt';
|
||||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
|
||||||
|
|
||||||
type LobbyMenuProps = {
|
type LobbyMenuProps = {
|
||||||
powerLevels: IPowerLevels;
|
powerLevels: IPowerLevels;
|
||||||
|
|
@ -166,7 +165,7 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
|
||||||
<Box shrink="No">
|
<Box shrink="No">
|
||||||
<BackRouteHandler>
|
<BackRouteHandler>
|
||||||
{(onBack) => (
|
{(onBack) => (
|
||||||
<IconButton className={PhoneHit} fill="None" onClick={onBack}>
|
<IconButton fill="None" onClick={onBack}>
|
||||||
<Icon src={Icons.ArrowLeft} />
|
<Icon src={Icons.ArrowLeft} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
|
|
@ -241,7 +240,6 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
|
||||||
>
|
>
|
||||||
{(triggerRef) => (
|
{(triggerRef) => (
|
||||||
<IconButton
|
<IconButton
|
||||||
className={PhoneHit}
|
|
||||||
fill="None"
|
fill="None"
|
||||||
onClick={handleOpenMenu}
|
onClick={handleOpenMenu}
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,7 @@ export function LobbyHero() {
|
||||||
const name = useRoomName(space);
|
const name = useRoomName(space);
|
||||||
const topic = useRoomTopic(space);
|
const topic = useRoomTopic(space);
|
||||||
const avatarMxc = useRoomAvatar(space);
|
const avatarMxc = useRoomAvatar(space);
|
||||||
const avatarUrl = avatarMxc
|
const avatarUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined : undefined;
|
||||||
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageHero
|
<PageHero
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ import { SearchResultGroup } from './SearchResultGroup';
|
||||||
import { SearchInput } from './SearchInput';
|
import { SearchInput } from './SearchInput';
|
||||||
import { SearchFilters } from './SearchFilters';
|
import { SearchFilters } from './SearchFilters';
|
||||||
import { VirtualTile } from '../../components/virtualizer';
|
import { VirtualTile } from '../../components/virtualizer';
|
||||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
|
||||||
|
|
||||||
const useSearchPathSearchParams = (searchParams: URLSearchParams): _SearchPathSearchParams =>
|
const useSearchPathSearchParams = (searchParams: URLSearchParams): _SearchPathSearchParams =>
|
||||||
useMemo(
|
useMemo(
|
||||||
|
|
@ -194,7 +193,6 @@ export function MessageSearch({
|
||||||
<Box direction="Column" gap="700">
|
<Box direction="Column" gap="700">
|
||||||
<ScrollTopContainer scrollRef={scrollRef} anchorRef={scrollTopAnchorRef}>
|
<ScrollTopContainer scrollRef={scrollRef} anchorRef={scrollTopAnchorRef}>
|
||||||
<IconButton
|
<IconButton
|
||||||
className={PhoneHit}
|
|
||||||
onClick={() => virtualizer.scrollToOffset(0)}
|
onClick={() => virtualizer.scrollToOffset(0)}
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
radii="Pill"
|
radii="Pill"
|
||||||
|
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
import React, { useCallback, useEffect, useState } from 'react';
|
|
||||||
import { Switch, Text, color } from 'folds';
|
|
||||||
import { SettingTile } from '../../components/setting-tile';
|
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|
||||||
import { getNotificationState, usePermissionState } from '../../hooks/usePermission';
|
|
||||||
import type { ActivePushConfig } from './config';
|
|
||||||
import { pushSessionToSW } from '../../../sw-session';
|
|
||||||
import { disablePush, enablePush, pushIsOn } from './pusher';
|
|
||||||
|
|
||||||
export const pushSupported = (): boolean =>
|
|
||||||
'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window;
|
|
||||||
|
|
||||||
const readyManager = async () => (await navigator.serviceWorker.ready).pushManager;
|
|
||||||
|
|
||||||
/** Background notifications through the installed app's service worker. The caller renders it only when the host's config fills the push block and the browser can. */
|
|
||||||
export function PushNotification({ cfg }: { cfg: ActivePushConfig }) {
|
|
||||||
const mx = useMatrixClient();
|
|
||||||
const permission = usePermissionState('notifications', getNotificationState());
|
|
||||||
const [on, setOn] = useState(false);
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const [error, setError] = useState<string>();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
readyManager()
|
|
||||||
.then((m) => pushIsOn(mx, m, cfg))
|
|
||||||
.then(setOn)
|
|
||||||
.catch(() => undefined);
|
|
||||||
}, [cfg, mx]);
|
|
||||||
|
|
||||||
const toggle = useCallback(
|
|
||||||
async (enable: boolean) => {
|
|
||||||
setBusy(true);
|
|
||||||
setError(undefined);
|
|
||||||
try {
|
|
||||||
if (enable) {
|
|
||||||
if ((await window.Notification.requestPermission()) !== 'granted') {
|
|
||||||
throw new Error('Notification permission was not granted.');
|
|
||||||
}
|
|
||||||
await enablePush(mx, await readyManager(), cfg, navigator.userAgent.slice(0, 64));
|
|
||||||
} else {
|
|
||||||
await disablePush(mx, await readyManager(), cfg);
|
|
||||||
}
|
|
||||||
// The worker keeps the token only while a subscription exists: tell it the new state.
|
|
||||||
pushSessionToSW(mx.baseUrl, mx.getAccessToken() ?? undefined);
|
|
||||||
setOn(enable);
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : 'Could not change background notifications.');
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[cfg, mx]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingTile
|
|
||||||
title="Background Notifications"
|
|
||||||
description={
|
|
||||||
error || permission === 'denied' ? (
|
|
||||||
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
|
|
||||||
{error ?? 'Notification permission is blocked. Allow it from the browser address bar.'}
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<span>
|
|
||||||
Notify when the app is closed. Installing the app to the home screen helps on iOS.
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
after={<Switch disabled={busy || permission === 'denied'} value={on} onChange={toggle} />}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
import { readFileSync } from 'node:fs';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { activePushConfig } from './config';
|
|
||||||
|
|
||||||
describe('activePushConfig', () => {
|
|
||||||
it('is off unless all three keys are filled', () => {
|
|
||||||
expect(activePushConfig(undefined)).toBeUndefined();
|
|
||||||
expect(activePushConfig({})).toBeUndefined();
|
|
||||||
expect(
|
|
||||||
activePushConfig({ vapidPublicKey: 'k', gatewayUrl: 'https://g/_matrix/push/v1/notify' })
|
|
||||||
).toBeUndefined();
|
|
||||||
expect(activePushConfig({ vapidPublicKey: 'k', gatewayUrl: 'u', appId: 'a' })).toEqual({
|
|
||||||
vapidPublicKey: 'k',
|
|
||||||
gatewayUrl: 'u',
|
|
||||||
appId: 'a',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
it('the shipped config.json carries the block empty, so push is off', () => {
|
|
||||||
const cfg = JSON.parse(readFileSync(join(__dirname, '../../../../config.json'), 'utf8'));
|
|
||||||
expect(cfg.push).toEqual({ vapidPublicKey: '', gatewayUrl: '', appId: '' });
|
|
||||||
expect(activePushConfig(cfg.push)).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
// Web Push is off unless the host's config.json fills all three keys (ships empty). The public key is not a secret;
|
|
||||||
// the gateway URL is the push gateway's notify endpoint the homeserver calls (`/_matrix/push/v1/notify`).
|
|
||||||
export type PushConfig = {
|
|
||||||
vapidPublicKey?: string;
|
|
||||||
gatewayUrl?: string;
|
|
||||||
appId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ActivePushConfig = Required<PushConfig>;
|
|
||||||
|
|
||||||
export const activePushConfig = (c?: PushConfig): ActivePushConfig | undefined =>
|
|
||||||
c?.vapidPublicKey && c.gatewayUrl && c.appId ? (c as ActivePushConfig) : undefined;
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
import { existsSync, readFileSync } from 'node:fs';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
const ROOT = join(__dirname, '../../../..');
|
|
||||||
const manifest = JSON.parse(readFileSync(join(ROOT, 'public/manifest.json'), 'utf8'));
|
|
||||||
|
|
||||||
describe('web app manifest', () => {
|
|
||||||
it('has a stable identity and scope, and no orientation lock', () => {
|
|
||||||
expect(manifest.id).toBe('/'); // what Chrome already derives from start_url "./" at the site root
|
|
||||||
expect(manifest.scope).toBe('./');
|
|
||||||
expect(manifest.start_url).toBe('./');
|
|
||||||
expect(manifest.display).toBe('standalone');
|
|
||||||
expect(manifest.orientation).toBeUndefined();
|
|
||||||
});
|
|
||||||
it('offers 192 and 512 icons for any use and a 512 maskable one, every file present', () => {
|
|
||||||
const icons = manifest.icons as { src: string; sizes: string; purpose?: string }[];
|
|
||||||
const any = icons.filter((i) => !i.purpose);
|
|
||||||
expect(any.map((i) => i.sizes)).toEqual(expect.arrayContaining(['192x192', '512x512']));
|
|
||||||
const maskable = icons.filter((i) => i.purpose === 'maskable');
|
|
||||||
expect(maskable.map((i) => i.sizes)).toEqual(['512x512']);
|
|
||||||
// the build copies public/res/android to public/android (vite.config.js)
|
|
||||||
icons.forEach((i) =>
|
|
||||||
expect(
|
|
||||||
existsSync(join(ROOT, i.src.replace('./public/android/', 'public/res/android/')))
|
|
||||||
).toBe(true)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,108 +0,0 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { describePush, GENERIC, parsePush } from './payload';
|
|
||||||
import { matrixNotify, mockHomeserver, SESSION } from './testkit';
|
|
||||||
|
|
||||||
describe('parsePush', () => {
|
|
||||||
it('reads the Matrix gateway body and the bare inner object', () => {
|
|
||||||
const a = parsePush(matrixNotify('!r:x', '$e'));
|
|
||||||
expect(a).toMatchObject({ roomId: '!r:x', eventId: '$e' });
|
|
||||||
expect(parsePush({ room_id: '!r:x', event_id: '$e' })).toMatchObject({ roomId: '!r:x' });
|
|
||||||
});
|
|
||||||
it('rejects a body with no room id', () => {
|
|
||||||
expect(parsePush({ notification: { event_id: '$e' } })).toBeUndefined();
|
|
||||||
expect(parsePush(undefined)).toBeUndefined();
|
|
||||||
expect(parsePush('x')).toBeUndefined();
|
|
||||||
});
|
|
||||||
it('carries text the gateway added', () => {
|
|
||||||
expect(
|
|
||||||
parsePush({
|
|
||||||
notification: {
|
|
||||||
room_id: '!r:x',
|
|
||||||
room_name: 'Lobby',
|
|
||||||
sender_display_name: 'Ann',
|
|
||||||
content: { body: 'hi' },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
).toMatchObject({ roomName: 'Lobby', sender: 'Ann', text: 'hi' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('describePush', () => {
|
|
||||||
const ids = { roomId: '!r:x', eventId: '$e' };
|
|
||||||
const event = {
|
|
||||||
type: 'm.room.message',
|
|
||||||
sender: '@ann:x',
|
|
||||||
content: { msgtype: 'm.text', body: 'hello there' },
|
|
||||||
};
|
|
||||||
|
|
||||||
it('resolves ids through the homeserver with the stored token', async () => {
|
|
||||||
const hs = mockHomeserver({
|
|
||||||
token: 'tok-1',
|
|
||||||
events: { $e: event },
|
|
||||||
roomNames: { '!r:x': 'Lobby' },
|
|
||||||
});
|
|
||||||
expect(await describePush(ids, SESSION, hs.fetchFn)).toEqual({
|
|
||||||
title: 'Lobby',
|
|
||||||
body: 'ann: hello there',
|
|
||||||
});
|
|
||||||
expect(hs.seen).toHaveLength(2);
|
|
||||||
expect(
|
|
||||||
hs.seen.every(
|
|
||||||
(r) => r.auth === 'Bearer tok-1' && r.url.startsWith('https://matrix.example/_matrix/')
|
|
||||||
)
|
|
||||||
).toBe(true);
|
|
||||||
expect(hs.seen[0].url).toContain('/rooms/!r%3Ax/');
|
|
||||||
});
|
|
||||||
it('shows the generic notification with no session, and asks nobody', async () => {
|
|
||||||
const hs = mockHomeserver({ token: 'tok-1' });
|
|
||||||
expect(await describePush(ids, undefined, hs.fetchFn)).toEqual({
|
|
||||||
title: 'Ratatoskr',
|
|
||||||
body: 'New message',
|
|
||||||
});
|
|
||||||
expect(hs.seen).toHaveLength(0);
|
|
||||||
});
|
|
||||||
it('falls back when the token is refused, the network is down, or the event is encrypted', async () => {
|
|
||||||
const stale = mockHomeserver({ token: 'other', events: { $e: event } });
|
|
||||||
expect(await describePush(ids, SESSION, stale.fetchFn)).toEqual(GENERIC);
|
|
||||||
const down = mockHomeserver({ token: 'tok-1', failAll: true });
|
|
||||||
expect(await describePush(ids, SESSION, down.fetchFn)).toEqual(GENERIC);
|
|
||||||
const enc = mockHomeserver({
|
|
||||||
token: 'tok-1',
|
|
||||||
events: {
|
|
||||||
$e: {
|
|
||||||
type: 'm.room.encrypted',
|
|
||||||
sender: '@ann:x',
|
|
||||||
content: { algorithm: 'm.megolm.v1.aes-sha2' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
roomNames: { '!r:x': 'Lobby' },
|
|
||||||
});
|
|
||||||
expect(await describePush(ids, SESSION, enc.fetchFn)).toEqual({
|
|
||||||
title: 'Lobby',
|
|
||||||
body: 'New message',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
it('does not fetch what the push already carried', async () => {
|
|
||||||
const hs = mockHomeserver({ token: 'tok-1' });
|
|
||||||
const shown = await describePush(
|
|
||||||
{ ...ids, roomName: 'R', sender: 'Bo', text: 'yo' },
|
|
||||||
SESSION,
|
|
||||||
hs.fetchFn
|
|
||||||
);
|
|
||||||
expect(shown).toEqual({ title: 'R', body: 'Bo: yo' });
|
|
||||||
expect(hs.seen).toHaveLength(0);
|
|
||||||
});
|
|
||||||
it('clips a long body', async () => {
|
|
||||||
const hs = mockHomeserver({
|
|
||||||
token: 'tok-1',
|
|
||||||
events: { $e: { ...event, content: { body: 'x'.repeat(500) } } },
|
|
||||||
});
|
|
||||||
const shown = await describePush(ids, SESSION, hs.fetchFn);
|
|
||||||
expect(shown.body.length).toBeLessThanOrEqual(200);
|
|
||||||
expect(shown.body.endsWith('…')).toBe(true);
|
|
||||||
});
|
|
||||||
it('shows the generic notification when the push names no room', async () => {
|
|
||||||
const hs = mockHomeserver({ token: 'tok-1' });
|
|
||||||
expect(await describePush(undefined, SESSION, hs.fetchFn)).toEqual(GENERIC);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
import type { SessionRecord } from './sessionStore';
|
|
||||||
|
|
||||||
/** What a push names. The homeserver sends ids only (`push.include_content: false`); a gateway may add text. */
|
|
||||||
export interface PushIds {
|
|
||||||
roomId: string;
|
|
||||||
eventId?: string;
|
|
||||||
roomName?: string;
|
|
||||||
sender?: string;
|
|
||||||
text?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Shown {
|
|
||||||
title: string;
|
|
||||||
body: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined);
|
|
||||||
|
|
||||||
/** The Matrix push body is `{ notification: {...} }`; a gateway may forward the inner object. No room id: not ours to show. */
|
|
||||||
export function parsePush(raw: unknown): PushIds | undefined {
|
|
||||||
const top = (raw ?? {}) as Record<string, unknown>;
|
|
||||||
const n = (top.notification ?? top) as Record<string, unknown>;
|
|
||||||
const roomId = str(n.room_id);
|
|
||||||
if (!roomId) return undefined;
|
|
||||||
const content = (n.content ?? {}) as Record<string, unknown>;
|
|
||||||
return {
|
|
||||||
roomId,
|
|
||||||
eventId: str(n.event_id),
|
|
||||||
roomName: str(n.room_name),
|
|
||||||
sender: str(n.sender_display_name) ?? str(n.sender),
|
|
||||||
text: str(content.body),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GENERIC: Shown = { title: 'Ratatoskr', body: 'New message' };
|
|
||||||
const MAX_BODY = 200;
|
|
||||||
const clip = (s: string): string => (s.length > MAX_BODY ? `${s.slice(0, MAX_BODY - 1)}…` : s);
|
|
||||||
const localpart = (userId: string): string => userId.replace(/^@/, '').split(':')[0];
|
|
||||||
|
|
||||||
type Fetch = (url: string, init: RequestInit) => Promise<Response>;
|
|
||||||
|
|
||||||
async function getJson(
|
|
||||||
f: Fetch,
|
|
||||||
session: SessionRecord,
|
|
||||||
path: string,
|
|
||||||
timeoutMs: number
|
|
||||||
): Promise<Record<string, unknown> | undefined> {
|
|
||||||
try {
|
|
||||||
const res = await f(`${session.baseUrl.replace(/\/+$/, '')}${path}`, {
|
|
||||||
headers: { Authorization: `Bearer ${session.accessToken}` },
|
|
||||||
signal: AbortSignal.timeout(timeoutMs),
|
|
||||||
});
|
|
||||||
return res.ok ? ((await res.json()) as Record<string, unknown>) : undefined;
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Title and body for a push. Text the push carried wins. Otherwise, with a session, the event and the room's name are
|
|
||||||
* fetched (clear-text rooms; an encrypted event shows the generic body). Every failure ends at the generic notification:
|
|
||||||
* a push must always show something.
|
|
||||||
*/
|
|
||||||
export async function describePush(
|
|
||||||
ids: PushIds | undefined,
|
|
||||||
session: SessionRecord | undefined,
|
|
||||||
f: Fetch,
|
|
||||||
timeoutMs = 4000
|
|
||||||
): Promise<Shown> {
|
|
||||||
if (!ids) return GENERIC;
|
|
||||||
let { roomName, sender, text } = ids;
|
|
||||||
if (session && (!text || !roomName)) {
|
|
||||||
const room = encodeURIComponent(ids.roomId);
|
|
||||||
const [event, name] = await Promise.all([
|
|
||||||
!text && ids.eventId
|
|
||||||
? getJson(
|
|
||||||
f,
|
|
||||||
session,
|
|
||||||
`/_matrix/client/v3/rooms/${room}/event/${encodeURIComponent(ids.eventId)}`,
|
|
||||||
timeoutMs
|
|
||||||
)
|
|
||||||
: undefined,
|
|
||||||
!roomName
|
|
||||||
? getJson(f, session, `/_matrix/client/v3/rooms/${room}/state/m.room.name`, timeoutMs)
|
|
||||||
: undefined,
|
|
||||||
]);
|
|
||||||
const content = (event?.content ?? {}) as Record<string, unknown>;
|
|
||||||
if (event?.type === 'm.room.message') text = str(content.body);
|
|
||||||
if (!sender && str(event?.sender)) sender = localpart(event?.sender as string);
|
|
||||||
roomName = roomName ?? str(name?.name);
|
|
||||||
}
|
|
||||||
let { body } = GENERIC;
|
|
||||||
if (text) body = sender ? `${sender}: ${text}` : text;
|
|
||||||
return { title: roomName ?? GENERIC.title, body: clip(body) };
|
|
||||||
}
|
|
||||||
|
|
@ -1,143 +0,0 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import type { IPusherRequest } from 'matrix-js-sdk';
|
|
||||||
import {
|
|
||||||
disablePush,
|
|
||||||
enablePush,
|
|
||||||
PushManagerPort,
|
|
||||||
pushIsOn,
|
|
||||||
vapidKeyBytes,
|
|
||||||
webPushPusher,
|
|
||||||
} from './pusher';
|
|
||||||
|
|
||||||
const cfg = {
|
|
||||||
vapidPublicKey: 'BAUD',
|
|
||||||
gatewayUrl: 'https://matrix.example/_matrix/push/v1/notify',
|
|
||||||
appId: 'org.gamecult.ratatoskr.web',
|
|
||||||
};
|
|
||||||
const key = (...b: number[]) => Uint8Array.from(b).buffer;
|
|
||||||
|
|
||||||
function subscription(unsubscribed: string[], serverKey?: ArrayBuffer): PushSubscription {
|
|
||||||
return {
|
|
||||||
options: { applicationServerKey: serverKey ?? vapidKeyBytes(cfg.vapidPublicKey).buffer },
|
|
||||||
endpoint: 'https://push.example/send/abc',
|
|
||||||
getKey: (n: string) => (n === 'p256dh' ? key(251, 255, 1) : key(250, 2)),
|
|
||||||
unsubscribe: async () => {
|
|
||||||
unsubscribed.push('x');
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
} as unknown as PushSubscription;
|
|
||||||
}
|
|
||||||
|
|
||||||
function world() {
|
|
||||||
const calls: string[] = [];
|
|
||||||
const pushers: IPusherRequest[] = [];
|
|
||||||
let sub: PushSubscription | null = null;
|
|
||||||
const unsub: string[] = [];
|
|
||||||
const mx = {
|
|
||||||
setPusher: async (p: IPusherRequest) => {
|
|
||||||
calls.push(p.kind === null ? 'delete-pusher' : 'set-pusher');
|
|
||||||
pushers.push(p);
|
|
||||||
},
|
|
||||||
// The account's pushers as the homeserver would list them: what setPusher last wrote, minus deletions.
|
|
||||||
getPushers: async () => ({
|
|
||||||
pushers: pushers
|
|
||||||
.filter(
|
|
||||||
(p, i) =>
|
|
||||||
p.kind !== null &&
|
|
||||||
!pushers.slice(i + 1).some((q) => q.kind === null && q.pushkey === p.pushkey)
|
|
||||||
)
|
|
||||||
.map((p) => ({ app_id: p.app_id, pushkey: p.pushkey })),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
const push: PushManagerPort = {
|
|
||||||
getSubscription: async () => sub,
|
|
||||||
subscribe: async (o) => {
|
|
||||||
calls.push(`subscribe:${o.userVisibleOnly}:${o.applicationServerKey.length}`);
|
|
||||||
sub = subscription(unsub);
|
|
||||||
return sub;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const swapSub = (s: PushSubscription | null) => {
|
|
||||||
sub = s;
|
|
||||||
};
|
|
||||||
return { mx, push, calls, pushers, unsub, swapSub };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('web push pusher', () => {
|
|
||||||
it('decodes the URL-safe VAPID key', () => {
|
|
||||||
expect([...vapidKeyBytes('-_8')]).toEqual([251, 255]);
|
|
||||||
});
|
|
||||||
it('is the Sygnal webpush shape: app id, p256dh pushkey, subscription in data, ids only', () => {
|
|
||||||
const p = webPushPusher(cfg, subscription([]), 'phone') as unknown as Record<
|
|
||||||
string,
|
|
||||||
unknown
|
|
||||||
> & {
|
|
||||||
data: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
expect(p).toMatchObject({
|
|
||||||
kind: 'http',
|
|
||||||
app_id: cfg.appId,
|
|
||||||
pushkey: '-_8B',
|
|
||||||
device_display_name: 'phone',
|
|
||||||
append: false,
|
|
||||||
});
|
|
||||||
expect(p.data).toMatchObject({
|
|
||||||
url: cfg.gatewayUrl,
|
|
||||||
format: 'event_id_only',
|
|
||||||
endpoint: 'https://push.example/send/abc',
|
|
||||||
auth: '-gI',
|
|
||||||
p256dh: '-_8B',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
it('enable subscribes with userVisibleOnly, then registers the pusher; a second enable reuses the subscription', async () => {
|
|
||||||
const w = world();
|
|
||||||
await enablePush(w.mx, w.push, cfg, 'phone');
|
|
||||||
expect(w.calls).toEqual(['subscribe:true:3', 'set-pusher']);
|
|
||||||
await enablePush(w.mx, w.push, cfg, 'phone');
|
|
||||||
expect(w.calls).toEqual(['subscribe:true:3', 'set-pusher', 'set-pusher']);
|
|
||||||
});
|
|
||||||
it('disable deletes the pusher before the subscription, and does nothing when there is none', async () => {
|
|
||||||
const w = world();
|
|
||||||
await disablePush(w.mx, w.push, cfg);
|
|
||||||
expect(w.calls).toEqual([]);
|
|
||||||
await enablePush(w.mx, w.push, cfg, 'phone');
|
|
||||||
await disablePush(w.mx, w.push, cfg);
|
|
||||||
expect(w.calls.slice(-1)).toEqual(['delete-pusher']);
|
|
||||||
expect(w.pushers.at(-1)).toMatchObject({ kind: null, app_id: cfg.appId, pushkey: '-_8B' });
|
|
||||||
expect(w.unsub).toEqual(['x']);
|
|
||||||
});
|
|
||||||
it('a failed pusher write leaves the subscription alone', async () => {
|
|
||||||
const w = world();
|
|
||||||
await enablePush(w.mx, w.push, cfg, 'phone');
|
|
||||||
w.mx.setPusher = async () => {
|
|
||||||
throw new Error('homeserver said no');
|
|
||||||
};
|
|
||||||
await expect(disablePush(w.mx, w.push, cfg)).rejects.toThrow('homeserver said no');
|
|
||||||
expect(w.unsub).toEqual([]);
|
|
||||||
});
|
|
||||||
it('the toggle is on only with a subscription and the pusher of this account for it', async () => {
|
|
||||||
const w = world();
|
|
||||||
expect(await pushIsOn(w.mx, w.push, cfg)).toBe(false);
|
|
||||||
await enablePush(w.mx, w.push, cfg, 'phone');
|
|
||||||
expect(await pushIsOn(w.mx, w.push, cfg)).toBe(true);
|
|
||||||
// another account on the same browser: the subscription exists, its pusher does not
|
|
||||||
const other = { ...w.mx, getPushers: async () => ({ pushers: [] }) };
|
|
||||||
expect(await pushIsOn(other, w.push, cfg)).toBe(false);
|
|
||||||
await disablePush(w.mx, w.push, cfg);
|
|
||||||
expect(await pushIsOn(w.mx, w.push, cfg)).toBe(false);
|
|
||||||
});
|
|
||||||
it('a subscription made under another VAPID key is replaced, not reused', async () => {
|
|
||||||
const w = world();
|
|
||||||
const old = [] as string[];
|
|
||||||
w.swapSub(subscription(old, Uint8Array.from([9, 9, 9]).buffer));
|
|
||||||
await enablePush(w.mx, w.push, cfg, 'phone');
|
|
||||||
expect(old).toEqual(['x']);
|
|
||||||
expect(w.calls).toEqual(['subscribe:true:3', 'set-pusher']);
|
|
||||||
});
|
|
||||||
it('a failed pusher write after subscribing leaves the toggle off', async () => {
|
|
||||||
const w = world();
|
|
||||||
const fail = { ...w.mx, setPusher: async () => Promise.reject(new Error('no')) };
|
|
||||||
await expect(enablePush(fail, w.push, cfg, 'phone')).rejects.toThrow('no');
|
|
||||||
expect(await pushIsOn(w.mx, w.push, cfg)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,107 +0,0 @@
|
||||||
import type { IPusherRequest } from 'matrix-js-sdk';
|
|
||||||
import type { ActivePushConfig } from './config';
|
|
||||||
|
|
||||||
/** What the page needs of the homeserver client and the browser's push manager, so tests supply fakes. */
|
|
||||||
export interface PusherClient {
|
|
||||||
setPusher(p: IPusherRequest): Promise<unknown>;
|
|
||||||
getPushers(): Promise<{ pushers: { app_id: string; pushkey: string }[] }>;
|
|
||||||
}
|
|
||||||
export interface PushManagerPort {
|
|
||||||
getSubscription(): Promise<PushSubscription | null>;
|
|
||||||
subscribe(o: {
|
|
||||||
userVisibleOnly: boolean;
|
|
||||||
applicationServerKey: Uint8Array;
|
|
||||||
}): Promise<PushSubscription>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function vapidKeyBytes(b64url: string): Uint8Array {
|
|
||||||
const pad = '='.repeat((4 - (b64url.length % 4)) % 4);
|
|
||||||
const bin = atob((b64url + pad).replace(/-/g, '+').replace(/_/g, '/'));
|
|
||||||
return Uint8Array.from(bin, (c) => c.charCodeAt(0));
|
|
||||||
}
|
|
||||||
|
|
||||||
const b64url = (buf: ArrayBuffer | null): string =>
|
|
||||||
btoa(String.fromCharCode(...new Uint8Array(buf ?? new ArrayBuffer(0))))
|
|
||||||
.replace(/\+/g, '-')
|
|
||||||
.replace(/\//g, '_')
|
|
||||||
.replace(/=+$/, '');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The Matrix pusher for a browser subscription (Sygnal's `webpush` shape: the subscription's keys ride in `data`; the
|
|
||||||
* pushkey is the subscription's p256dh key, as Element Web does). `event_id_only` asks the homeserver for ids, which
|
|
||||||
* the worker resolves itself.
|
|
||||||
*/
|
|
||||||
export function webPushPusher(
|
|
||||||
cfg: ActivePushConfig,
|
|
||||||
sub: PushSubscription,
|
|
||||||
deviceName: string
|
|
||||||
): IPusherRequest {
|
|
||||||
const p256dh = b64url(sub.getKey('p256dh'));
|
|
||||||
return {
|
|
||||||
kind: 'http',
|
|
||||||
app_id: cfg.appId,
|
|
||||||
pushkey: p256dh,
|
|
||||||
app_display_name: 'Ratatoskr',
|
|
||||||
device_display_name: deviceName,
|
|
||||||
lang: navigator.language || 'en',
|
|
||||||
data: {
|
|
||||||
url: cfg.gatewayUrl,
|
|
||||||
format: 'event_id_only',
|
|
||||||
endpoint: sub.endpoint,
|
|
||||||
auth: b64url(sub.getKey('auth')),
|
|
||||||
p256dh,
|
|
||||||
},
|
|
||||||
append: false,
|
|
||||||
} as IPusherRequest;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sameKey = (sub: PushSubscription, vapid: Uint8Array): boolean => {
|
|
||||||
const have = sub.options?.applicationServerKey;
|
|
||||||
return !have || b64url(have) === b64url(vapid.buffer as ArrayBuffer);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Subscribe (replacing a subscription made under another VAPID key), then register the pusher for this account. */
|
|
||||||
export async function enablePush(
|
|
||||||
mx: PusherClient,
|
|
||||||
push: PushManagerPort,
|
|
||||||
cfg: ActivePushConfig,
|
|
||||||
deviceName: string
|
|
||||||
): Promise<void> {
|
|
||||||
const key = vapidKeyBytes(cfg.vapidPublicKey);
|
|
||||||
let sub = await push.getSubscription();
|
|
||||||
if (sub && !sameKey(sub, key)) {
|
|
||||||
await sub.unsubscribe();
|
|
||||||
sub = null;
|
|
||||||
}
|
|
||||||
sub = sub ?? (await push.subscribe({ userVisibleOnly: true, applicationServerKey: key }));
|
|
||||||
await mx.setPusher(webPushPusher(cfg, sub, deviceName));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** On only when this browser holds a subscription AND this account has a pusher for it (the two live in different places). */
|
|
||||||
export async function pushIsOn(
|
|
||||||
mx: PusherClient,
|
|
||||||
push: PushManagerPort,
|
|
||||||
cfg: ActivePushConfig
|
|
||||||
): Promise<boolean> {
|
|
||||||
const sub = await push.getSubscription();
|
|
||||||
if (!sub) return false;
|
|
||||||
const pushkey = b64url(sub.getKey('p256dh'));
|
|
||||||
const { pushers } = await mx.getPushers();
|
|
||||||
return pushers.some((p) => p.app_id === cfg.appId && p.pushkey === pushkey);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Delete this account's pusher, then the subscription. The order matters: a pusher with no subscription would keep failing at the gateway. */
|
|
||||||
export async function disablePush(
|
|
||||||
mx: PusherClient,
|
|
||||||
push: PushManagerPort,
|
|
||||||
cfg: ActivePushConfig
|
|
||||||
): Promise<void> {
|
|
||||||
const sub = await push.getSubscription();
|
|
||||||
if (!sub) return;
|
|
||||||
await mx.setPusher({
|
|
||||||
kind: null,
|
|
||||||
app_id: cfg.appId,
|
|
||||||
pushkey: b64url(sub.getKey('p256dh')),
|
|
||||||
} as unknown as IPusherRequest);
|
|
||||||
await sub.unsubscribe();
|
|
||||||
}
|
|
||||||
|
|
@ -1,111 +0,0 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { idbPort, RecordPort, SessionRecord, sessionStore } from './sessionStore';
|
|
||||||
|
|
||||||
const memory = (): RecordPort & { held?: unknown } => {
|
|
||||||
const m: RecordPort & { held?: unknown } = {
|
|
||||||
async read() {
|
|
||||||
return m.held;
|
|
||||||
},
|
|
||||||
async write(v) {
|
|
||||||
m.held = v;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return m;
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('sessionStore', () => {
|
|
||||||
it('saves a valid session and loads it back', async () => {
|
|
||||||
const s = sessionStore(memory());
|
|
||||||
await s.save('https://hs/', 'tok');
|
|
||||||
expect(await s.load()).toEqual({ baseUrl: 'https://hs/', accessToken: 'tok' });
|
|
||||||
});
|
|
||||||
it('deletes the record on logout, and on anything that is not two strings', async () => {
|
|
||||||
const port = memory();
|
|
||||||
const s = sessionStore(port);
|
|
||||||
await s.save('https://hs/', 'tok');
|
|
||||||
await s.save(undefined, undefined);
|
|
||||||
expect(port.held).toBeUndefined();
|
|
||||||
await s.save('https://hs/', 'tok');
|
|
||||||
await s.save('https://hs/', 5);
|
|
||||||
expect(await s.load()).toBeUndefined();
|
|
||||||
});
|
|
||||||
it('keeps the token only while a push subscription exists', async () => {
|
|
||||||
const port = memory();
|
|
||||||
const s = sessionStore(port);
|
|
||||||
await s.saveIfSubscribed('https://hs/', 'tok', async () => false);
|
|
||||||
expect(port.held).toBeUndefined();
|
|
||||||
await s.saveIfSubscribed('https://hs/', 'tok', async () => true);
|
|
||||||
expect(await s.load()).toEqual({ baseUrl: 'https://hs/', accessToken: 'tok' });
|
|
||||||
await s.saveIfSubscribed('https://hs/', 'tok', async () => false);
|
|
||||||
expect(port.held).toBeUndefined();
|
|
||||||
await s.saveIfSubscribed('https://hs/', 'tok', async () => true);
|
|
||||||
await s.saveIfSubscribed('https://hs/', 'tok', () =>
|
|
||||||
Promise.reject(new Error('no push manager'))
|
|
||||||
);
|
|
||||||
expect(port.held).toBeUndefined();
|
|
||||||
});
|
|
||||||
it('ignores a record that is not a session', async () => {
|
|
||||||
const port = memory();
|
|
||||||
port.held = { baseUrl: 1 };
|
|
||||||
expect(await sessionStore(port).load()).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Just enough of IndexedDB for the port: one database, object stores of key -> value, async request callbacks.
|
|
||||||
function fakeIndexedDB(): IDBFactory {
|
|
||||||
const dbs = new Map<string, Map<string, Map<string, unknown>>>();
|
|
||||||
const request = <T>(run: () => T) => {
|
|
||||||
const r = {} as IDBRequest<T>;
|
|
||||||
setTimeout(() => {
|
|
||||||
(r as { result: T }).result = run();
|
|
||||||
r.onsuccess?.(new Event('success'));
|
|
||||||
}, 0);
|
|
||||||
return r;
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
open(name: string) {
|
|
||||||
const r = {} as IDBOpenDBRequest;
|
|
||||||
setTimeout(() => {
|
|
||||||
const fresh = !dbs.has(name);
|
|
||||||
const stores = dbs.get(name) ?? new Map();
|
|
||||||
dbs.set(name, stores);
|
|
||||||
const db = {
|
|
||||||
createObjectStore: (n: string) => stores.set(n, new Map()),
|
|
||||||
close: () => undefined,
|
|
||||||
transaction: (n: string) => {
|
|
||||||
const tx = {} as IDBTransaction;
|
|
||||||
const store = stores.get(n) as Map<string, unknown>;
|
|
||||||
const api = {
|
|
||||||
get: (k: string) => request(() => store.get(k)),
|
|
||||||
put: (v: unknown, k: string) => {
|
|
||||||
store.set(k, v);
|
|
||||||
setTimeout(() => tx.oncomplete?.(new Event('complete')), 0);
|
|
||||||
},
|
|
||||||
delete: (k: string) => {
|
|
||||||
store.delete(k);
|
|
||||||
setTimeout(() => tx.oncomplete?.(new Event('complete')), 0);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
(tx as { objectStore: unknown }).objectStore = () => api;
|
|
||||||
return tx;
|
|
||||||
},
|
|
||||||
} as unknown as IDBDatabase;
|
|
||||||
(r as { result: IDBDatabase }).result = db;
|
|
||||||
if (fresh) r.onupgradeneeded?.(new Event('upgradeneeded') as IDBVersionChangeEvent);
|
|
||||||
r.onsuccess?.(new Event('success'));
|
|
||||||
}, 0);
|
|
||||||
return r;
|
|
||||||
},
|
|
||||||
} as unknown as IDBFactory;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('idbPort', () => {
|
|
||||||
it('round-trips the one record through IndexedDB and deletes it', async () => {
|
|
||||||
const s = sessionStore(idbPort(fakeIndexedDB()));
|
|
||||||
expect(await s.load()).toBeUndefined();
|
|
||||||
await s.save('https://hs/', 'tok');
|
|
||||||
expect(await s.load()).toEqual<SessionRecord>({ baseUrl: 'https://hs/', accessToken: 'tok' });
|
|
||||||
await s.save(undefined, undefined);
|
|
||||||
expect(await s.load()).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
// The one thing a push needs that a closed app cannot give: the access token, to fetch the event a push names.
|
|
||||||
// Stored: `{ baseUrl, accessToken }`, one record, in this origin's IndexedDB `ratatoskr-sw` (not reachable by other
|
|
||||||
// origins; the token is already in the page's own storage). Written when the page hands the worker its session
|
|
||||||
// while this browser holds a push subscription; deleted when the page hands it none (logout) or the subscription is
|
|
||||||
// gone. A browser that never turned push on stores nothing. Nothing else reads it but the push handler.
|
|
||||||
export interface SessionRecord {
|
|
||||||
baseUrl: string;
|
|
||||||
accessToken: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The two operations the store needs from a key-value backend, so tests run without IndexedDB. */
|
|
||||||
export interface RecordPort {
|
|
||||||
read(): Promise<unknown>;
|
|
||||||
write(value: SessionRecord | undefined): Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DB = 'ratatoskr-sw';
|
|
||||||
const STORE = 'session';
|
|
||||||
const KEY = 'session';
|
|
||||||
|
|
||||||
const done = <T>(req: IDBRequest<T>): Promise<T> =>
|
|
||||||
new Promise((resolve, reject) => {
|
|
||||||
req.onsuccess = () => resolve(req.result);
|
|
||||||
req.onerror = () => reject(req.error);
|
|
||||||
});
|
|
||||||
|
|
||||||
export function idbPort(factory: IDBFactory): RecordPort {
|
|
||||||
const open = (): Promise<IDBDatabase> => {
|
|
||||||
const req = factory.open(DB, 1);
|
|
||||||
req.onupgradeneeded = () => req.result.createObjectStore(STORE);
|
|
||||||
return done(req);
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
async read() {
|
|
||||||
const db = await open();
|
|
||||||
try {
|
|
||||||
return await done(db.transaction(STORE).objectStore(STORE).get(KEY));
|
|
||||||
} finally {
|
|
||||||
db.close();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async write(value) {
|
|
||||||
const db = await open();
|
|
||||||
try {
|
|
||||||
const tx = db.transaction(STORE, 'readwrite');
|
|
||||||
const store = tx.objectStore(STORE);
|
|
||||||
const finished = new Promise<void>((resolve, reject) => {
|
|
||||||
tx.oncomplete = () => resolve();
|
|
||||||
tx.onerror = () => reject(tx.error);
|
|
||||||
tx.onabort = () => reject(tx.error);
|
|
||||||
});
|
|
||||||
if (value) store.put(value, KEY);
|
|
||||||
else store.delete(KEY);
|
|
||||||
await finished;
|
|
||||||
} finally {
|
|
||||||
db.close();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sessionStore(port: RecordPort) {
|
|
||||||
return {
|
|
||||||
/** A valid session replaces the record; anything else (logout) deletes it. */
|
|
||||||
async save(baseUrl: unknown, accessToken: unknown): Promise<void> {
|
|
||||||
await port.write(
|
|
||||||
typeof baseUrl === 'string' && typeof accessToken === 'string' && baseUrl && accessToken
|
|
||||||
? { baseUrl, accessToken }
|
|
||||||
: undefined
|
|
||||||
);
|
|
||||||
},
|
|
||||||
/** The token is kept only while this browser holds a push subscription; without one the record is deleted. */
|
|
||||||
async saveIfSubscribed(
|
|
||||||
baseUrl: unknown,
|
|
||||||
accessToken: unknown,
|
|
||||||
subscribed: () => Promise<boolean>
|
|
||||||
): Promise<void> {
|
|
||||||
const keep = await subscribed().catch(() => false);
|
|
||||||
await this.save(keep ? baseUrl : undefined, keep ? accessToken : undefined);
|
|
||||||
},
|
|
||||||
async load(): Promise<SessionRecord | undefined> {
|
|
||||||
const v = (await port.read()) as Partial<SessionRecord> | undefined;
|
|
||||||
return v && typeof v.baseUrl === 'string' && typeof v.accessToken === 'string'
|
|
||||||
? { baseUrl: v.baseUrl, accessToken: v.accessToken }
|
|
||||||
: undefined;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
import type { SessionRecord } from './sessionStore';
|
|
||||||
|
|
||||||
/** A mock homeserver: answers the two reads the push handler makes, and records every request it saw. */
|
|
||||||
export function mockHomeserver(opts: {
|
|
||||||
token: string;
|
|
||||||
events?: Record<string, unknown>;
|
|
||||||
roomNames?: Record<string, string>;
|
|
||||||
failAll?: boolean;
|
|
||||||
}) {
|
|
||||||
const seen: { url: string; auth: string | null }[] = [];
|
|
||||||
const fetchFn = async (url: string, init: RequestInit): Promise<Response> => {
|
|
||||||
const auth = new Headers(init.headers).get('Authorization');
|
|
||||||
seen.push({ url, auth });
|
|
||||||
if (opts.failAll) throw new Error('network down');
|
|
||||||
if (auth !== `Bearer ${opts.token}`) return new Response('{}', { status: 401 });
|
|
||||||
const ev = /\/rooms\/([^/]+)\/event\/([^/]+)$/.exec(url);
|
|
||||||
if (ev) {
|
|
||||||
const e = opts.events?.[decodeURIComponent(ev[2])];
|
|
||||||
return e ? new Response(JSON.stringify(e)) : new Response('{}', { status: 404 });
|
|
||||||
}
|
|
||||||
const nm = /\/rooms\/([^/]+)\/state\/m\.room\.name$/.exec(url);
|
|
||||||
if (nm) {
|
|
||||||
const name = opts.roomNames?.[decodeURIComponent(nm[1])];
|
|
||||||
return name ? new Response(JSON.stringify({ name })) : new Response('{}', { status: 404 });
|
|
||||||
}
|
|
||||||
return new Response('{}', { status: 404 });
|
|
||||||
};
|
|
||||||
return { fetchFn, seen };
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SESSION: SessionRecord = { baseUrl: 'https://matrix.example/', accessToken: 'tok-1' };
|
|
||||||
|
|
||||||
/** The body a homeserver posts to a push gateway for `format: event_id_only` (Matrix push gateway API). */
|
|
||||||
export const matrixNotify = (roomId: string, eventId: string) => ({
|
|
||||||
notification: {
|
|
||||||
event_id: eventId,
|
|
||||||
room_id: roomId,
|
|
||||||
counts: { unread: 1 },
|
|
||||||
devices: [{ app_id: 'org.gamecult.ratatoskr.web', pushkey: 'k', data: {} }],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
import { useEffect } from 'react';
|
|
||||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
|
||||||
import { OPEN_ROOM, PUSH_EVENT_PARAM, PUSH_ROOM_PARAM } from './worker';
|
|
||||||
|
|
||||||
/** Opens the room a push notification named: from the worker's message (app open) or the start URL (app was closed). */
|
|
||||||
export function usePushOpen(): void {
|
|
||||||
const { navigateRoom } = useRoomNavigate();
|
|
||||||
useEffect(() => {
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
const roomId = params.get(PUSH_ROOM_PARAM);
|
|
||||||
if (roomId) {
|
|
||||||
navigateRoom(roomId, params.get(PUSH_EVENT_PARAM) ?? undefined);
|
|
||||||
params.delete(PUSH_ROOM_PARAM);
|
|
||||||
params.delete(PUSH_EVENT_PARAM);
|
|
||||||
const rest = params.toString();
|
|
||||||
window.history.replaceState(
|
|
||||||
null,
|
|
||||||
'',
|
|
||||||
`${window.location.pathname}${rest ? `?${rest}` : ''}${window.location.hash}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const onMessage = (ev: MessageEvent) => {
|
|
||||||
if (ev.data?.type === OPEN_ROOM && typeof ev.data.roomId === 'string') {
|
|
||||||
navigateRoom(
|
|
||||||
ev.data.roomId,
|
|
||||||
typeof ev.data.eventId === 'string' ? ev.data.eventId : undefined
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
navigator.serviceWorker?.addEventListener('message', onMessage);
|
|
||||||
return () => navigator.serviceWorker?.removeEventListener('message', onMessage);
|
|
||||||
}, [navigateRoom]);
|
|
||||||
}
|
|
||||||
|
|
@ -1,108 +0,0 @@
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
import { handleClick, handlePush, OPEN_ROOM, WorkerPort } from './worker';
|
|
||||||
import { matrixNotify, mockHomeserver, SESSION } from './testkit';
|
|
||||||
|
|
||||||
type Shown = { title: string; options: NotificationOptions & { renotify?: boolean } };
|
|
||||||
|
|
||||||
function port(over: Partial<WorkerPort> = {}) {
|
|
||||||
const shown: Shown[] = [];
|
|
||||||
const opened: string[] = [];
|
|
||||||
const w: WorkerPort = {
|
|
||||||
windows: async () => [],
|
|
||||||
openWindow: async (u) => {
|
|
||||||
opened.push(u);
|
|
||||||
},
|
|
||||||
show: async (title, options) => {
|
|
||||||
shown.push({ title, options });
|
|
||||||
},
|
|
||||||
loadSession: async () => SESSION,
|
|
||||||
fetch: mockHomeserver({ token: 'tok-1' }).fetchFn,
|
|
||||||
scope: 'https://chat.example/',
|
|
||||||
...over,
|
|
||||||
};
|
|
||||||
return { w, shown, opened };
|
|
||||||
}
|
|
||||||
|
|
||||||
const msg = {
|
|
||||||
type: 'm.room.message',
|
|
||||||
sender: '@ann:x',
|
|
||||||
content: { msgtype: 'm.text', body: 'hello' },
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('push against a mock gateway and homeserver', () => {
|
|
||||||
it('a push the homeserver sent for event_id_only becomes a notification naming room, sender and text', async () => {
|
|
||||||
const hs = mockHomeserver({
|
|
||||||
token: 'tok-1',
|
|
||||||
events: { $e: msg },
|
|
||||||
roomNames: { '!r:x': 'Lobby' },
|
|
||||||
});
|
|
||||||
const { w, shown } = port({ fetch: hs.fetchFn });
|
|
||||||
await handlePush(matrixNotify('!r:x', '$e'), w);
|
|
||||||
expect(shown).toHaveLength(1);
|
|
||||||
expect(shown[0].title).toBe('Lobby');
|
|
||||||
expect(shown[0].options.body).toBe('ann: hello');
|
|
||||||
expect(shown[0].options.tag).toBe('!r:x');
|
|
||||||
expect(shown[0].options.renotify).toBe(true);
|
|
||||||
expect(shown[0].options.data).toEqual({ roomId: '!r:x', eventId: '$e' });
|
|
||||||
});
|
|
||||||
it('with no stored session it still shows (a push must always show) and sends the token nowhere', async () => {
|
|
||||||
const hs = mockHomeserver({ token: 'tok-1', events: { $e: msg } });
|
|
||||||
const { w, shown } = port({ fetch: hs.fetchFn, loadSession: async () => undefined });
|
|
||||||
await handlePush(matrixNotify('!r:x', '$e'), w);
|
|
||||||
expect(shown[0]).toMatchObject({ title: 'Ratatoskr', options: { body: 'New message' } });
|
|
||||||
expect(hs.seen).toHaveLength(0);
|
|
||||||
});
|
|
||||||
it('shows the generic notification for a body it cannot read', async () => {
|
|
||||||
const { w, shown } = port();
|
|
||||||
await handlePush(undefined, w);
|
|
||||||
expect(shown[0].title).toBe('Ratatoskr');
|
|
||||||
expect(shown[0].options.tag).toBeUndefined();
|
|
||||||
expect(shown[0].options.data).toBeUndefined();
|
|
||||||
});
|
|
||||||
it('shows even when the window list cannot be read', async () => {
|
|
||||||
const { w, shown } = port({ windows: () => Promise.reject(new Error('no clients')) });
|
|
||||||
await handlePush(matrixNotify('!r:x', '$e'), w);
|
|
||||||
expect(shown).toHaveLength(1);
|
|
||||||
});
|
|
||||||
it('shows nothing while a window is visible and focused, and shows when the window is hidden', async () => {
|
|
||||||
const win = (visibilityState: string, focused: boolean) => ({
|
|
||||||
visibilityState,
|
|
||||||
focused,
|
|
||||||
focus: async () => undefined,
|
|
||||||
postMessage: () => undefined,
|
|
||||||
});
|
|
||||||
const a = port({ windows: async () => [win('visible', true)] });
|
|
||||||
await handlePush(matrixNotify('!r:x', '$e'), a.w);
|
|
||||||
expect(a.shown).toHaveLength(0);
|
|
||||||
const b = port({ windows: async () => [win('hidden', false), win('visible', false)] });
|
|
||||||
await handlePush(matrixNotify('!r:x', '$e'), b.w);
|
|
||||||
expect(b.shown).toHaveLength(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('notification click', () => {
|
|
||||||
it('focuses the open window and tells it the room', async () => {
|
|
||||||
const focus = vi.fn(async () => undefined);
|
|
||||||
const postMessage = vi.fn();
|
|
||||||
const { w, opened } = port({
|
|
||||||
windows: async () => [{ visibilityState: 'hidden', focused: false, focus, postMessage }],
|
|
||||||
});
|
|
||||||
await handleClick({ roomId: '!r:x', eventId: '$e' }, w);
|
|
||||||
expect(focus).toHaveBeenCalled();
|
|
||||||
expect(postMessage).toHaveBeenCalledWith({ type: OPEN_ROOM, roomId: '!r:x', eventId: '$e' });
|
|
||||||
expect(opened).toHaveLength(0);
|
|
||||||
});
|
|
||||||
it('with no window, starts the app with the room in the URL', async () => {
|
|
||||||
const { w, opened } = port();
|
|
||||||
await handleClick({ roomId: '!r:x', eventId: '$e' }, w);
|
|
||||||
const u = new URL(opened[0]);
|
|
||||||
expect(u.origin + u.pathname).toBe('https://chat.example/');
|
|
||||||
expect(u.searchParams.get('pushRoom')).toBe('!r:x');
|
|
||||||
expect(u.searchParams.get('pushEvent')).toBe('$e');
|
|
||||||
});
|
|
||||||
it('with no window and no target, just opens the app', async () => {
|
|
||||||
const { w, opened } = port();
|
|
||||||
await handleClick(undefined, w);
|
|
||||||
expect(opened).toEqual(['https://chat.example/']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
import { describePush, parsePush } from './payload';
|
|
||||||
import type { SessionRecord } from './sessionStore';
|
|
||||||
|
|
||||||
/** The slice of the service worker's scope the handlers use, so they run in tests without a worker. */
|
|
||||||
export interface WorkerPort {
|
|
||||||
windows(): Promise<
|
|
||||||
{
|
|
||||||
visibilityState: string;
|
|
||||||
focused: boolean;
|
|
||||||
focus(): Promise<unknown>;
|
|
||||||
postMessage(m: unknown): void;
|
|
||||||
}[]
|
|
||||||
>;
|
|
||||||
openWindow(url: string): Promise<unknown>;
|
|
||||||
show(title: string, options: NotificationOptions): Promise<void>;
|
|
||||||
loadSession(): Promise<SessionRecord | undefined>;
|
|
||||||
fetch(url: string, init: RequestInit): Promise<Response>;
|
|
||||||
/** The worker's scope URL: where a cold click opens the app. */
|
|
||||||
scope: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PushTarget {
|
|
||||||
roomId: string;
|
|
||||||
eventId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A push shows a notification, unless the app is open and focused (browsers allow that, and the page notifies itself). */
|
|
||||||
export async function handlePush(raw: unknown, w: WorkerPort): Promise<void> {
|
|
||||||
const ids = parsePush(raw);
|
|
||||||
const open = await w.windows().catch(() => []);
|
|
||||||
if (open.some((c) => c.visibilityState === 'visible' && c.focused)) return;
|
|
||||||
const shown = await describePush(ids, await w.loadSession(), w.fetch);
|
|
||||||
await w.show(shown.title, {
|
|
||||||
body: shown.body,
|
|
||||||
// One notification per room, replaced (and re-alerted) by the next message there.
|
|
||||||
tag: ids?.roomId,
|
|
||||||
renotify: !!ids?.roomId,
|
|
||||||
icon: './public/android/android-chrome-192x192.png',
|
|
||||||
data: ids ? ({ roomId: ids.roomId, eventId: ids.eventId } satisfies PushTarget) : undefined,
|
|
||||||
} as NotificationOptions);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const OPEN_ROOM = 'openRoom';
|
|
||||||
export const PUSH_ROOM_PARAM = 'pushRoom';
|
|
||||||
export const PUSH_EVENT_PARAM = 'pushEvent';
|
|
||||||
|
|
||||||
/** A click focuses an open window and tells it which room; with none open it starts the app with the room in the URL. */
|
|
||||||
export async function handleClick(target: PushTarget | undefined, w: WorkerPort): Promise<void> {
|
|
||||||
const windows = await w.windows();
|
|
||||||
const open = windows[0];
|
|
||||||
if (open) {
|
|
||||||
await open.focus();
|
|
||||||
if (target?.roomId) open.postMessage({ type: OPEN_ROOM, ...target });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const url = new URL(w.scope);
|
|
||||||
if (target?.roomId) {
|
|
||||||
url.searchParams.set(PUSH_ROOM_PARAM, target.roomId);
|
|
||||||
if (target.eventId) url.searchParams.set(PUSH_EVENT_PARAM, target.eventId);
|
|
||||||
}
|
|
||||||
await w.openWindow(url.href);
|
|
||||||
}
|
|
||||||
|
|
@ -66,7 +66,6 @@ import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo';
|
||||||
import { livekitSupport } from '../../hooks/useLivekitSupport';
|
import { livekitSupport } from '../../hooks/useLivekitSupport';
|
||||||
import { StateEvent } from '../../../types/matrix/room';
|
import { StateEvent } from '../../../types/matrix/room';
|
||||||
import { webRTCSupported } from '../../utils/rtc';
|
import { webRTCSupported } from '../../utils/rtc';
|
||||||
import { PhoneHitSpread24 } from '../../styles/PhoneHit.css';
|
|
||||||
|
|
||||||
type RoomNavItemMenuProps = {
|
type RoomNavItemMenuProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
|
|
@ -230,7 +229,6 @@ function CallChatToggle() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<IconButton
|
<IconButton
|
||||||
className={PhoneHitSpread24}
|
|
||||||
onClick={() => setChat(!chat)}
|
onClick={() => setChat(!chat)}
|
||||||
aria-pressed={chat}
|
aria-pressed={chat}
|
||||||
aria-label="Toggle Chat"
|
aria-label="Toggle Chat"
|
||||||
|
|
@ -438,7 +436,6 @@ export function RoomNavItem({
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
className={PhoneHitSpread24}
|
|
||||||
onClick={handleOpenMenu}
|
onClick={handleOpenMenu}
|
||||||
aria-pressed={!!menuAnchor}
|
aria-pressed={!!menuAnchor}
|
||||||
aria-controls={`menu-${room.roomId}`}
|
aria-controls={`menu-${room.roomId}`}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ import { Permissions } from './permissions';
|
||||||
import { RoomSettingsPage } from '../../state/roomSettings';
|
import { RoomSettingsPage } from '../../state/roomSettings';
|
||||||
import { useRoom } from '../../hooks/useRoom';
|
import { useRoom } from '../../hooks/useRoom';
|
||||||
import { DeveloperTools } from '../common-settings/developer-tools';
|
import { DeveloperTools } from '../common-settings/developer-tools';
|
||||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
|
||||||
|
|
||||||
type RoomSettingsMenuItem = {
|
type RoomSettingsMenuItem = {
|
||||||
page: RoomSettingsPage;
|
page: RoomSettingsPage;
|
||||||
|
|
@ -118,7 +117,7 @@ export function RoomSettings({ initialPage, requestClose }: RoomSettingsProps) {
|
||||||
</Box>
|
</Box>
|
||||||
<Box shrink="No">
|
<Box shrink="No">
|
||||||
{screenSize === ScreenSize.Mobile && (
|
{screenSize === ScreenSize.Mobile && (
|
||||||
<IconButton className={PhoneHit} onClick={requestClose} variant="Background">
|
<IconButton onClick={requestClose} variant="Background">
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ import {
|
||||||
} from '../../common-settings/general';
|
} from '../../common-settings/general';
|
||||||
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 GeneralProps = {
|
type GeneralProps = {
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
|
|
@ -36,7 +35,7 @@ export function General({ requestClose }: GeneralProps) {
|
||||||
</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>
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import { usePermissionGroups } from './usePermissionItems';
|
||||||
import { PermissionGroups, Powers, PowersEditor } from '../../common-settings/permissions';
|
import { PermissionGroups, Powers, PowersEditor } from '../../common-settings/permissions';
|
||||||
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 PermissionsProps = {
|
type PermissionsProps = {
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
|
|
@ -46,7 +45,7 @@ export function Permissions({ requestClose }: PermissionsProps) {
|
||||||
</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>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import { Page, PageHeader } from '../../components/page';
|
||||||
import { callChatAtom } from '../../state/callEmbed';
|
import { callChatAtom } from '../../state/callEmbed';
|
||||||
import { RoomView } from './RoomView';
|
import { RoomView } from './RoomView';
|
||||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
|
||||||
|
|
||||||
export function CallChatView() {
|
export function CallChatView() {
|
||||||
const { eventId } = useParams();
|
const { eventId } = useParams();
|
||||||
|
|
@ -42,12 +41,7 @@ export function CallChatView() {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(triggerRef) => (
|
{(triggerRef) => (
|
||||||
<IconButton
|
<IconButton ref={triggerRef} variant="Surface" onClick={handleClose}>
|
||||||
className={PhoneHit}
|
|
||||||
ref={triggerRef}
|
|
||||||
variant="Surface"
|
|
||||||
onClick={handleClose}
|
|
||||||
>
|
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,6 @@ import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||||
import { useFlattenPowerTagMembers, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
|
import { useFlattenPowerTagMembers, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
|
||||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
|
||||||
|
|
||||||
type MemberDrawerHeaderProps = {
|
type MemberDrawerHeaderProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
|
|
@ -88,7 +87,6 @@ function MemberDrawerHeader({ room }: MemberDrawerHeaderProps) {
|
||||||
>
|
>
|
||||||
{(triggerRef) => (
|
{(triggerRef) => (
|
||||||
<IconButton
|
<IconButton
|
||||||
className={PhoneHit}
|
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
variant="Background"
|
variant="Background"
|
||||||
onClick={() => setPeopleDrawer(false)}
|
onClick={() => setPeopleDrawer(false)}
|
||||||
|
|
@ -361,7 +359,6 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
|
||||||
|
|
||||||
<ScrollTopContainer scrollRef={scrollRef} anchorRef={scrollTopAnchorRef}>
|
<ScrollTopContainer scrollRef={scrollRef} anchorRef={scrollTopAnchorRef}>
|
||||||
<IconButton
|
<IconButton
|
||||||
className={PhoneHit}
|
|
||||||
onClick={() => virtualizer.scrollToOffset(0)}
|
onClick={() => virtualizer.scrollToOffset(0)}
|
||||||
variant="Surface"
|
variant="Surface"
|
||||||
radii="Pill"
|
radii="Pill"
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,6 @@ import {
|
||||||
config,
|
config,
|
||||||
toRem,
|
toRem,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import { RoomOutbox } from './RoomOutbox';
|
|
||||||
import { sendTracked } from '../../state/room/outboxSend';
|
|
||||||
import { clearDraft, loadDraft, saveDraft } from '../../state/room/composerStore';
|
|
||||||
|
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import {
|
import {
|
||||||
|
|
@ -96,7 +93,12 @@ import { safeFile } from '../../utils/mimeTypes';
|
||||||
import { fulfilledPromiseSettledResult } from '../../utils/common';
|
import { fulfilledPromiseSettledResult } from '../../utils/common';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
import { getMsgContent } from './msgContent';
|
import {
|
||||||
|
getAudioMsgContent,
|
||||||
|
getFileMsgContent,
|
||||||
|
getImageMsgContent,
|
||||||
|
getVideoMsgContent,
|
||||||
|
} from './msgContent';
|
||||||
import { FILE_HASH_KEY, hashForSend } from '../albums/fileReceipt';
|
import { FILE_HASH_KEY, hashForSend } from '../albums/fileReceipt';
|
||||||
import { getMemberDisplayName, getMentionContent, trimReplyFromBody } from '../../utils/room';
|
import { getMemberDisplayName, getMentionContent, trimReplyFromBody } from '../../utils/room';
|
||||||
import { CommandAutocomplete } from './CommandAutocomplete';
|
import { CommandAutocomplete } from './CommandAutocomplete';
|
||||||
|
|
@ -116,7 +118,6 @@ import { useTheme } from '../../hooks/useTheme';
|
||||||
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
|
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
|
||||||
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
|
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
|
||||||
import { useComposingCheck } from '../../hooks/useComposingCheck';
|
import { useComposingCheck } from '../../hooks/useComposingCheck';
|
||||||
import { PhoneHitSpread24, PhoneHitSpread32 } from '../../styles/PhoneHit.css';
|
|
||||||
|
|
||||||
interface RoomInputProps {
|
interface RoomInputProps {
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
|
|
@ -225,66 +226,22 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
useCallback((width) => setHideStickerBtn(width < 500), [])
|
useCallback((width) => setHideStickerBtn(width < 500), [])
|
||||||
);
|
);
|
||||||
|
|
||||||
const userId = mx.getUserId() ?? '';
|
|
||||||
const draftSaveTimer = useRef<ReturnType<typeof setTimeout>>();
|
|
||||||
|
|
||||||
// The atom is only a cache: after a reload it is empty and the stored draft is the truth.
|
|
||||||
useEffect(() => {
|
|
||||||
if (msgDraft.length === 0) {
|
|
||||||
const stored = loadDraft(userId, roomId);
|
|
||||||
if (stored.length > 0) setMsgDraft(stored);
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [userId, roomId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Transforms.insertFragment(editor, msgDraft);
|
Transforms.insertFragment(editor, msgDraft);
|
||||||
}, [editor, msgDraft]);
|
}, [editor, msgDraft]);
|
||||||
|
|
||||||
const persistDraft = useCallback(() => {
|
|
||||||
clearTimeout(draftSaveTimer.current);
|
|
||||||
draftSaveTimer.current = undefined;
|
|
||||||
saveDraft(userId, roomId, editor.children);
|
|
||||||
}, [userId, roomId, editor]);
|
|
||||||
|
|
||||||
// Write on input (debounced), and flush when the page is hidden or unloading, which is
|
|
||||||
// all a mobile reload gives us.
|
|
||||||
const handleEditorChange = useCallback(() => {
|
|
||||||
clearTimeout(draftSaveTimer.current);
|
|
||||||
draftSaveTimer.current = setTimeout(persistDraft, 400);
|
|
||||||
}, [persistDraft]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const flush = () => {
|
|
||||||
if (draftSaveTimer.current !== undefined) persistDraft();
|
|
||||||
};
|
|
||||||
const onVisibility = () => {
|
|
||||||
if (document.visibilityState === 'hidden') flush();
|
|
||||||
};
|
|
||||||
window.addEventListener('pagehide', flush);
|
|
||||||
document.addEventListener('visibilitychange', onVisibility);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('pagehide', flush);
|
|
||||||
document.removeEventListener('visibilitychange', onVisibility);
|
|
||||||
};
|
|
||||||
}, [persistDraft]);
|
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
clearTimeout(draftSaveTimer.current);
|
|
||||||
draftSaveTimer.current = undefined;
|
|
||||||
if (!isEmptyEditor(editor)) {
|
if (!isEmptyEditor(editor)) {
|
||||||
const parsedDraft = JSON.parse(JSON.stringify(editor.children));
|
const parsedDraft = JSON.parse(JSON.stringify(editor.children));
|
||||||
setMsgDraft(parsedDraft);
|
setMsgDraft(parsedDraft);
|
||||||
saveDraft(userId, roomId, parsedDraft);
|
|
||||||
} else {
|
} else {
|
||||||
setMsgDraft([]);
|
setMsgDraft([]);
|
||||||
clearDraft(userId, roomId);
|
|
||||||
}
|
}
|
||||||
resetEditor(editor);
|
resetEditor(editor);
|
||||||
resetEditorHistory(editor);
|
resetEditorHistory(editor);
|
||||||
},
|
},
|
||||||
[roomId, editor, setMsgDraft, userId]
|
[roomId, editor, setMsgDraft]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleFileMetadata = useCallback(
|
const handleFileMetadata = useCallback(
|
||||||
|
|
@ -324,7 +281,16 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
const fileItem = selectedFiles.find((f) => f.file === upload.file);
|
const fileItem = selectedFiles.find((f) => f.file === upload.file);
|
||||||
if (!fileItem) throw new Error('Broken upload');
|
if (!fileItem) throw new Error('Broken upload');
|
||||||
|
|
||||||
const content = await getMsgContent(mx, fileItem, upload.mxc);
|
let content: IContent;
|
||||||
|
if (fileItem.file.type.startsWith('image')) {
|
||||||
|
content = await getImageMsgContent(mx, fileItem, upload.mxc);
|
||||||
|
} else if (fileItem.file.type.startsWith('video')) {
|
||||||
|
content = await getVideoMsgContent(mx, fileItem, upload.mxc);
|
||||||
|
} else if (fileItem.file.type.startsWith('audio')) {
|
||||||
|
content = getAudioMsgContent(fileItem, upload.mxc);
|
||||||
|
} else {
|
||||||
|
content = getFileMsgContent(fileItem, upload.mxc);
|
||||||
|
}
|
||||||
// A Mind verifies what it fetched against this. An encrypted file already carries the
|
// A Mind verifies what it fetched against this. An encrypted file already carries the
|
||||||
// ciphertext hash in file.hashes; a plaintext hash would leak the file's identity.
|
// ciphertext hash in file.hashes; a plaintext hash would leak the file's identity.
|
||||||
if (!fileItem.encInfo) {
|
if (!fileItem.encInfo) {
|
||||||
|
|
@ -335,7 +301,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
});
|
});
|
||||||
handleCancelUpload(uploads);
|
handleCancelUpload(uploads);
|
||||||
const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
|
const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
|
||||||
contents.forEach((content) => sendTracked(mx, roomId, content as Record<string, unknown>));
|
contents.forEach((content) => mx.sendMessage(roomId, content as any));
|
||||||
};
|
};
|
||||||
|
|
||||||
const submit = useCallback(() => {
|
const submit = useCallback(() => {
|
||||||
|
|
@ -414,25 +380,12 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
content['m.relates_to'].is_falling_back = false;
|
content['m.relates_to'].is_falling_back = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sendTracked(mx, roomId, content);
|
mx.sendMessage(roomId, content as any);
|
||||||
clearTimeout(draftSaveTimer.current);
|
|
||||||
draftSaveTimer.current = undefined;
|
|
||||||
clearDraft(userId, roomId);
|
|
||||||
resetEditor(editor);
|
resetEditor(editor);
|
||||||
resetEditorHistory(editor);
|
resetEditorHistory(editor);
|
||||||
setReplyDraft(undefined);
|
setReplyDraft(undefined);
|
||||||
sendTypingStatus(false);
|
sendTypingStatus(false);
|
||||||
}, [
|
}, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands]);
|
||||||
mx,
|
|
||||||
roomId,
|
|
||||||
userId,
|
|
||||||
editor,
|
|
||||||
replyDraft,
|
|
||||||
sendTypingStatus,
|
|
||||||
setReplyDraft,
|
|
||||||
isMarkdown,
|
|
||||||
commands,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleKeyDown: KeyboardEventHandler = useCallback(
|
const handleKeyDown: KeyboardEventHandler = useCallback(
|
||||||
(evt) => {
|
(evt) => {
|
||||||
|
|
@ -494,7 +447,11 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
await getImageUrlBlob(stickerUrl)
|
await getImageUrlBlob(stickerUrl)
|
||||||
);
|
);
|
||||||
|
|
||||||
sendTracked(mx, roomId, { body: label, url: mxc, info }, EventType.Sticker);
|
mx.sendEvent(roomId, EventType.Sticker, {
|
||||||
|
body: label,
|
||||||
|
url: mxc,
|
||||||
|
info,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -594,53 +551,47 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onKeyUp={handleKeyUp}
|
onKeyUp={handleKeyUp}
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
onChange={handleEditorChange}
|
|
||||||
top={
|
top={
|
||||||
<>
|
replyDraft && (
|
||||||
<RoomOutbox mx={mx} roomId={roomId} />
|
<div>
|
||||||
{replyDraft && (
|
<Box
|
||||||
<div>
|
alignItems="Center"
|
||||||
<Box
|
gap="300"
|
||||||
alignItems="Center"
|
style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
|
||||||
gap="300"
|
>
|
||||||
style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
|
<IconButton
|
||||||
|
onClick={() => setReplyDraft(undefined)}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
>
|
>
|
||||||
<IconButton
|
<Icon src={Icons.Cross} size="50" />
|
||||||
className={PhoneHitSpread24}
|
</IconButton>
|
||||||
onClick={() => setReplyDraft(undefined)}
|
<Box direction="Row" gap="200" alignItems="Center">
|
||||||
variant="SurfaceVariant"
|
{replyDraft.relation?.rel_type === RelationType.Thread && <ThreadIndicator />}
|
||||||
size="300"
|
<ReplyLayout
|
||||||
radii="300"
|
userColor={replyUsernameColor}
|
||||||
>
|
username={
|
||||||
<Icon src={Icons.Cross} size="50" />
|
|
||||||
</IconButton>
|
|
||||||
<Box direction="Row" gap="200" alignItems="Center">
|
|
||||||
{replyDraft.relation?.rel_type === RelationType.Thread && <ThreadIndicator />}
|
|
||||||
<ReplyLayout
|
|
||||||
userColor={replyUsernameColor}
|
|
||||||
username={
|
|
||||||
<Text size="T300" truncate>
|
|
||||||
<b>
|
|
||||||
{getMemberDisplayName(room, replyDraft.userId) ??
|
|
||||||
getMxIdLocalPart(replyDraft.userId) ??
|
|
||||||
replyDraft.userId}
|
|
||||||
</b>
|
|
||||||
</Text>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Text size="T300" truncate>
|
<Text size="T300" truncate>
|
||||||
{trimReplyFromBody(replyDraft.body)}
|
<b>
|
||||||
|
{getMemberDisplayName(room, replyDraft.userId) ??
|
||||||
|
getMxIdLocalPart(replyDraft.userId) ??
|
||||||
|
replyDraft.userId}
|
||||||
|
</b>
|
||||||
</Text>
|
</Text>
|
||||||
</ReplyLayout>
|
}
|
||||||
</Box>
|
>
|
||||||
|
<Text size="T300" truncate>
|
||||||
|
{trimReplyFromBody(replyDraft.body)}
|
||||||
|
</Text>
|
||||||
|
</ReplyLayout>
|
||||||
</Box>
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
)}
|
</div>
|
||||||
</>
|
)
|
||||||
}
|
}
|
||||||
before={
|
before={
|
||||||
<IconButton
|
<IconButton
|
||||||
className={PhoneHitSpread32}
|
|
||||||
onClick={() => pickFile('*')}
|
onClick={() => pickFile('*')}
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
size="300"
|
size="300"
|
||||||
|
|
@ -652,7 +603,6 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
after={
|
after={
|
||||||
<>
|
<>
|
||||||
<IconButton
|
<IconButton
|
||||||
className={PhoneHitSpread32}
|
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
size="300"
|
size="300"
|
||||||
radii="300"
|
radii="300"
|
||||||
|
|
@ -695,7 +645,6 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
>
|
>
|
||||||
{!hideStickerBtn && (
|
{!hideStickerBtn && (
|
||||||
<IconButton
|
<IconButton
|
||||||
className={PhoneHitSpread32}
|
|
||||||
aria-pressed={emojiBoardTab === EmojiBoardTab.Sticker}
|
aria-pressed={emojiBoardTab === EmojiBoardTab.Sticker}
|
||||||
onClick={() => setEmojiBoardTab(EmojiBoardTab.Sticker)}
|
onClick={() => setEmojiBoardTab(EmojiBoardTab.Sticker)}
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
|
|
@ -709,7 +658,6 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
<IconButton
|
<IconButton
|
||||||
className={PhoneHitSpread32}
|
|
||||||
ref={emojiBtnRef}
|
ref={emojiBtnRef}
|
||||||
aria-pressed={
|
aria-pressed={
|
||||||
hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
|
hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
|
||||||
|
|
@ -729,13 +677,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
</PopOut>
|
</PopOut>
|
||||||
)}
|
)}
|
||||||
</UseStateProvider>
|
</UseStateProvider>
|
||||||
<IconButton
|
<IconButton onClick={submit} variant="SurfaceVariant" size="300" radii="300">
|
||||||
className={PhoneHitSpread32}
|
|
||||||
onClick={submit}
|
|
||||||
variant="SurfaceVariant"
|
|
||||||
size="300"
|
|
||||||
radii="300"
|
|
||||||
>
|
|
||||||
<Icon src={Icons.Send} />
|
<Icon src={Icons.Send} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { enqueueOutbox, failOutbox, loadOutbox } from '../../state/room/composerStore';
|
|
||||||
import { RoomOutbox } from './RoomOutbox';
|
|
||||||
|
|
||||||
// The component's hooks are held still (state is its initial value, effects do not run) so the element tree
|
|
||||||
// it returns can be read and its click handlers called without a DOM.
|
|
||||||
vi.mock('react', async (importOriginal) => {
|
|
||||||
const actual = await importOriginal<typeof import('react')>();
|
|
||||||
const useState = (init: unknown) => [
|
|
||||||
typeof init === 'function' ? (init as () => unknown)() : init,
|
|
||||||
() => undefined,
|
|
||||||
];
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
default: { ...actual, useState, useEffect: () => undefined },
|
|
||||||
useState,
|
|
||||||
useEffect: () => undefined,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
type El = { props?: { children?: unknown; onClick?: () => void } };
|
|
||||||
const flatten = (node: unknown): El[] => {
|
|
||||||
if (Array.isArray(node)) return node.flatMap(flatten);
|
|
||||||
if (node && typeof node === 'object') {
|
|
||||||
const el = node as El;
|
|
||||||
return [el, ...flatten(el.props?.children)];
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
};
|
|
||||||
const textOf = (node: unknown): string => {
|
|
||||||
if (typeof node === 'string' || typeof node === 'number') return String(node);
|
|
||||||
if (Array.isArray(node)) return node.map(textOf).join('');
|
|
||||||
if (node && typeof node === 'object') return textOf((node as El).props?.children);
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
/** The onClick of the chip whose label is `label`. */
|
|
||||||
const clickOf = (tree: unknown, label: string) => {
|
|
||||||
const chip = flatten(tree).find((e) => e.props?.onClick && textOf(e) === label);
|
|
||||||
if (!chip?.props?.onClick) throw new Error(`no ${label} chip`);
|
|
||||||
return chip.props.onClick;
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('RoomOutbox actions', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
const m = new Map<string, string>();
|
|
||||||
vi.stubGlobal('localStorage', {
|
|
||||||
getItem: (k: string) => m.get(k) ?? null,
|
|
||||||
setItem: (k: string, v: string) => {
|
|
||||||
m.set(k, v);
|
|
||||||
},
|
|
||||||
removeItem: (k: string) => {
|
|
||||||
m.delete(k);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
enqueueOutbox('@a:x', { txnId: 'keep', roomId: '!r:x', content: { body: 'stay' } });
|
|
||||||
enqueueOutbox('@a:x', { txnId: 'bad', roomId: '!r:x', content: { body: 'did not go' } });
|
|
||||||
failOutbox('@a:x', 'bad', 'ECONNRESET');
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
const mount = () => {
|
|
||||||
const mx = {
|
|
||||||
getUserId: () => '@a:x',
|
|
||||||
makeTxnId: () => 'txn-new',
|
|
||||||
getRoom: () => undefined,
|
|
||||||
sendEvent: vi.fn().mockResolvedValue({ event_id: '$e' }),
|
|
||||||
};
|
|
||||||
const tree = (RoomOutbox as unknown as (p: unknown) => unknown)({ mx, roomId: '!r:x' });
|
|
||||||
return { mx, tree };
|
|
||||||
};
|
|
||||||
|
|
||||||
it('Discard removes that message from the outbox and no other', () => {
|
|
||||||
const { mx, tree } = mount();
|
|
||||||
// both entries are shown; the failed one is the one with Discard
|
|
||||||
clickOf(tree, 'Discard')();
|
|
||||||
expect(loadOutbox('@a:x').map((e) => e.txnId)).toEqual(['keep']);
|
|
||||||
expect(mx.sendEvent).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Resend sends that message again and does not discard it first', async () => {
|
|
||||||
const { mx, tree } = mount();
|
|
||||||
// the server has not answered yet: the resend is in flight
|
|
||||||
mx.sendEvent.mockReturnValue(
|
|
||||||
new Promise<never>(() => {
|
|
||||||
// never settles
|
|
||||||
})
|
|
||||||
);
|
|
||||||
clickOf(tree, 'Resend')();
|
|
||||||
await Promise.resolve();
|
|
||||||
expect(mx.sendEvent).toHaveBeenCalledTimes(1);
|
|
||||||
expect(mx.sendEvent).toHaveBeenCalledWith(
|
|
||||||
'!r:x',
|
|
||||||
'm.room.message',
|
|
||||||
{ body: 'did not go' },
|
|
||||||
'bad'
|
|
||||||
);
|
|
||||||
// still in the outbox, pending again under the same id, until the server confirms it
|
|
||||||
expect(loadOutbox('@a:x').find((e) => e.txnId === 'bad')).toMatchObject({ status: 'pending' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { renderToStaticMarkup } from 'react-dom/server';
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { enqueueOutbox, failOutbox } from '../../state/room/composerStore';
|
|
||||||
import { RoomOutbox } from './RoomOutbox';
|
|
||||||
|
|
||||||
// What the person sees under the timeline for sends the server has not confirmed.
|
|
||||||
const text = (html: string) => html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ');
|
|
||||||
|
|
||||||
describe('RoomOutbox as rendered', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
const m = new Map<string, string>();
|
|
||||||
vi.stubGlobal('localStorage', {
|
|
||||||
getItem: (k: string) => m.get(k) ?? null,
|
|
||||||
setItem: (k: string, v: string) => {
|
|
||||||
m.set(k, v);
|
|
||||||
},
|
|
||||||
removeItem: (k: string) => {
|
|
||||||
m.delete(k);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
const mx = { getUserId: () => '@a:x' } as never;
|
|
||||||
const entry = (txnId: string, roomId: string, body: string) => ({
|
|
||||||
txnId,
|
|
||||||
roomId,
|
|
||||||
content: { body },
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders nothing when the room has no unconfirmed sends', () => {
|
|
||||||
enqueueOutbox('@a:x', entry('t1', '!other:x', 'elsewhere'));
|
|
||||||
expect(renderToStaticMarkup(<RoomOutbox mx={mx} roomId="!r:x" />)).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows a fresh send as Sending with its text and no Resend', () => {
|
|
||||||
enqueueOutbox('@a:x', entry('t1', '!r:x', 'on its way'), Date.now() + 1000);
|
|
||||||
const html = renderToStaticMarkup(<RoomOutbox mx={mx} roomId="!r:x" />);
|
|
||||||
expect(html).toContain('data-outbox-status="pending"');
|
|
||||||
expect(text(html)).toContain('Sending…');
|
|
||||||
expect(text(html)).toContain('on its way');
|
|
||||||
expect(text(html)).not.toContain('Resend');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows a failed send with its error, Resend and Discard', () => {
|
|
||||||
enqueueOutbox('@a:x', entry('t2', '!r:x', 'did not go'));
|
|
||||||
failOutbox('@a:x', 't2', 'ECONNRESET');
|
|
||||||
const html = renderToStaticMarkup(<RoomOutbox mx={mx} roomId="!r:x" />);
|
|
||||||
expect(html).toContain('data-outbox-status="failed"');
|
|
||||||
expect(text(html)).toContain('Not sent: ECONNRESET');
|
|
||||||
expect(text(html)).toContain('Resend');
|
|
||||||
expect(text(html)).toContain('Discard');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows a send left over from before this page load as interrupted, resendable', () => {
|
|
||||||
enqueueOutbox('@a:x', entry('t3', '!r:x', 'lost to a reload'), 5);
|
|
||||||
const html = renderToStaticMarkup(<RoomOutbox mx={mx} roomId="!r:x" />);
|
|
||||||
expect(text(html)).toContain('Interrupted by a reload');
|
|
||||||
expect(text(html)).toContain('Resend');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { SLOW_SEND_MS, outboxView } from './RoomOutbox';
|
|
||||||
|
|
||||||
describe('outboxView', () => {
|
|
||||||
it('walks sending -> still sending (resendable) -> failed', () => {
|
|
||||||
expect(outboxView({ status: 'pending', ts: 100 }, 100, 0)).toMatchObject({
|
|
||||||
label: 'Sending…',
|
|
||||||
canResend: false,
|
|
||||||
});
|
|
||||||
expect(outboxView({ status: 'pending', ts: 100 }, 100 + SLOW_SEND_MS, 0)).toMatchObject({
|
|
||||||
label: expect.stringMatching(/^Still sending/),
|
|
||||||
canResend: true,
|
|
||||||
});
|
|
||||||
expect(outboxView({ status: 'failed', ts: 100, error: 'x' }, 100, 0)).toMatchObject({
|
|
||||||
failed: true,
|
|
||||||
canResend: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('offers Resend to a pending send at 30 s and not a millisecond before', () => {
|
|
||||||
const at = (ms: number) => outboxView({ status: 'pending', ts: 100 }, 100 + ms, 0).canResend;
|
|
||||||
expect([at(29_999), at(30_000)]).toEqual([false, true]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('an entry older than this page load reads as interrupted', () => {
|
|
||||||
expect(outboxView({ status: 'pending', ts: 5 }, 6, 10).label).toMatch(/Interrupted/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,121 +0,0 @@
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import { MatrixClient } from 'matrix-js-sdk';
|
|
||||||
import { Box, Chip, Text, color, config } from 'folds';
|
|
||||||
import {
|
|
||||||
OutboxEntry,
|
|
||||||
loadOutbox,
|
|
||||||
removeOutbox,
|
|
||||||
subscribeOutbox,
|
|
||||||
} from '../../state/room/composerStore';
|
|
||||||
import { PAGE_START, resendTracked } from '../../state/room/outboxSend';
|
|
||||||
|
|
||||||
/** A send still unconfirmed after this long is offered Resend: the request may be hung. */
|
|
||||||
export const SLOW_SEND_MS = 30_000;
|
|
||||||
|
|
||||||
export type OutboxView = { label: string; failed: boolean; canResend: boolean };
|
|
||||||
|
|
||||||
export const outboxView = (
|
|
||||||
entry: Pick<OutboxEntry, 'status' | 'ts' | 'error'>,
|
|
||||||
now: number,
|
|
||||||
since: number = PAGE_START
|
|
||||||
): OutboxView => {
|
|
||||||
if (entry.status === 'failed') {
|
|
||||||
return {
|
|
||||||
label: `Not sent${entry.error ? `: ${entry.error}` : ''}`,
|
|
||||||
failed: true,
|
|
||||||
canResend: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (entry.ts < since) {
|
|
||||||
return {
|
|
||||||
label: 'Interrupted by a reload: may not have been sent',
|
|
||||||
failed: false,
|
|
||||||
canResend: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (now - entry.ts >= SLOW_SEND_MS) {
|
|
||||||
return {
|
|
||||||
label: 'Still sending: slow connection (Resend may duplicate it if it lands later)',
|
|
||||||
failed: false,
|
|
||||||
canResend: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { label: 'Sending…', failed: false, canResend: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useRoomOutbox = (userId: string, roomId: string): OutboxEntry[] => {
|
|
||||||
const read = () => loadOutbox(userId).filter((e) => e.roomId === roomId);
|
|
||||||
const [entries, setEntries] = useState(read);
|
|
||||||
useEffect(() => {
|
|
||||||
setEntries(read());
|
|
||||||
return subscribeOutbox(() => setEntries(read()));
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [userId, roomId]);
|
|
||||||
return entries;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Re-render every few seconds while anything is waiting, so a slow send turns resendable. */
|
|
||||||
const useNow = (active: boolean): number => {
|
|
||||||
const [now, setNow] = useState(Date.now());
|
|
||||||
useEffect(() => {
|
|
||||||
if (!active) return undefined;
|
|
||||||
setNow(Date.now());
|
|
||||||
const id = setInterval(() => setNow(Date.now()), 5000);
|
|
||||||
return () => clearInterval(id);
|
|
||||||
}, [active]);
|
|
||||||
return now;
|
|
||||||
};
|
|
||||||
|
|
||||||
type RoomOutboxProps = { mx: MatrixClient; roomId: string };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Messages this client sent that the server has not confirmed: sending, slow, interrupted by a
|
|
||||||
* reload, or failed, each with Resend where one makes sense and Discard.
|
|
||||||
*/
|
|
||||||
export function RoomOutbox({ mx, roomId }: RoomOutboxProps) {
|
|
||||||
const userId = mx.getUserId() ?? '';
|
|
||||||
const entries = useRoomOutbox(userId, roomId);
|
|
||||||
const now = useNow(entries.length > 0);
|
|
||||||
if (entries.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
direction="Column"
|
|
||||||
gap="100"
|
|
||||||
style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
|
|
||||||
>
|
|
||||||
{entries.map((entry) => {
|
|
||||||
const view = outboxView(entry, now);
|
|
||||||
return (
|
|
||||||
<Box key={entry.txnId} alignItems="Center" gap="200" data-outbox-status={entry.status}>
|
|
||||||
<Box grow="Yes" direction="Column">
|
|
||||||
<Text
|
|
||||||
size="T200"
|
|
||||||
style={{ color: view.failed ? color.Critical.Main : color.Surface.OnContainer }}
|
|
||||||
>
|
|
||||||
{view.label}
|
|
||||||
</Text>
|
|
||||||
<Text size="T300" truncate>
|
|
||||||
{String(entry.content.body ?? '')}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
{view.canResend && (
|
|
||||||
<>
|
|
||||||
<Chip variant="Surface" radii="Pill" onClick={() => resendTracked(mx, entry)}>
|
|
||||||
<Text size="B300">Resend</Text>
|
|
||||||
</Chip>
|
|
||||||
<Chip
|
|
||||||
variant="Surface"
|
|
||||||
radii="Pill"
|
|
||||||
onClick={() => removeOutbox(userId, entry.txnId)}
|
|
||||||
>
|
|
||||||
<Text size="B300">Discard</Text>
|
|
||||||
</Chip>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -47,7 +47,6 @@ import {
|
||||||
import { isKeyHotkey } from 'is-hotkey';
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
import { Opts as LinkifyOpts } from 'linkifyjs';
|
import { Opts as LinkifyOpts } from 'linkifyjs';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { isUnconfirmedLocalEcho } from './localEcho';
|
|
||||||
import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../utils/matrix';
|
import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../utils/matrix';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator';
|
import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator';
|
||||||
|
|
@ -390,19 +389,6 @@ const useLiveEventArrive = (room: Room, onArrive: (mEvent: MatrixEvent) => void)
|
||||||
}, [room, onArrive]);
|
}, [room, onArrive]);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** A local echo changes status (or becomes its remote echo) without a Timeline event: redraw. */
|
|
||||||
const useLocalEchoRefresh = (room: Room, onUpdate: () => void) => {
|
|
||||||
useEffect(() => {
|
|
||||||
const handle: RoomEventHandlerMap[RoomEvent.LocalEchoUpdated] = (_evt, r) => {
|
|
||||||
if (r.roomId === room.roomId) onUpdate();
|
|
||||||
};
|
|
||||||
room.on(RoomEvent.LocalEchoUpdated, handle);
|
|
||||||
return () => {
|
|
||||||
room.removeListener(RoomEvent.LocalEchoUpdated, handle);
|
|
||||||
};
|
|
||||||
}, [room, onUpdate]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const useLiveTimelineRefresh = (room: Room, onRefresh: () => void) => {
|
const useLiveTimelineRefresh = (room: Room, onRefresh: () => void) => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleTimelineRefresh: RoomEventHandlerMap[RoomEvent.TimelineRefresh] = (r) => {
|
const handleTimelineRefresh: RoomEventHandlerMap[RoomEvent.TimelineRefresh] = (r) => {
|
||||||
|
|
@ -662,18 +648,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
useLocalEchoRefresh(
|
|
||||||
room,
|
|
||||||
useCallback(() => {
|
|
||||||
// the echo was hidden while unconfirmed, so its arrival scrolled to nothing: scroll now
|
|
||||||
if (atBottomRef.current) {
|
|
||||||
scrollToBottomRef.current.count += 1;
|
|
||||||
scrollToBottomRef.current.smooth = true;
|
|
||||||
}
|
|
||||||
setTimeline((ct) => ({ ...ct }));
|
|
||||||
}, [])
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleOpenEvent = useCallback(
|
const handleOpenEvent = useCallback(
|
||||||
async (
|
async (
|
||||||
evtId: string,
|
evtId: string,
|
||||||
|
|
@ -1672,7 +1646,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
if (mEvent.isRedacted() && !showHiddenEvents) {
|
if (mEvent.isRedacted() && !showHiddenEvents) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (isUnconfirmedLocalEcho(mEvent)) return null;
|
|
||||||
|
|
||||||
if (!newDivider && readUptoEventIdRef.current) {
|
if (!newDivider && readUptoEventIdRef.current) {
|
||||||
newDivider = prevEvent?.getId() === readUptoEventIdRef.current;
|
newDivider = prevEvent?.getId() === readUptoEventIdRef.current;
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue