Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16ccd2132e | ||
|
|
da395cc0bf |
206 changed files with 547 additions and 6195 deletions
|
|
@ -25,14 +25,6 @@
|
|||
"dings": {
|
||||
"enabled": false
|
||||
},
|
||||
"fileZoneShare": {
|
||||
"enabled": false
|
||||
},
|
||||
"push": {
|
||||
"vapidPublicKey": "",
|
||||
"gatewayUrl": "",
|
||||
"appId": ""
|
||||
},
|
||||
"registration": {
|
||||
"enabled": false,
|
||||
"url": ""
|
||||
|
|
|
|||
|
|
@ -71,21 +71,21 @@ Reuse the composer's pipeline, pointed at an album instead of the main timeline:
|
|||
|
||||
- `createUploadAtom` / `useBindUploadAtom` (`src/app/state/upload.ts`), `UploadCard`, `upload-board` for progress.
|
||||
- On success, the msgContent builder produces the event; the zone adds the thread relation to the album root before sending (the composer already does this for thread replies, `RoomInput.tsx`).
|
||||
- "New album" = send the root text event, then upload into its thread. Multi-file drop = N events, sent in order.
|
||||
- "New album" = send the root text event, then upload into its thread. Multi-file drop = N events, sent in order.
|
||||
- Size limit from `MediaConfigLoader` (server `m.upload.size`). **UNKNOWN:** whether the 32 MB `shelf` kit's clips (42 x ~0.75 MB) should be uploaded individually or batched; individually is the only option without a server change.
|
||||
|
||||
## Existing code to reuse (verified present in the repo)
|
||||
|
||||
| need | where |
|
||||
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| 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` |
|
||||
| full-screen image viewer | `src/app/components/image-viewer/ImageViewer.tsx` |
|
||||
| media dispatch by msgtype | `src/app/components/RenderMessageContent.tsx`, `message/MsgTypeRenderers.tsx` |
|
||||
| upload + progress | `state/upload.ts`, `components/upload-card`, `upload-board` |
|
||||
| event content builders | `features/room/msgContent.ts` |
|
||||
| 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` |
|
||||
| need | where |
|
||||
|---|---|
|
||||
| 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` |
|
||||
| full-screen image viewer | `src/app/components/image-viewer/ImageViewer.tsx` |
|
||||
| media dispatch by msgtype | `src/app/components/RenderMessageContent.tsx`, `message/MsgTypeRenderers.tsx` |
|
||||
| upload + progress | `state/upload.ts`, `components/upload-card`, `upload-board` |
|
||||
| event content builders | `features/room/msgContent.ts` |
|
||||
| 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` |
|
||||
|
||||
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).
|
||||
- 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).
|
||||
|
||||
## 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",
|
||||
"short_name": "Ratatoskr",
|
||||
"id": "/",
|
||||
"description": "GameCult's Matrix client",
|
||||
"dir": "auto",
|
||||
"lang": "en-US",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"start_url": "./",
|
||||
"scope": "./",
|
||||
"background_color": "#07111a",
|
||||
"theme_color": "#07111a",
|
||||
"icons": [
|
||||
|
|
@ -55,12 +54,6 @@
|
|||
"src": "./public/android/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "./public/android/android-chrome-maskable-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
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';
|
||||
import { MatrixError } from 'matrix-js-sdk';
|
||||
import { Cursor } from '../plugins/text-area';
|
||||
import { PhoneHit } from '../styles/PhoneHit.css';
|
||||
import { syntaxErrorPosition } from '../utils/dom';
|
||||
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
||||
import { Page, PageHeader } from './page';
|
||||
|
|
@ -295,7 +294,7 @@ export function AccountDataEditor({
|
|||
</Chip>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton onClick={requestClose} variant="Surface" className={PhoneHit}>
|
||||
<IconButton onClick={requestClose} variant="Surface">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ import {
|
|||
} from '../hooks/useKeyBackup';
|
||||
import { stopPropagation } from '../utils/keyboard';
|
||||
import { useRestoreBackupOnVerification } from '../hooks/useRestoreBackupOnVerification';
|
||||
import { PhoneHit } from '../styles/PhoneHit.css';
|
||||
|
||||
type BackupStatusProps = {
|
||||
enabled: boolean;
|
||||
|
|
@ -178,7 +177,6 @@ export function BackupRestoreTile({ crypto }: BackupRestoreTileProps) {
|
|||
<BackupSyncing count={remainingSession} />
|
||||
)}
|
||||
<IconButton
|
||||
className={PhoneHit}
|
||||
aria-pressed={!!menuCords}
|
||||
size="300"
|
||||
variant="Surface"
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ import {
|
|||
} from '../hooks/useVerificationRequest';
|
||||
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
||||
import { ContainerColor } from '../styles/ContainerColor.css';
|
||||
import { PhoneHit } from '../styles/PhoneHit.css';
|
||||
|
||||
const DialogHeaderStyles: CSSProperties = {
|
||||
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||
|
|
@ -262,7 +261,7 @@ export function DeviceVerification({ request, onExit }: DeviceVerificationProps)
|
|||
<Box grow="Yes">
|
||||
<Text size="H4">Device Verification</Text>
|
||||
</Box>
|
||||
<IconButton className={PhoneHit} size="300" radii="300" onClick={handleCancel}>
|
||||
<IconButton size="300" radii="300" onClick={handleCancel}>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ import { ActionUIA, ActionUIAFlowsLoader } from './ActionUIA';
|
|||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||
import { useAlive } from '../hooks/useAlive';
|
||||
import { UseStateProvider } from './UseStateProvider';
|
||||
import { PhoneHit } from '../styles/PhoneHit.css';
|
||||
|
||||
type UIACallback<T> = (
|
||||
authDict: AuthDict | null
|
||||
|
|
@ -302,7 +301,7 @@ export const DeviceVerificationSetup = forwardRef<HTMLDivElement, DeviceVerifica
|
|||
<Box grow="Yes">
|
||||
<Text size="H4">Setup Device Verification</Text>
|
||||
</Box>
|
||||
<IconButton className={PhoneHit} size="300" radii="300" onClick={onCancel}>
|
||||
<IconButton size="300" radii="300" onClick={onCancel}>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
|
@ -337,7 +336,7 @@ export const DeviceVerificationReset = forwardRef<HTMLDivElement, DeviceVerifica
|
|||
<Box grow="Yes">
|
||||
<Text size="H4">Reset Device Verification</Text>
|
||||
</Box>
|
||||
<IconButton className={PhoneHit} size="300" radii="300" onClick={onCancel}>
|
||||
<IconButton size="300" radii="300" onClick={onCancel}>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ import { AsyncStatus } from '../../hooks/useAsyncCallback';
|
|||
import { useZoom } from '../../hooks/useZoom';
|
||||
import { createPage, usePdfDocumentLoader, usePdfJSLoader } from '../../plugins/pdfjs-dist';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { PhoneHit, PhoneHitSpread24 } from '../../styles/PhoneHit.css';
|
||||
|
||||
export type PdfViewerProps = {
|
||||
name: string;
|
||||
|
|
@ -109,7 +108,7 @@ export const PdfViewer = as<'div', PdfViewerProps>(
|
|||
<Box className={classNames(css.PdfViewer, className)} direction="Column" {...props} ref={ref}>
|
||||
<Header className={css.PdfViewerHeader} size="400">
|
||||
<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} />
|
||||
</IconButton>
|
||||
<Text size="T300" truncate>
|
||||
|
|
@ -118,7 +117,6 @@ export const PdfViewer = as<'div', PdfViewerProps>(
|
|||
</Box>
|
||||
<Box shrink="No" alignItems="Center" gap="200">
|
||||
<IconButton
|
||||
className={PhoneHitSpread24}
|
||||
variant={zoom < 1 ? 'Success' : 'SurfaceVariant'}
|
||||
outlined={zoom < 1}
|
||||
size="300"
|
||||
|
|
@ -132,7 +130,6 @@ export const PdfViewer = as<'div', PdfViewerProps>(
|
|||
<Text size="B300">{Math.round(zoom * 100)}%</Text>
|
||||
</Chip>
|
||||
<IconButton
|
||||
className={PhoneHitSpread24}
|
||||
variant={zoom > 1 ? 'Success' : 'SurfaceVariant'}
|
||||
outlined={zoom > 1}
|
||||
size="300"
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import {
|
|||
IconButton,
|
||||
} from 'folds';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { PhoneHit } from '../styles/PhoneHit.css';
|
||||
|
||||
export type UIAFlowOverlayProps = {
|
||||
currentStep: number;
|
||||
|
|
@ -54,7 +53,6 @@ export function UIAFlowOverlay({
|
|||
>
|
||||
{(anchorRef) => (
|
||||
<IconButton
|
||||
className={PhoneHit}
|
||||
ref={anchorRef}
|
||||
variant="Critical"
|
||||
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 { settingsAtom } from '../../state/settings';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { PhoneHit, PhoneHitRoom36, PhoneHitSpread36 } from '../../styles/PhoneHit.css';
|
||||
|
||||
function BtnTooltip({ text, shortCode }: { text: string; shortCode?: string }) {
|
||||
return (
|
||||
|
|
@ -73,7 +72,6 @@ export function MarkButton({ format, icon, tooltip }: MarkButtonProps) {
|
|||
<TooltipProvider tooltip={tooltip} delay={500}>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
className={PhoneHitSpread36}
|
||||
ref={triggerRef}
|
||||
variant="SurfaceVariant"
|
||||
onClick={handleClick}
|
||||
|
|
@ -106,7 +104,6 @@ export function BlockButton({ format, icon, tooltip }: BlockButtonProps) {
|
|||
<TooltipProvider tooltip={tooltip} delay={500}>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
className={PhoneHitSpread36}
|
||||
ref={triggerRef}
|
||||
variant="SurfaceVariant"
|
||||
onClick={handleClick}
|
||||
|
|
@ -166,7 +163,6 @@ export function HeadingBlockButton() {
|
|||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
className={PhoneHitRoom36}
|
||||
ref={triggerRef}
|
||||
onClick={() => handleMenuSelect(1)}
|
||||
size="400"
|
||||
|
|
@ -182,7 +178,6 @@ export function HeadingBlockButton() {
|
|||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
className={PhoneHitRoom36}
|
||||
ref={triggerRef}
|
||||
onClick={() => handleMenuSelect(2)}
|
||||
size="400"
|
||||
|
|
@ -198,7 +193,6 @@ export function HeadingBlockButton() {
|
|||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
className={PhoneHitRoom36}
|
||||
ref={triggerRef}
|
||||
onClick={() => handleMenuSelect(3)}
|
||||
size="400"
|
||||
|
|
@ -214,7 +208,6 @@ export function HeadingBlockButton() {
|
|||
}
|
||||
>
|
||||
<IconButton
|
||||
className={PhoneHit}
|
||||
style={{ width: 'unset' }}
|
||||
variant="SurfaceVariant"
|
||||
onClick={handleMenuOpen}
|
||||
|
|
@ -246,7 +239,6 @@ export function ExitFormatting({ tooltip }: ExitFormattingProps) {
|
|||
<TooltipProvider tooltip={tooltip} delay={500}>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
className={PhoneHit}
|
||||
ref={triggerRef}
|
||||
variant="SurfaceVariant"
|
||||
onClick={handleClick}
|
||||
|
|
@ -352,7 +344,6 @@ export function Toolbar() {
|
|||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
className={PhoneHit}
|
||||
ref={triggerRef}
|
||||
variant="SurfaceVariant"
|
||||
onClick={() => setIsMarkdown(!isMarkdown)}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import {
|
|||
} from 'folds';
|
||||
import classNames from 'classnames';
|
||||
import * as css from './styles.css';
|
||||
import { PhoneHit } from '../../../styles/PhoneHit.css';
|
||||
|
||||
export function Sidebar({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
|
|
@ -69,7 +68,6 @@ function SidebarBtn<T extends string>({
|
|||
>
|
||||
{(ref) => (
|
||||
<IconButton
|
||||
className={PhoneHit}
|
||||
aria-pressed={active}
|
||||
aria-labelledby={`SidebarStackItem-${id}-label`}
|
||||
ref={ref}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
|||
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||
import { getMouseEventCords } from '../../utils/dom';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
|
||||
export type EventReadersProps = {
|
||||
room: Room;
|
||||
|
|
@ -53,7 +52,7 @@ export const EventReaders = as<'div', EventReadersProps>(
|
|||
<Box grow="Yes">
|
||||
<Text size="H3">Seen by</Text>
|
||||
</Box>
|
||||
<IconButton className={PhoneHit} size="300" onClick={requestClose}>
|
||||
<IconButton size="300" onClick={requestClose}>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import React from 'react';
|
|||
import classNames from 'classnames';
|
||||
import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds';
|
||||
import * as css from './ImageEditor.css';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
|
||||
export type ImageEditorProps = {
|
||||
name: string;
|
||||
|
|
@ -25,7 +24,7 @@ export const ImageEditor = as<'div', ImageEditorProps>(
|
|||
>
|
||||
<Header className={css.ImageEditorHeader} size="400">
|
||||
<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} />
|
||||
</IconButton>
|
||||
<Text size="T300" truncate>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import React from 'react';
|
||||
import { Box, IconButton, Text, Icon, Icons, Scroll, Chip } from 'folds';
|
||||
import { PackAddress } from '../../plugins/custom-emoji';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
import { Page, PageHeader, PageContent } from '../page';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { RoomImagePack } from './RoomImagePack';
|
||||
|
|
@ -30,7 +29,7 @@ export function ImagePackView({ address, requestClose }: ImagePackViewProps) {
|
|||
</Chip>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton onClick={requestClose} variant="Surface" className={PhoneHit}>
|
||||
<IconButton onClick={requestClose} variant="Surface">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import * as css from './ImageViewer.css';
|
|||
import { useZoom } from '../../hooks/useZoom';
|
||||
import { usePan } from '../../hooks/usePan';
|
||||
import { downloadMedia } from '../../utils/matrix';
|
||||
import { PhoneHit, PhoneHitSpread24 } from '../../styles/PhoneHit.css';
|
||||
|
||||
export type ImageViewerProps = {
|
||||
alt: string;
|
||||
|
|
@ -34,7 +33,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
|
|||
>
|
||||
<Header className={css.ImageViewerHeader} size="400">
|
||||
<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} />
|
||||
</IconButton>
|
||||
<Text size="T300" truncate>
|
||||
|
|
@ -43,7 +42,6 @@ export const ImageViewer = as<'div', ImageViewerProps>(
|
|||
</Box>
|
||||
<Box shrink="No" alignItems="Center" gap="200">
|
||||
<IconButton
|
||||
className={PhoneHitSpread24}
|
||||
variant={zoom < 1 ? 'Success' : 'SurfaceVariant'}
|
||||
outlined={zoom < 1}
|
||||
size="300"
|
||||
|
|
@ -57,7 +55,6 @@ export const ImageViewer = as<'div', ImageViewerProps>(
|
|||
<Text size="B300">{Math.round(zoom * 100)}%</Text>
|
||||
</Chip>
|
||||
<IconButton
|
||||
className={PhoneHitSpread24}
|
||||
variant={zoom > 1 ? 'Success' : 'SurfaceVariant'}
|
||||
outlined={zoom > 1}
|
||||
size="300"
|
||||
|
|
|
|||
|
|
@ -42,7 +42,6 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
|||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { BreakWord } from '../../styles/Text.css';
|
||||
import { useAlive } from '../../hooks/useAlive';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
|
||||
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
|
||||
limit: 1000,
|
||||
|
|
@ -174,7 +173,7 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
|
|||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton className={PhoneHit} size="300" radii="300" onClick={requestClose}>
|
||||
<IconButton size="300" radii="300" onClick={requestClose}>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import { stopPropagation } from '../../utils/keyboard';
|
|||
import { isRoomAlias, isRoomId } from '../../utils/matrix';
|
||||
import { parseMatrixToRoom, parseMatrixToRoomEvent, testMatrixTo } from '../../plugins/matrix-to';
|
||||
import { tryDecodeURIComponent } from '../../utils/dom';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
|
||||
type JoinAddressProps = {
|
||||
onOpen: (roomIdOrAlias: string, via?: string[], eventId?: string) => void;
|
||||
|
|
@ -83,7 +82,7 @@ export function JoinAddressPrompt({ onOpen, onCancel }: JoinAddressProps) {
|
|||
<Box grow="Yes">
|
||||
<Text size="H4">Join with Address</Text>
|
||||
</Box>
|
||||
<IconButton className={PhoneHit} size="300" onClick={onCancel} radii="300">
|
||||
<IconButton size="300" onClick={onCancel} radii="300">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import { MatrixError } from 'matrix-js-sdk';
|
|||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
|
||||
type LeaveRoomPromptProps = {
|
||||
roomId: string;
|
||||
|
|
@ -69,7 +68,7 @@ export function LeaveRoomPrompt({ roomId, onDone, onCancel }: LeaveRoomPromptPro
|
|||
<Box grow="Yes">
|
||||
<Text size="H4">Leave Room</Text>
|
||||
</Box>
|
||||
<IconButton className={PhoneHit} size="300" onClick={onCancel} radii="300">
|
||||
<IconButton size="300" onClick={onCancel} radii="300">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import { MatrixError } from 'matrix-js-sdk';
|
|||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
|
||||
type LeaveSpacePromptProps = {
|
||||
roomId: string;
|
||||
|
|
@ -69,7 +68,7 @@ export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptP
|
|||
<Box grow="Yes">
|
||||
<Text size="H4">Leave Space</Text>
|
||||
</Box>
|
||||
<IconButton className={PhoneHit} size="300" onClick={onCancel} radii="300">
|
||||
<IconButton size="300" onClick={onCancel} radii="300">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { Badge, Box, Icon, IconButton, Icons, Spinner, Text, as, toRem } from 'f
|
|||
import React, { ReactNode, useCallback } from 'react';
|
||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||
import FileSaver from 'file-saver';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
import { mimeTypeToExt } from '../../utils/mimeTypes';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
|
|
@ -49,7 +48,6 @@ export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDow
|
|||
variant={hasError ? 'Critical' : 'SurfaceVariant'}
|
||||
size="300"
|
||||
radii="300"
|
||||
className={PhoneHit}
|
||||
>
|
||||
{downloading ? (
|
||||
<Spinner size="100" variant={hasError ? 'Critical' : 'Secondary'} />
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
import { createVar, style } from '@vanilla-extract/css';
|
||||
import { DefaultReset, FocusOutline, color, config, toRem } from 'folds';
|
||||
import { MOBILE_BREAKPOINT } from '../../hooks/useScreenSize';
|
||||
import { PHONE_TAP } from '../../styles/PhoneHit.css';
|
||||
|
||||
const PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`;
|
||||
const Container = createVar();
|
||||
const ContainerHover = createVar();
|
||||
const ContainerActive = createVar();
|
||||
|
|
@ -28,14 +25,6 @@ export const Reaction = style([
|
|||
selectors: {
|
||||
'button&': {
|
||||
cursor: 'pointer',
|
||||
// Phone tap target: the chip is 26 px tall, so its hit area grows 9 px above and below (an ::after) and the
|
||||
// same 9 px of margin opens the row, so the chips of one row and of the next never share a tap.
|
||||
'@media': { [PHONE]: { position: 'relative', marginBlock: (PHONE_TAP - 26) / 2 } },
|
||||
},
|
||||
'button&::after': {
|
||||
'@media': {
|
||||
[PHONE]: { content: '""', position: 'absolute', inset: `${-(PHONE_TAP - 26) / 2}px 0` },
|
||||
},
|
||||
},
|
||||
'&[aria-pressed=true]': {
|
||||
vars: {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import React, { ReactNode, useCallback, useRef, useState } from 'react';
|
|||
import { Badge, Chip, Icon, IconButton, Icons, ProgressBar, Spinner, Text, toRem } from 'folds';
|
||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||
import { Range } from 'react-range';
|
||||
import { PhoneHitSpread24 } from '../../../styles/PhoneHit.css';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { IAudioInfo } from '../../../../types/matrix/common';
|
||||
|
|
@ -160,7 +159,6 @@ export function AudioContent({
|
|||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="Pill"
|
||||
className={PhoneHitSpread24}
|
||||
onClick={() => setMute(!mute)}
|
||||
aria-pressed={mute}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -299,10 +299,7 @@ export const BubbleLeftArrow = style({
|
|||
});
|
||||
|
||||
// Phone tap target: a sender name is 23 px tall, so the button grows 11 px above and below (44+ px to tap) and the
|
||||
// negative margin takes the growth back out of the layout. Sideways it cannot borrow from the layout the
|
||||
// same way (a negative inline margin makes the button shrink inside its shrink-to-fit parent and clip the name), so
|
||||
// a name under 44 px is 44 px wide, text kept at the start: real growth of at most 16 px, in a row with room for it.
|
||||
// No background, border or outline moves.
|
||||
// negative margin takes the growth back out of the layout. Hit area only: no background, border or outline moves.
|
||||
const SCREEN_PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`;
|
||||
export const Username = style({
|
||||
overflow: 'hidden',
|
||||
|
|
@ -312,7 +309,7 @@ export const Username = style({
|
|||
'button&': {
|
||||
cursor: 'pointer',
|
||||
'@media': {
|
||||
[SCREEN_PHONE]: { paddingBlock: 11, marginBlock: -11, minWidth: 44, textAlign: 'start' },
|
||||
[SCREEN_PHONE]: { paddingBlock: 11, marginBlock: -11 },
|
||||
},
|
||||
},
|
||||
'button&:hover, button&:focus-visible': {
|
||||
|
|
|
|||
|
|
@ -53,10 +53,6 @@ export const NavCategoryHeader = style({
|
|||
'&&': { paddingLeft: 0 },
|
||||
},
|
||||
'@media': {
|
||||
// A phone's section header is as tall as its 44 px button, so it does not overlap the next row.
|
||||
'screen and (max-width: 750px)': {
|
||||
selectors: { '&&': { minHeight: toRem(44), height: toRem(44) } },
|
||||
},
|
||||
// Category labels sit on the scene like the rows do, and need a wash to be read (wide screens;
|
||||
// a phone's list is plain text).
|
||||
'screen and (min-width: 751px)': {
|
||||
|
|
@ -121,8 +117,6 @@ const NavItemBase = style({
|
|||
[PHONE]: {
|
||||
backgroundColor: 'transparent',
|
||||
borderRadius: 0,
|
||||
// The row is as tall as the 44 px tap target inside it (EstateNavigator.css.ts), so neighbours do not overlap.
|
||||
minHeight: toRem(44),
|
||||
},
|
||||
[WIDE]: {
|
||||
selectors: {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import React, { ComponentProps, forwardRef } from 'react';
|
||||
import { Icon, IconButton, Input, config, Icons } from 'folds';
|
||||
import { UseStateProvider } from '../UseStateProvider';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
|
||||
type PasswordInputProps = Omit<ComponentProps<typeof Input>, 'type' | 'size'> & {
|
||||
size: '400' | '500';
|
||||
|
|
@ -24,7 +23,6 @@ export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
|
|||
<>
|
||||
{after}
|
||||
<IconButton
|
||||
className={PhoneHit}
|
||||
onClick={() => setVisible(!visible)}
|
||||
type="button"
|
||||
variant={visible ? 'Warning' : variant}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import classNames from 'classnames';
|
|||
import Linkify from 'linkify-react';
|
||||
import * as css from './style.css';
|
||||
import { LINKIFY_OPTS, scaleSystemEmoji } from '../../plugins/react-custom-html-parser';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
|
||||
export const RoomTopicViewer = as<
|
||||
'div',
|
||||
|
|
@ -27,7 +26,7 @@ export const RoomTopicViewer = as<
|
|||
{name}
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton className={PhoneHit} size="300" onClick={requestClose} radii="300">
|
||||
<IconButton size="300" onClick={requestClose} radii="300">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { Box, Chip, Header, Icon, IconButton, Icons, Scroll, Text, as } from 'fo
|
|||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import * as css from './TextViewer.css';
|
||||
import { copyToClipboard } from '../../utils/dom';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
|
||||
const ReactPrism = lazy(() => import('../../plugins/react-prism/ReactPrism'));
|
||||
|
||||
|
|
@ -54,7 +53,7 @@ export const TextViewer = as<'div', TextViewerProps>(
|
|||
>
|
||||
<Header className={css.TextViewerHeader} size="400">
|
||||
<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} />
|
||||
</IconButton>
|
||||
<Text size="T300" truncate>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { AuthType } from 'matrix-js-sdk';
|
|||
import { StageComponentProps } from './types';
|
||||
import { ErrorCode } from '../../cs-errorcode';
|
||||
import { PasswordInput } from '../password-input';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
|
||||
export function PasswordStage({
|
||||
stageData,
|
||||
|
|
@ -46,7 +45,7 @@ export function PasswordStage({
|
|||
<Box grow="Yes">
|
||||
<Text size="H4">Account Password</Text>
|
||||
</Box>
|
||||
<IconButton className={PhoneHit} size="300" onClick={onCancel} radii="300">
|
||||
<IconButton size="300" onClick={onCancel} radii="300">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { Box, Button, color, config, Dialog, Header, Icon, IconButton, Icons, Text } from 'folds';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { StageComponentProps } from './types';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
|
||||
export function SSOStage({
|
||||
ssoRedirectURL,
|
||||
|
|
@ -57,7 +56,7 @@ export function SSOStage({
|
|||
<Box grow="Yes">
|
||||
<Text size="H4">SSO Login</Text>
|
||||
</Box>
|
||||
<IconButton className={PhoneHit} size="300" onClick={onCancel} radii="300">
|
||||
<IconButton size="300" onClick={onCancel} radii="300">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|||
import { TUploadContent } from '../../utils/matrix';
|
||||
import { bytesToSize, getFileTypeIcon } from '../../utils/common';
|
||||
import { useMediaConfig } from '../../hooks/useMediaConfig';
|
||||
import { PhoneHitSpread28 } from '../../styles/PhoneHit.css';
|
||||
|
||||
type CompactUploadCardRendererProps = {
|
||||
isEncrypted?: boolean;
|
||||
|
|
@ -64,7 +63,6 @@ export function CompactUploadCardRenderer({
|
|||
</Chip>
|
||||
)}
|
||||
<IconButton
|
||||
className={PhoneHitSpread28}
|
||||
onClick={removeUpload}
|
||||
aria-label="Cancel Upload"
|
||||
variant="SurfaceVariant"
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import {
|
|||
} from '../../state/room/roomInputDrafts';
|
||||
import { useObjectURL } from '../../hooks/useObjectURL';
|
||||
import { useMediaConfig } from '../../hooks/useMediaConfig';
|
||||
import { PhoneHitSpread28 } from '../../styles/PhoneHit.css';
|
||||
|
||||
type PreviewImageProps = {
|
||||
fileItem: TUploadItem;
|
||||
|
|
@ -161,7 +160,6 @@ export function UploadCardRenderer({
|
|||
</Chip>
|
||||
)}
|
||||
<IconButton
|
||||
className={PhoneHitSpread28}
|
||||
onClick={removeUpload}
|
||||
aria-label="Cancel Upload"
|
||||
variant="SurfaceVariant"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import classNames from 'classnames';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { IPreviewUrlResponse } from 'matrix-js-sdk';
|
||||
import { Box, Icon, IconButton, Icons, Scroll, Spinner, Text, as, color, config } from 'folds';
|
||||
|
|
@ -11,7 +10,6 @@ import {
|
|||
useIntersectionObserver,
|
||||
} from '../../hooks/useIntersectionObserver';
|
||||
import * as css from './UrlPreviewCard.css';
|
||||
import { PhoneHitInPlace } from '../../styles/PhoneHit.css';
|
||||
import { tryDecodeURIComponent } from '../../utils/dom';
|
||||
import { mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
|
|
@ -183,10 +181,7 @@ export const UrlPreviewHolder = as<'div'>(({ children, ...props }, ref) => {
|
|||
<>
|
||||
<div className={css.UrlPreviewHolderGradient({ position: 'Left' })} />
|
||||
<IconButton
|
||||
className={classNames(
|
||||
css.UrlPreviewHolderBtn({ position: 'Left' }),
|
||||
PhoneHitInPlace
|
||||
)}
|
||||
className={css.UrlPreviewHolderBtn({ position: 'Left' })}
|
||||
variant="Secondary"
|
||||
radii="Pill"
|
||||
size="300"
|
||||
|
|
@ -204,10 +199,7 @@ export const UrlPreviewHolder = as<'div'>(({ children, ...props }, ref) => {
|
|||
<>
|
||||
<div className={css.UrlPreviewHolderGradient({ position: 'Right' })} />
|
||||
<IconButton
|
||||
className={classNames(
|
||||
css.UrlPreviewHolderBtn({ position: 'Right' }),
|
||||
PhoneHitInPlace
|
||||
)}
|
||||
className={css.UrlPreviewHolderBtn({ position: 'Right' })}
|
||||
variant="Primary"
|
||||
radii="Pill"
|
||||
size="300"
|
||||
|
|
|
|||
|
|
@ -43,7 +43,6 @@ import { getPowerTagIconSrc, useGetMemberPowerTag } from '../../hooks/useMemberP
|
|||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||
import { useMemberPowerCompare } from '../../hooks/useMemberPowerCompare';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
|
||||
type SelfDemoteAlertProps = {
|
||||
power: number;
|
||||
|
|
@ -71,7 +70,7 @@ function SelfDemoteAlert({ power, onCancel, onChange }: SelfDemoteAlertProps) {
|
|||
<Box grow="Yes">
|
||||
<Text size="H4">Self Demotion</Text>
|
||||
</Box>
|
||||
<IconButton className={PhoneHit} size="300" onClick={onCancel} radii="300">
|
||||
<IconButton size="300" onClick={onCancel} radii="300">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
|
@ -121,7 +120,7 @@ function SharedPowerAlert({ power, onCancel, onChange }: SharedPowerAlertProps)
|
|||
<Box grow="Yes">
|
||||
<Text size="H4">Shared Power</Text>
|
||||
</Box>
|
||||
<IconButton className={PhoneHit} size="300" onClick={onCancel} radii="300">
|
||||
<IconButton size="300" onClick={onCancel} radii="300">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
|
|
|||
|
|
@ -54,7 +54,6 @@ import { StateEvent } from '../../../types/matrix/room';
|
|||
import { getViaServers } from '../../plugins/via-servers';
|
||||
import { rateLimitedActions } from '../../utils/matrix';
|
||||
import { useAlive } from '../../hooks/useAlive';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
|
||||
const SEARCH_OPTS: UseAsyncSearchOptions = {
|
||||
limit: 500,
|
||||
|
|
@ -202,7 +201,7 @@ export function AddExistingModal({ parentId, space, requestClose }: AddExistingM
|
|||
<Text size="H4">Add Existing</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton className={PhoneHit} size="300" radii="300" onClick={requestClose}>
|
||||
<IconButton size="300" radii="300" onClick={requestClose}>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -49,23 +49,3 @@ export const FilesWide = style({
|
|||
});
|
||||
|
||||
export const Dim = style({ opacity: config.opacity.P300 });
|
||||
|
||||
/** The share panel: idle it is one row; the title, names and Send mount only once files are picked. */
|
||||
export const SharePanel = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: config.space.S200,
|
||||
});
|
||||
|
||||
export const ShareTitle = style({
|
||||
all: 'unset',
|
||||
boxSizing: 'border-box',
|
||||
minHeight: 44,
|
||||
width: '100%',
|
||||
borderBottom: `1px solid ${color.Background.ContainerLine}`,
|
||||
color: color.Background.OnContainer,
|
||||
':focus-visible': { borderBottomColor: color.Background.OnContainer },
|
||||
});
|
||||
|
||||
/** Picked names and send notes wrap, even a long unbroken name. */
|
||||
export const ShareNames = style({ overflowWrap: 'anywhere' });
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ import { timeDayMonYear } from '../../utils/time';
|
|||
import { getMemberDisplayName } from '../../utils/room';
|
||||
import { Album, AlbumFile } from './buildAlbums';
|
||||
import { FileReadBy } from './FileReadBy';
|
||||
import { ShareSet, useShareEnabled } from './ShareSet';
|
||||
import { useAlbums } from './useAlbums';
|
||||
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 authorOf = (a: Album) => getMemberDisplayName(room, a.author) ?? a.author;
|
||||
const encrypted = room.hasEncryptionStateEvent();
|
||||
const canShare = useShareEnabled(room);
|
||||
|
||||
let body: React.ReactNode;
|
||||
if (loading) body = <Text>Loading albums…</Text>;
|
||||
|
|
@ -136,12 +134,6 @@ export function AlbumView({ room, onTimeline }: AlbumViewProps) {
|
|||
<Scroll hideTrack visibility="Hover">
|
||||
<div className={css.Column}>
|
||||
{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}>
|
||||
<Text size="B400">Timeline ›</Text>
|
||||
</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 { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { callEmbedAtom } from '../../state/callEmbed';
|
||||
import { PhoneHitSpread26 } from '../../styles/PhoneHit.css';
|
||||
|
||||
type MicrophoneButtonProps = {
|
||||
enabled: boolean;
|
||||
|
|
@ -15,7 +14,7 @@ type MicrophoneButtonProps = {
|
|||
function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps) {
|
||||
const [micState, toggleMic] = useAsyncCallback(onToggle);
|
||||
const loading = micState.status === AsyncStatus.Loading;
|
||||
|
||||
|
||||
return (
|
||||
<TooltipProvider
|
||||
position="Top"
|
||||
|
|
@ -27,7 +26,6 @@ function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps
|
|||
>
|
||||
{(anchorRef) => (
|
||||
<IconButton
|
||||
className={PhoneHitSpread26}
|
||||
ref={anchorRef}
|
||||
variant={enabled ? 'Surface' : 'Warning'}
|
||||
fill="Soft"
|
||||
|
|
@ -61,7 +59,6 @@ function SoundButton({ enabled, onToggle, disabled }: SoundButtonProps) {
|
|||
>
|
||||
{(anchorRef) => (
|
||||
<IconButton
|
||||
className={PhoneHitSpread26}
|
||||
ref={anchorRef}
|
||||
variant={enabled ? 'Surface' : 'Warning'}
|
||||
fill="Soft"
|
||||
|
|
@ -102,7 +99,6 @@ function VideoButton({ enabled, onToggle, disabled }: VideoButtonProps) {
|
|||
>
|
||||
{(anchorRef) => (
|
||||
<IconButton
|
||||
className={PhoneHitSpread26}
|
||||
ref={anchorRef}
|
||||
variant={enabled ? 'Success' : 'Surface'}
|
||||
fill="Soft"
|
||||
|
|
@ -140,7 +136,6 @@ function ScreenShareButton({ enabled, onToggle, disabled }: ScreenShareButtonPro
|
|||
>
|
||||
{(anchorRef) => (
|
||||
<IconButton
|
||||
className={PhoneHitSpread26}
|
||||
ref={anchorRef}
|
||||
variant={enabled ? 'Success' : 'Surface'}
|
||||
fill="Soft"
|
||||
|
|
@ -169,10 +164,7 @@ export function CallControl({
|
|||
const { microphone, video, sound, screenshare } = useCallControlState(callEmbed.control);
|
||||
const setCallEmbed = useSetAtom(callEmbedAtom);
|
||||
|
||||
const handleMicrophoneToggle = useCallback(
|
||||
() => callEmbed.control.toggleMicrophone(),
|
||||
[callEmbed]
|
||||
);
|
||||
const handleMicrophoneToggle = useCallback(() => callEmbed.control.toggleMicrophone(), [callEmbed]);
|
||||
const handleVideoToggle = useCallback(() => callEmbed.control.toggleVideo(), [callEmbed]);
|
||||
|
||||
const [hangupState, hangup] = useAsyncCallback(
|
||||
|
|
@ -203,7 +195,11 @@ export function CallControl({
|
|||
disabled={!callJoined}
|
||||
/>
|
||||
{!compact && <StatusDivider />}
|
||||
<VideoButton enabled={video} onToggle={handleVideoToggle} disabled={!callJoined} />
|
||||
<VideoButton
|
||||
enabled={video}
|
||||
onToggle={handleVideoToggle}
|
||||
disabled={!callJoined}
|
||||
/>
|
||||
{!compact && (
|
||||
<ScreenShareButton
|
||||
enabled={screenshare}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ import {
|
|||
SoundButton,
|
||||
VideoButton,
|
||||
} from './Controls';
|
||||
import { PhoneHitSpread40 } from '../../styles/PhoneHit.css';
|
||||
import { CallEmbed, useCallControlState } from '../../plugins/call';
|
||||
import { useResizeObserver } from '../../hooks/useResizeObserver';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
|
|
@ -72,10 +71,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||
setCords(undefined);
|
||||
};
|
||||
|
||||
const handleMicrophoneToggle = useCallback(
|
||||
() => callEmbed.control.toggleMicrophone(),
|
||||
[callEmbed]
|
||||
);
|
||||
const handleMicrophoneToggle = useCallback(() => callEmbed.control.toggleMicrophone(), [callEmbed]);
|
||||
const handleVideoToggle = useCallback(() => callEmbed.control.toggleVideo(), [callEmbed]);
|
||||
|
||||
const [hangupState, hangup] = useAsyncCallback(
|
||||
|
|
@ -101,7 +97,10 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||
>
|
||||
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}>
|
||||
<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()} />
|
||||
</Box>
|
||||
{!compact && <ControlDivider />}
|
||||
|
|
@ -170,7 +169,6 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||
}
|
||||
>
|
||||
<IconButton
|
||||
className={PhoneHitSpread40}
|
||||
variant="Surface"
|
||||
fill="Soft"
|
||||
radii="400"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { useAtom } from 'jotai';
|
|||
import * as css from './styles.css';
|
||||
import { callChatAtom } from '../../state/callEmbed';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { PhoneHitSpread40 } from '../../styles/PhoneHit.css';
|
||||
|
||||
export function ControlDivider() {
|
||||
return (
|
||||
|
|
@ -19,7 +18,7 @@ type MicrophoneButtonProps = {
|
|||
export function MicrophoneButton({ enabled, onToggle }: MicrophoneButtonProps) {
|
||||
const [micState, toggleMic] = useAsyncCallback(onToggle);
|
||||
const loading = micState.status === AsyncStatus.Loading;
|
||||
|
||||
|
||||
return (
|
||||
<TooltipProvider
|
||||
position="Top"
|
||||
|
|
@ -32,7 +31,6 @@ export function MicrophoneButton({ enabled, onToggle }: MicrophoneButtonProps) {
|
|||
>
|
||||
{(anchorRef) => (
|
||||
<IconButton
|
||||
className={PhoneHitSpread40}
|
||||
ref={anchorRef}
|
||||
variant={enabled ? 'Surface' : 'Warning'}
|
||||
fill="Soft"
|
||||
|
|
@ -66,7 +64,6 @@ export function SoundButton({ enabled, onToggle }: SoundButtonProps) {
|
|||
>
|
||||
{(anchorRef) => (
|
||||
<IconButton
|
||||
className={PhoneHitSpread40}
|
||||
ref={anchorRef}
|
||||
variant={enabled ? 'Surface' : 'Warning'}
|
||||
fill="Soft"
|
||||
|
|
@ -106,7 +103,6 @@ export function VideoButton({ enabled, onToggle }: VideoButtonProps) {
|
|||
>
|
||||
{(anchorRef) => (
|
||||
<IconButton
|
||||
className={PhoneHitSpread40}
|
||||
ref={anchorRef}
|
||||
variant={enabled ? 'Success' : 'Surface'}
|
||||
fill="Soft"
|
||||
|
|
@ -144,7 +140,6 @@ export function ScreenShareButton({ enabled, onToggle }: ScreenShareButtonProps)
|
|||
>
|
||||
{(anchorRef) => (
|
||||
<IconButton
|
||||
className={PhoneHitSpread40}
|
||||
ref={anchorRef}
|
||||
variant={enabled ? 'Success' : 'Surface'}
|
||||
fill="Soft"
|
||||
|
|
@ -175,7 +170,6 @@ export function ChatButton() {
|
|||
>
|
||||
{(anchorRef) => (
|
||||
<IconButton
|
||||
className={PhoneHitSpread40}
|
||||
ref={anchorRef}
|
||||
variant={chat ? 'Success' : 'Surface'}
|
||||
fill="Soft"
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export function PrescreenControls({ canJoin }: PrescreenControlsProps) {
|
|||
const { microphone, video, sound, toggleMicrophone, toggleVideo, toggleSound } =
|
||||
useCallPreferences();
|
||||
|
||||
|
||||
const handleMicrophoneToggle = useCallback(async () => toggleMicrophone(), [toggleMicrophone]);
|
||||
const handleVideoToggle = useCallback(async () => toggleVideo(), [toggleVideo]);
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ import {
|
|||
AccountDataSubmitCallback,
|
||||
} from '../../../components/AccountDataEditor';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { PhoneHit } from '../../../styles/PhoneHit.css';
|
||||
|
||||
type DeveloperToolsProps = {
|
||||
requestClose: () => void;
|
||||
|
|
@ -93,7 +92,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
|
|||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton className={PhoneHit} onClick={requestClose} variant="Surface">
|
||||
<IconButton onClick={requestClose} variant="Surface">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ import { useTextAreaCodeEditor } from '../../../hooks/useTextAreaCodeEditor';
|
|||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { syntaxErrorPosition } from '../../../utils/dom';
|
||||
import { Cursor } from '../../../plugins/text-area';
|
||||
import { PhoneHit } from '../../../styles/PhoneHit.css';
|
||||
|
||||
const EDITOR_INTENT_SPACE_COUNT = 2;
|
||||
|
||||
|
|
@ -119,7 +118,7 @@ export function SendRoomEvent({ type, stateKey, requestClose }: SendRoomEventPro
|
|||
</Chip>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton className={PhoneHit} onClick={requestClose} variant="Surface">
|
||||
<IconButton onClick={requestClose} variant="Surface">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ import { usePowerLevels } from '../../../hooks/usePowerLevels';
|
|||
import { useTextAreaCodeEditor } from '../../../hooks/useTextAreaCodeEditor';
|
||||
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
|
||||
import { PhoneHit } from '../../../styles/PhoneHit.css';
|
||||
|
||||
const EDITOR_INTENT_SPACE_COUNT = 2;
|
||||
|
||||
|
|
@ -276,7 +275,7 @@ export function StateEventEditor({ type, stateKey, requestClose }: StateEventEdi
|
|||
</Chip>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton className={PhoneHit} onClick={requestClose} variant="Surface">
|
||||
<IconButton onClick={requestClose} variant="Surface">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { Page, PageContent, PageHeader } from '../../../components/page';
|
|||
import { ImagePack } from '../../../plugins/custom-emoji';
|
||||
import { ImagePackView } from '../../../components/image-pack-view';
|
||||
import { RoomPacks } from './RoomPacks';
|
||||
import { PhoneHit } from '../../../styles/PhoneHit.css';
|
||||
|
||||
type EmojisStickersProps = {
|
||||
requestClose: () => void;
|
||||
|
|
@ -30,7 +29,7 @@ export function EmojisStickers({ requestClose }: EmojisStickersProps) {
|
|||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton className={PhoneHit} onClick={requestClose} variant="Surface">
|
||||
<IconButton onClick={requestClose} variant="Surface">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -40,7 +40,6 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
|||
import { useAlive } from '../../../hooks/useAlive';
|
||||
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
|
||||
import { PhoneHit } from '../../../styles/PhoneHit.css';
|
||||
|
||||
type CreatePackTileProps = {
|
||||
packs: ImagePack[];
|
||||
|
|
@ -216,7 +215,6 @@ export function RoomPacks({ onViewPack }: RoomPacksProps) {
|
|||
{canEdit &&
|
||||
(removed ? (
|
||||
<IconButton
|
||||
className={PhoneHit}
|
||||
size="300"
|
||||
radii="Pill"
|
||||
variant="Critical"
|
||||
|
|
@ -227,7 +225,6 @@ export function RoomPacks({ onViewPack }: RoomPacksProps) {
|
|||
</IconButton>
|
||||
) : (
|
||||
<IconButton
|
||||
className={PhoneHit}
|
||||
size="300"
|
||||
radii="Pill"
|
||||
variant="Secondary"
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ import { useRoom } from '../../../hooks/useRoom';
|
|||
import { useStateEvent } from '../../../hooks/useStateEvent';
|
||||
import { stopPropagation } from '../../../utils/keyboard';
|
||||
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
|
||||
import { PhoneHit } from '../../../styles/PhoneHit.css';
|
||||
|
||||
const ROOM_ENC_ALGO = 'm.megolm.v1.aes-sha2';
|
||||
|
||||
|
|
@ -124,12 +123,7 @@ export function RoomEncryption({ permissions }: RoomEncryptionProps) {
|
|||
<Box grow="Yes">
|
||||
<Text size="H4">Enable Encryption</Text>
|
||||
</Box>
|
||||
<IconButton
|
||||
className={PhoneHit}
|
||||
size="300"
|
||||
onClick={() => setPrompt(false)}
|
||||
radii="300"
|
||||
>
|
||||
<IconButton size="300" onClick={() => setPrompt(false)} radii="300">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
|
|
|||
|
|
@ -39,7 +39,6 @@ import { useAlive } from '../../../hooks/useAlive';
|
|||
import { creatorsSupported } from '../../../utils/matrix';
|
||||
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||
import { BreakWord } from '../../../styles/Text.css';
|
||||
import { PhoneHit } from '../../../styles/PhoneHit.css';
|
||||
|
||||
function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) {
|
||||
const mx = useMatrixClient();
|
||||
|
|
@ -106,7 +105,7 @@ function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) {
|
|||
<Box grow="Yes">
|
||||
<Text size="H4">{room.isSpaceRoom() ? 'Space Upgrade' : 'Room Upgrade'}</Text>
|
||||
</Box>
|
||||
<IconButton className={PhoneHit} size="300" onClick={requestClose} radii="300">
|
||||
<IconButton size="300" onClick={requestClose} radii="300">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
|
|
|||
|
|
@ -56,7 +56,6 @@ import { useSpaceOptionally } from '../../../hooks/useSpace';
|
|||
import { useFlattenPowerTagMembers, useGetMemberPowerTag } from '../../../hooks/useMemberPowerTag';
|
||||
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||
import { getMouseEventCords } from '../../../utils/dom';
|
||||
import { PhoneHit } from '../../../styles/PhoneHit.css';
|
||||
|
||||
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
|
||||
limit: 1000,
|
||||
|
|
@ -162,7 +161,7 @@ export function Members({ requestClose }: MembersProps) {
|
|||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton className={PhoneHit} onClick={requestClose} variant="Surface">
|
||||
<IconButton onClick={requestClose} variant="Surface">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
|
@ -278,7 +277,6 @@ export function Members({ requestClose }: MembersProps) {
|
|||
anchorRef={scrollTopAnchorRef}
|
||||
>
|
||||
<IconButton
|
||||
className={PhoneHit}
|
||||
onClick={() => virtualizer.scrollToOffset(0)}
|
||||
variant="Surface"
|
||||
radii="Pill"
|
||||
|
|
|
|||
|
|
@ -49,7 +49,6 @@ import { useAlive } from '../../../hooks/useAlive';
|
|||
import { BetaNoticeBadge } from '../../../components/BetaNoticeBadge';
|
||||
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
|
||||
import { creatorsSupported } from '../../../utils/matrix';
|
||||
import { PhoneHit } from '../../../styles/PhoneHit.css';
|
||||
|
||||
type EditPowerProps = {
|
||||
maxPower: number;
|
||||
|
|
@ -370,7 +369,7 @@ export function PowersEditor({ powerLevels, requestClose }: PowersEditorProps) {
|
|||
</Chip>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton className={PhoneHit} onClick={requestClose} variant="Surface">
|
||||
<IconButton onClick={requestClose} variant="Surface">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ import {
|
|||
import { CreateRoomModalState } from '../../state/createRoomModal';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { CreateRoomType } from '../../components/create-room/types';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
|
||||
type CreateRoomModalProps = {
|
||||
state: CreateRoomModalState;
|
||||
|
|
@ -64,7 +63,7 @@ function CreateRoomModal({ state }: CreateRoomModalProps) {
|
|||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton className={PhoneHit} size="300" radii="300" onClick={closeDialog}>
|
||||
<IconButton size="300" radii="300" onClick={closeDialog}>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ import {
|
|||
} from '../../state/hooks/createSpaceModal';
|
||||
import { CreateSpaceModalState } from '../../state/createSpaceModal';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
|
||||
type CreateSpaceModalProps = {
|
||||
state: CreateSpaceModalState;
|
||||
|
|
@ -62,7 +61,7 @@ function CreateSpaceModal({ state }: CreateSpaceModalProps) {
|
|||
<Text size="H4">New Space</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton className={PhoneHit} size="300" radii="300" onClick={closeDialog}>
|
||||
<IconButton size="300" radii="300" onClick={closeDialog}>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
import { Page, PageContent, PageContentCenter, PageHeader } from '../../components/page';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
import { getHomePath } from '../../pages/pathUtils';
|
||||
|
|
@ -34,11 +33,7 @@ export function Desk() {
|
|||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Box grow="Yes" basis="No">
|
||||
{phone && (
|
||||
<IconButton
|
||||
className={PhoneHit}
|
||||
aria-label="Back to rooms"
|
||||
onClick={() => navigate(getHomePath())}
|
||||
>
|
||||
<IconButton aria-label="Back to rooms" onClick={() => navigate(getHomePath())}>
|
||||
<Icon src={Icons.ArrowLeft} />
|
||||
</IconButton>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -30,29 +30,13 @@ export const Title = style({
|
|||
export const Fold = style({
|
||||
all: 'unset',
|
||||
cursor: 'pointer',
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
minHeight: 44,
|
||||
boxSizing: 'border-box',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: config.space.S200,
|
||||
// A folded row says it can be opened: a chevron at the end, turned down while the row is open.
|
||||
selectors: {
|
||||
'&::after': {
|
||||
content: '"›"',
|
||||
flexShrink: 0,
|
||||
fontSize: '1.5em',
|
||||
lineHeight: 1,
|
||||
transition: 'transform 120ms',
|
||||
},
|
||||
'&[aria-expanded=true]::after': { transform: 'rotate(90deg)' },
|
||||
},
|
||||
});
|
||||
|
||||
// Quiet lines (age, who it waits on, helper text) were measured at 4.5 over the day sky and 3.9-4.0 at dusk and night
|
||||
// with the theme's 0.75 (taps2/desk-REPORT.md); 0.9 clears 4.5 in all three.
|
||||
export const Quiet = style({ opacity: 0.9, margin: `${config.space.S100} 0` });
|
||||
export const Quiet = style({ opacity: config.opacity.P300, margin: `${config.space.S100} 0` });
|
||||
export const Body = style({ whiteSpace: 'pre-wrap', margin: `${config.space.S200} 0` });
|
||||
export const Options = style({ margin: 0, padding: 0, listStyle: 'none' });
|
||||
export const Option = style({ padding: `${config.space.S100} 0` });
|
||||
|
|
@ -66,9 +50,8 @@ export const Note = style({
|
|||
font: 'inherit',
|
||||
color: 'inherit',
|
||||
background: 'none',
|
||||
// The note is disabled (slice 1). Its border is a UI boundary (3:1 wanted; the faint line under the 0.38 disabled
|
||||
// opacity measured 2.0), so it is drawn dashed in the full ink and not faded.
|
||||
border: '1px dashed currentColor',
|
||||
border: `1px solid ${color.Background.ContainerLine}`,
|
||||
opacity: config.opacity.Disabled,
|
||||
});
|
||||
|
||||
/** Sticky beside the rows on wide screens; a small fixed corner image on phones. */
|
||||
|
|
@ -114,9 +97,3 @@ export const BoothLine = style({
|
|||
},
|
||||
},
|
||||
});
|
||||
|
||||
/** The recorded rulings sit on the dark ridge in every sky; they carry their own ground (scrimStyle). */
|
||||
export const Recorded = style({
|
||||
marginTop: config.space.S400,
|
||||
padding: `${config.space.S200} ${config.space.S300}`,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,19 +1,14 @@
|
|||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { ScreenSize, ScreenSizeProvider } from '../../hooks/useScreenSize';
|
||||
import { SkyInkContextProvider } from '../../hooks/useTheme';
|
||||
import { SCRIM_GROUND, SCRIM_INK } from '../scene/skyInk';
|
||||
import { DeskEntry } from './DeskEntry';
|
||||
import { DeskView, SIGN_IN_NOTE } from './DeskView';
|
||||
import { ForkList } from './forkContract';
|
||||
import { STATES } from './fixtures';
|
||||
import { setDeskSource } from './source';
|
||||
|
||||
// useTheme reads the stored settings when it loads.
|
||||
vi.hoisted(() => vi.stubGlobal('localStorage', { getItem: () => null, setItem: () => undefined }));
|
||||
|
||||
const view = (
|
||||
state: keyof typeof STATES | 'unpublished',
|
||||
size: ScreenSize,
|
||||
|
|
@ -71,19 +66,6 @@ describe('DeskView', () => {
|
|||
expect(html).toContain('Question done (ruling:done-1)');
|
||||
expect(html).not.toContain('Question shut');
|
||||
});
|
||||
it('gives the recorded rulings their own ground and ink while a scene runs, none without one', () => {
|
||||
const withScene = renderToStaticMarkup(
|
||||
<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', () => {
|
||||
const html = view('answered', ScreenSize.Desktop);
|
||||
expect(html).toContain(
|
||||
|
|
@ -163,15 +145,7 @@ describe("a question's age", () => {
|
|||
expect(html('2026-11-11T11:40:00Z')).toContain('raised just now');
|
||||
});
|
||||
it('shows no age when the card carries none, or an instant that does not parse or is ahead of the clock', () => {
|
||||
[
|
||||
'null',
|
||||
'yesterday',
|
||||
'2027-01-01T00:00:00Z',
|
||||
'2026-11-06',
|
||||
'Nov 6 2026',
|
||||
'0',
|
||||
'2026-02-30T00:00:00Z',
|
||||
].forEach((v) => {
|
||||
['null', 'yesterday', '2027-01-01T00:00:00Z', '2026-11-06', 'Nov 6 2026', '0', '2026-02-30T00:00:00Z'].forEach((v) => {
|
||||
expect(html(v === 'null' ? null : v)).not.toContain('raised ');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,8 +4,6 @@ import { PoseAvatar } from '../../components/message/pose/PoseAvatar';
|
|||
import { MINDS } from '../../components/message/pose/poses';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
import { AnswerRow, Desk, buildDesk, DeskRow } from './buildDesk';
|
||||
import { useSkyInk } from '../../hooks/useTheme';
|
||||
import { scrimStyle } from '../scene/skyInk';
|
||||
import { ForkList } from './forkContract';
|
||||
import { deskMood } from './deskMood';
|
||||
import { raisedAge } from './age';
|
||||
|
|
@ -146,7 +144,6 @@ type DeskViewProps = {
|
|||
/** One Mind's desk, read-only: a stream of rows, the recorded rulings, and the booth Persona. */
|
||||
export function DeskView({ mind, label, list, unverified, now = Date.now() }: DeskViewProps) {
|
||||
const desk = buildDesk(list, unverified);
|
||||
const skyInk = useSkyInk();
|
||||
// Slice 1 has no session, so nothing on the desk is the viewer's answer yet.
|
||||
const mood = deskMood(desk.counts.open, 0);
|
||||
return (
|
||||
|
|
@ -169,7 +166,7 @@ export function DeskView({ mind, label, list, unverified, now = Date.now() }: De
|
|||
)}
|
||||
</div>
|
||||
{desk.recorded.length > 0 && (
|
||||
<div className={css.Recorded} style={scrimStyle(skyInk)}>
|
||||
<>
|
||||
<h3 className={css.Title}>Recorded as rulings</h3>
|
||||
<ul className={css.Options}>
|
||||
{desk.recorded.map((r) => (
|
||||
|
|
@ -178,7 +175,7 @@ export function DeskView({ mind, label, list, unverified, now = Date.now() }: De
|
|||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -109,10 +109,7 @@ describe('v4 manifest against the parser', () => {
|
|||
|
||||
it("every voice file is Aqua's v4 render (sha256 prefix)", () => {
|
||||
const hash = (file: string) =>
|
||||
createHash('sha256')
|
||||
.update(readFileSync(join(DINGS, file)))
|
||||
.digest('hex')
|
||||
.slice(0, 12);
|
||||
createHash('sha256').update(readFileSync(join(DINGS, file))).digest('hex').slice(0, 12);
|
||||
expect(Object.keys(V4_SHA).length).toBe(VOICES.length * MOTIFS.length);
|
||||
Object.entries(V4_SHA).forEach(([k, want]) => expect(hash(`senders/${k}.ogg`)).toBe(want));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|||
import { allRoomsAtom } from '../../state/room-list/roomList';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
import { BackRouteHandler } from '../../components/BackRouteHandler';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
|
||||
type JoinBeforeNavigateProps = { roomIdOrAlias: string; eventId?: string; viaServers?: string[] };
|
||||
export function JoinBeforeNavigate({
|
||||
|
|
@ -39,7 +38,7 @@ export function JoinBeforeNavigate({
|
|||
{screenSize === ScreenSize.Mobile && (
|
||||
<BackRouteHandler>
|
||||
{(onBack) => (
|
||||
<IconButton className={PhoneHit} onClick={onBack}>
|
||||
<IconButton onClick={onBack}>
|
||||
<Icon src={Icons.ArrowLeft} />
|
||||
</IconButton>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ import { IPowerLevels } from '../../hooks/usePowerLevels';
|
|||
import { getRoomCreatorsForRoomId } from '../../hooks/useRoomCreators';
|
||||
import { getRoomPermissionsAPI } from '../../hooks/useRoomPermissions';
|
||||
import { InviteUserPrompt } from '../../components/invite-user-prompt';
|
||||
import { PhoneHitSpread24 } from '../../styles/PhoneHit.css';
|
||||
|
||||
type HierarchyItemWithParent = HierarchyItem & {
|
||||
parentId: string;
|
||||
|
|
@ -232,7 +231,6 @@ export function HierarchyItemMenu({
|
|||
return (
|
||||
<Box gap="200" alignItems="Center" shrink="No">
|
||||
<IconButton
|
||||
className={PhoneHitSpread24}
|
||||
onClick={handleOpenMenu}
|
||||
size="300"
|
||||
variant="SurfaceVariant"
|
||||
|
|
|
|||
|
|
@ -48,7 +48,6 @@ import { useGetRoom } from '../../hooks/useGetRoom';
|
|||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { getRoomPermissionsAPI } from '../../hooks/useRoomPermissions';
|
||||
import { getRoomCreatorsForRoomId } from '../../hooks/useRoomCreators';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
|
||||
const useCanDropLobbyItem = (
|
||||
space: Room,
|
||||
|
|
@ -414,7 +413,6 @@ export function Lobby() {
|
|||
onVisibilityChange={setOnTop}
|
||||
>
|
||||
<IconButton
|
||||
className={PhoneHit}
|
||||
onClick={() => virtualizer.scrollToOffset(0)}
|
||||
variant="SurfaceVariant"
|
||||
radii="Pill"
|
||||
|
|
|
|||
|
|
@ -38,7 +38,6 @@ import { useOpenSpaceSettings } from '../../state/hooks/spaceSettings';
|
|||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||
import { InviteUserPrompt } from '../../components/invite-user-prompt';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
|
||||
type LobbyMenuProps = {
|
||||
powerLevels: IPowerLevels;
|
||||
|
|
@ -166,7 +165,7 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
|
|||
<Box shrink="No">
|
||||
<BackRouteHandler>
|
||||
{(onBack) => (
|
||||
<IconButton className={PhoneHit} fill="None" onClick={onBack}>
|
||||
<IconButton fill="None" onClick={onBack}>
|
||||
<Icon src={Icons.ArrowLeft} />
|
||||
</IconButton>
|
||||
)}
|
||||
|
|
@ -241,7 +240,6 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
|
|||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
className={PhoneHit}
|
||||
fill="None"
|
||||
onClick={handleOpenMenu}
|
||||
ref={triggerRef}
|
||||
|
|
|
|||
|
|
@ -22,9 +22,7 @@ export function LobbyHero() {
|
|||
const name = useRoomName(space);
|
||||
const topic = useRoomTopic(space);
|
||||
const avatarMxc = useRoomAvatar(space);
|
||||
const avatarUrl = avatarMxc
|
||||
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
|
||||
: undefined;
|
||||
const avatarUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined : undefined;
|
||||
|
||||
return (
|
||||
<PageHero
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ import { SearchResultGroup } from './SearchResultGroup';
|
|||
import { SearchInput } from './SearchInput';
|
||||
import { SearchFilters } from './SearchFilters';
|
||||
import { VirtualTile } from '../../components/virtualizer';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
|
||||
const useSearchPathSearchParams = (searchParams: URLSearchParams): _SearchPathSearchParams =>
|
||||
useMemo(
|
||||
|
|
@ -194,7 +193,6 @@ export function MessageSearch({
|
|||
<Box direction="Column" gap="700">
|
||||
<ScrollTopContainer scrollRef={scrollRef} anchorRef={scrollTopAnchorRef}>
|
||||
<IconButton
|
||||
className={PhoneHit}
|
||||
onClick={() => virtualizer.scrollToOffset(0)}
|
||||
variant="SurfaceVariant"
|
||||
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 { StateEvent } from '../../../types/matrix/room';
|
||||
import { webRTCSupported } from '../../utils/rtc';
|
||||
import { PhoneHitSpread24 } from '../../styles/PhoneHit.css';
|
||||
|
||||
type RoomNavItemMenuProps = {
|
||||
room: Room;
|
||||
|
|
@ -230,7 +229,6 @@ function CallChatToggle() {
|
|||
|
||||
return (
|
||||
<IconButton
|
||||
className={PhoneHitSpread24}
|
||||
onClick={() => setChat(!chat)}
|
||||
aria-pressed={chat}
|
||||
aria-label="Toggle Chat"
|
||||
|
|
@ -438,7 +436,6 @@ export function RoomNavItem({
|
|||
}
|
||||
>
|
||||
<IconButton
|
||||
className={PhoneHitSpread24}
|
||||
onClick={handleOpenMenu}
|
||||
aria-pressed={!!menuAnchor}
|
||||
aria-controls={`menu-${room.roomId}`}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ import { Permissions } from './permissions';
|
|||
import { RoomSettingsPage } from '../../state/roomSettings';
|
||||
import { useRoom } from '../../hooks/useRoom';
|
||||
import { DeveloperTools } from '../common-settings/developer-tools';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
|
||||
type RoomSettingsMenuItem = {
|
||||
page: RoomSettingsPage;
|
||||
|
|
@ -118,7 +117,7 @@ export function RoomSettings({ initialPage, requestClose }: RoomSettingsProps) {
|
|||
</Box>
|
||||
<Box shrink="No">
|
||||
{screenSize === ScreenSize.Mobile && (
|
||||
<IconButton className={PhoneHit} onClick={requestClose} variant="Background">
|
||||
<IconButton onClick={requestClose} variant="Background">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ import {
|
|||
} from '../../common-settings/general';
|
||||
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
|
||||
import { PhoneHit } from '../../../styles/PhoneHit.css';
|
||||
|
||||
type GeneralProps = {
|
||||
requestClose: () => void;
|
||||
|
|
@ -36,7 +35,7 @@ export function General({ requestClose }: GeneralProps) {
|
|||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton className={PhoneHit} onClick={requestClose} variant="Surface">
|
||||
<IconButton onClick={requestClose} variant="Surface">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import { usePermissionGroups } from './usePermissionItems';
|
|||
import { PermissionGroups, Powers, PowersEditor } from '../../common-settings/permissions';
|
||||
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
|
||||
import { PhoneHit } from '../../../styles/PhoneHit.css';
|
||||
|
||||
type PermissionsProps = {
|
||||
requestClose: () => void;
|
||||
|
|
@ -46,7 +45,7 @@ export function Permissions({ requestClose }: PermissionsProps) {
|
|||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton className={PhoneHit} onClick={requestClose} variant="Surface">
|
||||
<IconButton onClick={requestClose} variant="Surface">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { Page, PageHeader } from '../../components/page';
|
|||
import { callChatAtom } from '../../state/callEmbed';
|
||||
import { RoomView } from './RoomView';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
|
||||
export function CallChatView() {
|
||||
const { eventId } = useParams();
|
||||
|
|
@ -42,12 +41,7 @@ export function CallChatView() {
|
|||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
className={PhoneHit}
|
||||
ref={triggerRef}
|
||||
variant="Surface"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<IconButton ref={triggerRef} variant="Surface" onClick={handleClose}>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -59,7 +59,6 @@ import { useSpaceOptionally } from '../../hooks/useSpace';
|
|||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||
import { useFlattenPowerTagMembers, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
|
||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
|
||||
type MemberDrawerHeaderProps = {
|
||||
room: Room;
|
||||
|
|
@ -88,7 +87,6 @@ function MemberDrawerHeader({ room }: MemberDrawerHeaderProps) {
|
|||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
className={PhoneHit}
|
||||
ref={triggerRef}
|
||||
variant="Background"
|
||||
onClick={() => setPeopleDrawer(false)}
|
||||
|
|
@ -361,7 +359,6 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
|
|||
|
||||
<ScrollTopContainer scrollRef={scrollRef} anchorRef={scrollTopAnchorRef}>
|
||||
<IconButton
|
||||
className={PhoneHit}
|
||||
onClick={() => virtualizer.scrollToOffset(0)}
|
||||
variant="Surface"
|
||||
radii="Pill"
|
||||
|
|
|
|||
|
|
@ -28,9 +28,6 @@ import {
|
|||
config,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import { RoomOutbox } from './RoomOutbox';
|
||||
import { sendTracked } from '../../state/room/outboxSend';
|
||||
import { clearDraft, loadDraft, saveDraft } from '../../state/room/composerStore';
|
||||
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import {
|
||||
|
|
@ -96,7 +93,12 @@ import { safeFile } from '../../utils/mimeTypes';
|
|||
import { fulfilledPromiseSettledResult } from '../../utils/common';
|
||||
import { useSetting } from '../../state/hooks/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 { getMemberDisplayName, getMentionContent, trimReplyFromBody } from '../../utils/room';
|
||||
import { CommandAutocomplete } from './CommandAutocomplete';
|
||||
|
|
@ -116,7 +118,6 @@ import { useTheme } from '../../hooks/useTheme';
|
|||
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
|
||||
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
|
||||
import { useComposingCheck } from '../../hooks/useComposingCheck';
|
||||
import { PhoneHitSpread24, PhoneHitSpread32 } from '../../styles/PhoneHit.css';
|
||||
|
||||
interface RoomInputProps {
|
||||
editor: Editor;
|
||||
|
|
@ -225,66 +226,22 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||
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(() => {
|
||||
Transforms.insertFragment(editor, msgDraft);
|
||||
}, [editor, msgDraft]);
|
||||
|
||||
const persistDraft = useCallback(() => {
|
||||
clearTimeout(draftSaveTimer.current);
|
||||
draftSaveTimer.current = undefined;
|
||||
saveDraft(userId, roomId, editor.children);
|
||||
}, [userId, roomId, editor]);
|
||||
|
||||
// Write on input (debounced), and flush when the page is hidden or unloading, which is
|
||||
// all a mobile reload gives us.
|
||||
const handleEditorChange = useCallback(() => {
|
||||
clearTimeout(draftSaveTimer.current);
|
||||
draftSaveTimer.current = setTimeout(persistDraft, 400);
|
||||
}, [persistDraft]);
|
||||
|
||||
useEffect(() => {
|
||||
const flush = () => {
|
||||
if (draftSaveTimer.current !== undefined) persistDraft();
|
||||
};
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === 'hidden') flush();
|
||||
};
|
||||
window.addEventListener('pagehide', flush);
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
return () => {
|
||||
window.removeEventListener('pagehide', flush);
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
};
|
||||
}, [persistDraft]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
clearTimeout(draftSaveTimer.current);
|
||||
draftSaveTimer.current = undefined;
|
||||
if (!isEmptyEditor(editor)) {
|
||||
const parsedDraft = JSON.parse(JSON.stringify(editor.children));
|
||||
setMsgDraft(parsedDraft);
|
||||
saveDraft(userId, roomId, parsedDraft);
|
||||
} else {
|
||||
setMsgDraft([]);
|
||||
clearDraft(userId, roomId);
|
||||
}
|
||||
resetEditor(editor);
|
||||
resetEditorHistory(editor);
|
||||
},
|
||||
[roomId, editor, setMsgDraft, userId]
|
||||
[roomId, editor, setMsgDraft]
|
||||
);
|
||||
|
||||
const handleFileMetadata = useCallback(
|
||||
|
|
@ -324,7 +281,16 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||
const fileItem = selectedFiles.find((f) => f.file === upload.file);
|
||||
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
|
||||
// ciphertext hash in file.hashes; a plaintext hash would leak the file's identity.
|
||||
if (!fileItem.encInfo) {
|
||||
|
|
@ -335,7 +301,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||
});
|
||||
handleCancelUpload(uploads);
|
||||
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(() => {
|
||||
|
|
@ -414,25 +380,12 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||
content['m.relates_to'].is_falling_back = false;
|
||||
}
|
||||
}
|
||||
sendTracked(mx, roomId, content);
|
||||
clearTimeout(draftSaveTimer.current);
|
||||
draftSaveTimer.current = undefined;
|
||||
clearDraft(userId, roomId);
|
||||
mx.sendMessage(roomId, content as any);
|
||||
resetEditor(editor);
|
||||
resetEditorHistory(editor);
|
||||
setReplyDraft(undefined);
|
||||
sendTypingStatus(false);
|
||||
}, [
|
||||
mx,
|
||||
roomId,
|
||||
userId,
|
||||
editor,
|
||||
replyDraft,
|
||||
sendTypingStatus,
|
||||
setReplyDraft,
|
||||
isMarkdown,
|
||||
commands,
|
||||
]);
|
||||
}, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands]);
|
||||
|
||||
const handleKeyDown: KeyboardEventHandler = useCallback(
|
||||
(evt) => {
|
||||
|
|
@ -494,7 +447,11 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||
await getImageUrlBlob(stickerUrl)
|
||||
);
|
||||
|
||||
sendTracked(mx, roomId, { body: label, url: mxc, info }, EventType.Sticker);
|
||||
mx.sendEvent(roomId, EventType.Sticker, {
|
||||
body: label,
|
||||
url: mxc,
|
||||
info,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -594,53 +551,47 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||
onKeyDown={handleKeyDown}
|
||||
onKeyUp={handleKeyUp}
|
||||
onPaste={handlePaste}
|
||||
onChange={handleEditorChange}
|
||||
top={
|
||||
<>
|
||||
<RoomOutbox mx={mx} roomId={roomId} />
|
||||
{replyDraft && (
|
||||
<div>
|
||||
<Box
|
||||
alignItems="Center"
|
||||
gap="300"
|
||||
style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
|
||||
replyDraft && (
|
||||
<div>
|
||||
<Box
|
||||
alignItems="Center"
|
||||
gap="300"
|
||||
style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
|
||||
>
|
||||
<IconButton
|
||||
onClick={() => setReplyDraft(undefined)}
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
>
|
||||
<IconButton
|
||||
className={PhoneHitSpread24}
|
||||
onClick={() => setReplyDraft(undefined)}
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
>
|
||||
<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>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
{trimReplyFromBody(replyDraft.body)}
|
||||
<b>
|
||||
{getMemberDisplayName(room, replyDraft.userId) ??
|
||||
getMxIdLocalPart(replyDraft.userId) ??
|
||||
replyDraft.userId}
|
||||
</b>
|
||||
</Text>
|
||||
</ReplyLayout>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Text size="T300" truncate>
|
||||
{trimReplyFromBody(replyDraft.body)}
|
||||
</Text>
|
||||
</ReplyLayout>
|
||||
</Box>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</Box>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
before={
|
||||
<IconButton
|
||||
className={PhoneHitSpread32}
|
||||
onClick={() => pickFile('*')}
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
|
|
@ -652,7 +603,6 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||
after={
|
||||
<>
|
||||
<IconButton
|
||||
className={PhoneHitSpread32}
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
|
|
@ -695,7 +645,6 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||
>
|
||||
{!hideStickerBtn && (
|
||||
<IconButton
|
||||
className={PhoneHitSpread32}
|
||||
aria-pressed={emojiBoardTab === EmojiBoardTab.Sticker}
|
||||
onClick={() => setEmojiBoardTab(EmojiBoardTab.Sticker)}
|
||||
variant="SurfaceVariant"
|
||||
|
|
@ -709,7 +658,6 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||
</IconButton>
|
||||
)}
|
||||
<IconButton
|
||||
className={PhoneHitSpread32}
|
||||
ref={emojiBtnRef}
|
||||
aria-pressed={
|
||||
hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
|
||||
|
|
@ -729,13 +677,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||
</PopOut>
|
||||
)}
|
||||
</UseStateProvider>
|
||||
<IconButton
|
||||
className={PhoneHitSpread32}
|
||||
onClick={submit}
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
>
|
||||
<IconButton onClick={submit} variant="SurfaceVariant" size="300" radii="300">
|
||||
<Icon src={Icons.Send} />
|
||||
</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 { Opts as LinkifyOpts } from 'linkifyjs';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { isUnconfirmedLocalEcho } from './localEcho';
|
||||
import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../utils/matrix';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator';
|
||||
|
|
@ -390,19 +389,6 @@ const useLiveEventArrive = (room: Room, onArrive: (mEvent: MatrixEvent) => void)
|
|||
}, [room, onArrive]);
|
||||
};
|
||||
|
||||
/** A local echo changes status (or becomes its remote echo) without a Timeline event: redraw. */
|
||||
const useLocalEchoRefresh = (room: Room, onUpdate: () => void) => {
|
||||
useEffect(() => {
|
||||
const handle: RoomEventHandlerMap[RoomEvent.LocalEchoUpdated] = (_evt, r) => {
|
||||
if (r.roomId === room.roomId) onUpdate();
|
||||
};
|
||||
room.on(RoomEvent.LocalEchoUpdated, handle);
|
||||
return () => {
|
||||
room.removeListener(RoomEvent.LocalEchoUpdated, handle);
|
||||
};
|
||||
}, [room, onUpdate]);
|
||||
};
|
||||
|
||||
const useLiveTimelineRefresh = (room: Room, onRefresh: () => void) => {
|
||||
useEffect(() => {
|
||||
const handleTimelineRefresh: RoomEventHandlerMap[RoomEvent.TimelineRefresh] = (r) => {
|
||||
|
|
@ -662,18 +648,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
)
|
||||
);
|
||||
|
||||
useLocalEchoRefresh(
|
||||
room,
|
||||
useCallback(() => {
|
||||
// the echo was hidden while unconfirmed, so its arrival scrolled to nothing: scroll now
|
||||
if (atBottomRef.current) {
|
||||
scrollToBottomRef.current.count += 1;
|
||||
scrollToBottomRef.current.smooth = true;
|
||||
}
|
||||
setTimeline((ct) => ({ ...ct }));
|
||||
}, [])
|
||||
);
|
||||
|
||||
const handleOpenEvent = useCallback(
|
||||
async (
|
||||
evtId: string,
|
||||
|
|
@ -1672,7 +1646,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
if (mEvent.isRedacted() && !showHiddenEvents) {
|
||||
return null;
|
||||
}
|
||||
if (isUnconfirmedLocalEcho(mEvent)) return null;
|
||||
|
||||
if (!newDivider && readUptoEventIdRef.current) {
|
||||
newDivider = prevEvent?.getId() === readUptoEventIdRef.current;
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue