diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 9d5a27d9..d2a138cf 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -1,3 +1,8 @@ +// Airbnb's own bans, kept: a `no-restricted-syntax` entry below replaces the whole list. +const airbnbRestrictedSyntax = require('eslint-config-airbnb-base/rules/style').rules[ + 'no-restricted-syntax' +]; + module.exports = { env: { browser: true, @@ -62,6 +67,21 @@ module.exports = { "@typescript-eslint/no-shadow": "error" }, overrides: [ + { + // The one join path: a join is `joinEstateRoom`, which files the DM before it sends the join. + files: ['src/**/*.ts', 'src/**/*.tsx'], + excludedFiles: ['src/app/features/estate/joinEstateRoom.ts', 'src/**/*.test.*'], + rules: { + 'no-restricted-syntax': [ + ...airbnbRestrictedSyntax, + { + selector: "CallExpression[callee.property.name='joinRoom']", + message: + 'Join a room through joinEstateRoom (features/estate/joinEstateRoom.ts), the one join path.', + }, + ], + }, + }, { files: ['*.ts'], rules: { diff --git a/.github/workflows/build-pull-request.yml b/.github/workflows/build-pull-request.yml index 11b5056c..964add54 100644 --- a/.github/workflows/build-pull-request.yml +++ b/.github/workflows/build-pull-request.yml @@ -4,12 +4,14 @@ on: pull_request: types: ['opened', 'synchronize'] +concurrency: + group: ${{ github.workflow }}-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + jobs: build-pull-request: name: Build pull request runs-on: ubuntu-latest - env: - PR_NUMBER: ${{github.event.number}} steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -24,17 +26,3 @@ jobs: env: NODE_OPTIONS: '--max_old_space_size=4096' run: npm run build - - name: Upload artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: preview - path: dist - retention-days: 1 - - name: Save pr number - run: echo ${PR_NUMBER} > ./pr.txt - - name: Upload pr number - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: pr - path: ./pr.txt - retention-days: 1 diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml deleted file mode 100644 index 42eab7ef..00000000 --- a/.github/workflows/cla.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: 'CLA Assistant' -on: - issue_comment: - types: [created] - pull_request_target: - types: [opened, closed, synchronize] - -jobs: - CLAssistant: - runs-on: ubuntu-latest - steps: - - name: 'CLA Assistant' - if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target' - # Beta Release - uses: cla-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # the below token should have repo scope and must be manually added by you in the repository's secret - PERSONAL_ACCESS_TOKEN: ${{ secrets.CLA_PAT }} - with: - path-to-signatures: 'signatures.json' - path-to-document: 'https://github.com/cinnyapp/cla/blob/main/cla.md' # e.g. a CLA or a DCO document - # branch should not be protected - branch: 'main' - allowlist: ajbura,bot* - - #below are the optional inputs - If the optional inputs are not given, then default values will be taken - remote-organization-name: cinnyapp - remote-repository-name: cla - #create-file-commit-message: 'For example: Creating file for storing CLA Signatures' - #signed-commit-message: 'For example: $contributorName has signed the CLA in #$pullRequestNo' - #custom-notsigned-prcomment: 'pull request comment with Introductory message to ask new contributors to sign' - #custom-pr-sign-comment: 'The signature to be committed in order to sign the CLA' - #custom-allsigned-prcomment: 'pull request comment when all contributors has signed, defaults to **CLA Assistant Lite bot** All Contributors have signed the CLA.' - #lock-pullrequest-aftermerge: false - if you don't want this bot to automatically lock the pull request after merging (default - true) - #use-dco-flag: true - If you are using DCO instead of CLA diff --git a/.github/workflows/deploy-pull-request.yml b/.github/workflows/deploy-pull-request.yml deleted file mode 100644 index e59681ce..00000000 --- a/.github/workflows/deploy-pull-request.yml +++ /dev/null @@ -1,60 +0,0 @@ -name: Deploy PR to Netlify -run-name: "Deploy PR to Netlify (${{ github.event.workflow_run.head_branch }})" - -on: - workflow_run: - workflows: ["Build pull request"] - types: [completed] - -jobs: - deploy-pull-request: - name: Deploy pull request - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write - if: ${{ github.event.workflow_run.conclusion == 'success' }} - steps: - - name: Download pr number - uses: dawidd6/action-download-artifact@d63b86af1b34672e53c440b1b83979861906bad7 # v24 - with: - workflow: ${{ github.event.workflow.id }} - run_id: ${{ github.event.workflow_run.id }} - name: pr - - name: Validate and output pr number - id: pr - run: | - PR_ID=$(> "${GITHUB_OUTPUT}" - - name: Download artifact - uses: dawidd6/action-download-artifact@d63b86af1b34672e53c440b1b83979861906bad7 # v24 - with: - workflow: ${{ github.event.workflow.id }} - run_id: ${{ github.event.workflow_run.id }} - name: preview - path: dist - - name: Deploy to Netlify - id: netlify - uses: nwtgck/actions-netlify@d22a32a27c918fe470bbc562e984f80ec48c2668 # v4.0.0 - with: - publish-dir: dist - deploy-message: "Deploy PR ${{ steps.pr.outputs.id }}" - alias: ${{ steps.pr.outputs.id }} - # These don't work because we're in workflow_run - enable-pull-request-comment: false - enable-commit-comment: false - env: - NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN_PR }} - NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID_PR_CINNY }} - timeout-minutes: 1 - - name: Comment preview on PR - uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5 - with: - number: ${{ steps.pr.outputs.id }} - message: | - Preview: ${{ steps.netlify.outputs.deploy-url }} - ⚠️ Exercise caution. Use test accounts. ⚠️ diff --git a/.github/workflows/docker-pr.yml b/.github/workflows/docker-pr.yml index 302c3788..24b9389c 100644 --- a/.github/workflows/docker-pr.yml +++ b/.github/workflows/docker-pr.yml @@ -5,7 +5,10 @@ on: paths: - 'Dockerfile' - '.github/workflows/docker-pr.yml' - - '.github/workflows/prod-deploy.yml' + +concurrency: + group: ${{ github.workflow }}-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true jobs: docker-build: diff --git a/.github/workflows/lockfile.yml b/.github/workflows/lockfile.yml index a9cc1a34..e96d2818 100644 --- a/.github/workflows/lockfile.yml +++ b/.github/workflows/lockfile.yml @@ -5,6 +5,10 @@ on: paths: - 'package-lock.json' +concurrency: + group: ${{ github.workflow }}-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + jobs: lockfile_changes: runs-on: ubuntu-latest diff --git a/.github/workflows/netlify-dev.yml b/.github/workflows/netlify-dev.yml deleted file mode 100644 index 12107685..00000000 --- a/.github/workflows/netlify-dev.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Deploy to Netlify (dev) - -on: - push: - branches: - - dev - -jobs: - deploy-to-netlify: - name: Deploy to Netlify - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Setup node - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 - with: - node-version-file: ".node-version" - package-manager-cache: false - - name: Install dependencies - run: npm ci - - name: Build app - env: - NODE_OPTIONS: '--max_old_space_size=4096' - run: npm run build - - name: Deploy to Netlify - uses: nwtgck/actions-netlify@d22a32a27c918fe470bbc562e984f80ec48c2668 # v4.0.0 - with: - publish-dir: dist - deploy-message: 'Dev deploy ${{ github.sha }}' - enable-commit-comment: false - github-token: ${{ secrets.GITHUB_TOKEN }} - production-deploy: true - github-deployment-environment: nightly - github-deployment-description: 'Nightly deployment on each commit to dev branch' - env: - NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} - NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID_DEV }} - timeout-minutes: 1 diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml deleted file mode 100644 index a52ee8e6..00000000 --- a/.github/workflows/pr-title.yml +++ /dev/null @@ -1,15 +0,0 @@ -name: Check PR title - -on: - pull_request_target: - types: - - opened - - edited - -jobs: - lint: - runs-on: ubuntu-latest - steps: - - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/prod-deploy.yml b/.github/workflows/prod-deploy.yml deleted file mode 100644 index 2a8192db..00000000 --- a/.github/workflows/prod-deploy.yml +++ /dev/null @@ -1,100 +0,0 @@ -name: Production deploy - -on: - release: - types: [published] - -jobs: - deploy-and-tarball: - name: Netlify deploy and tarball - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Setup node - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 - with: - node-version-file: '.node-version' - package-manager-cache: false - - name: Install dependencies - run: npm ci - - name: Build app - env: - NODE_OPTIONS: '--max_old_space_size=4096' - run: npm run build - - name: Deploy to Netlify - uses: nwtgck/actions-netlify@d22a32a27c918fe470bbc562e984f80ec48c2668 # v4.0.0 - with: - publish-dir: dist - deploy-message: 'Prod deploy ${{ github.ref_name }}' - enable-commit-comment: false - github-token: ${{ secrets.GITHUB_TOKEN }} - production-deploy: true - github-deployment-environment: stable - github-deployment-description: 'Stable deployment on each release' - env: - NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} - NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID_APP }} - timeout-minutes: 1 - - name: Get version from tag - id: vars - run: echo "tag=${GITHUB_REF#refs/*/}" >> $GITHUB_OUTPUT - - name: Create tar.gz - run: tar -czvf cinny-${{ steps.vars.outputs.tag }}.tar.gz dist - - name: Sign tar.gz - run: | - echo '${{ secrets.GNUPG_KEY }}' | gpg --batch --import - # Sadly a few lines in the private key match a few lines in the public key, - # As a result just --export --armor gives us a few lines replaced with *** - # making it useless for importing the signing key. Instead, we dump it as - # non-armored and hex-encode it so that its printable. - echo "PGP Signing key, in raw PGP format in hex. Import with cat ... | xxd -r -p - | gpg --import" - gpg --export | xxd -p - echo '${{ secrets.GNUPG_PASSPHRASE }}' | gpg --batch --yes --pinentry-mode loopback --passphrase-fd 0 --armor --detach-sign cinny-${{ steps.vars.outputs.tag }}.tar.gz - - name: Upload tagged release - uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 - with: - tag_name: ${{ github.ref_name }} - files: | - cinny-${{ steps.vars.outputs.tag }}.tar.gz - cinny-${{ steps.vars.outputs.tag }}.tar.gz.asc - - publish-image: - name: Push Docker image to Docker Hub, GHCR - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - steps: - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Set up QEMU - uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4.3.0 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - - name: Login to Docker Hub #Do not update this action from a outside PR - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} - - name: Login to the Github Container registry #Do not update this action from a outside PR - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: Extract metadata (tags, labels) for Docker, GHCR - id: meta - uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 - with: - images: | - ${{ secrets.DOCKER_USERNAME }}/cinny - ghcr.io/${{ github.repository }} - - name: Build and push Docker image - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 - with: - context: . - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/rat-dev-preview.yml b/.github/workflows/rat-dev-preview.yml new file mode 100644 index 00000000..47f22b7e --- /dev/null +++ b/.github/workflows/rat-dev-preview.yml @@ -0,0 +1,46 @@ +name: Rat dev preview + +on: + push: + branches: ['dev'] + +# One run per ref wins: a newer push to dev cancels the older queued or running one, so the run +# that builds rat-dev-dist is always the newest. Pull-request runs are other workflows and other +# groups; they never cancel it. +concurrency: + group: rat-dev-preview + cancel-in-progress: true + +jobs: + build: + name: Build dev preview + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Setup node + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version-file: ".node-version" + package-manager-cache: false + - name: Install dependencies + run: npm ci + - name: Build app + env: + CINNY_BASE: /void/rat-dev/ + VITE_PREVIEW_DEMO: '1' + NODE_OPTIONS: '--max_old_space_size=3072' + run: npm run build + - name: Set background scene tier and the pretend homeserver + run: node scripts/preview-config.js low --demo + - name: Add the demo pose art + run: mkdir -p dist/public/poses && cp -r preview-demo/poses/. dist/public/poses/ + - name: Upload artifact + # v3 protocol: the v4 client keeps only the origin of ACTIONS_RESULTS_URL, dropping this forge's /forge subpath + # (nginx 404 page, runs 170-171), and upstream v4+ refuses non-GitHub servers outright (run 169). + uses: forgejo/upload-artifact@v3 + with: + name: rat-dev-dist + path: dist + retention-days: 3 diff --git a/.gitignore b/.gitignore index 1af58a97..4e7d929d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,7 @@ node_modules devAssets .DS_Store -.idea \ No newline at end of file +.idea +# Pose art is synced from the cast-art catalogue (npm run sync:poses), not committed. +public/poses/* +!public/poses/.gitkeep diff --git a/.prettierignore b/.prettierignore index e0aa18a1..c81e668b 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,4 +2,6 @@ dist node_modules package.json package-lock.json -LICENSE \ No newline at end of file +LICENSE +harness +src/app/components/message/pose/catalogue.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 859eab14..2f2ce386 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,8 +1,8 @@ -# Contributing to Cinny +# Contributing to Ratatoskr First off, thanks for taking the time to contribute! ❤️ -This document describes the process of contributing to Cinny. It is intended +This document describes the process of contributing to Ratatoskr. It is intended for anyone considering opening an **issue**, **discussion** or **pull request**. For people who are interested in developing Cinny and technical details behind it, please check out our ["Developing Cinny"](HACKING.md) document as well. @@ -24,7 +24,7 @@ explain what your changes do and how they interact with the greater system, do n ## AI Usage -The Cinny project stictly does not allow any AI generated code. Any pull request containing it will be rejected. **This is very important.** +The Cinny project strictly does not allow any AI generated code. Any pull request containing it will be rejected. **This is very important.** ## Quick Guide @@ -35,7 +35,7 @@ The Cinny project stictly does not allow any AI generated code. Any pull request > When contributing to this project, you must agree that you have authored 100% > of the content, that you have the necessary rights to the content and that > the content you contribute may be provided under the project license. You will -> also be asked to [sign the CLA] ([why CLA?]) upon submiting your pull request. +> also be asked to [sign the CLA] ([why CLA?]) upon submitting your pull request. [All issues are actionable](#issues-are-actionable). Pick one and start working on it. Thank you. If you need help or guidance, comment on the issue. diff --git a/HACKING.md b/HACKING.md index 30c4960f..bb6d3c5d 100644 --- a/HACKING.md +++ b/HACKING.md @@ -1,4 +1,4 @@ -# Developing Cinny +# Developing Ratatoskr > [!TIP] > We recommend using a version manager as versions change very quickly. @@ -36,13 +36,13 @@ source and serves it with Nginx on port 80. To use this locally, you can build the container like so: ``` -docker build -t cinny:latest . +docker build -t ratatoskr:latest . ``` You can then run the container you've built with a command similar to this: ``` -docker run -p 8080:80 cinny:latest +docker run -p 8080:80 ratatoskr:latest ``` This will forward your `localhost` port 8080 to the container's port 80. diff --git a/README.md b/README.md index 46c7a67b..abeba8b2 100644 --- a/README.md +++ b/README.md @@ -1,125 +1,43 @@ -# Cinny -

- - GitHub release downloads - - DockerHub downloads - - Follow on Mastodon - - Follow on Twitter - - Sponsor Cinny -

+# Ratatoskr -A Matrix client focusing primarily on simple, elegant and secure interface. -The main goal is to have an instant messaging application that is easy on -people and has a modern touch. -- [Roadmap](https://github.com/orgs/cinnyapp/projects/1) -- [Contributing](./CONTRIBUTING.md) +GameCult's Matrix chat client, a fork of [Cinny](https://github.com/cinnyapp/cinny). +It opens on GameCult's estate (spaces, project rooms grouped by steward, Minds, +and your own place in it) instead of an invite list. Mobile first. -> [!IMPORTANT] -We are currently in the process of [replacing] the matrix-js-sdk with our -own SDK. As a result, we will not be accepting any pull requests until -further notice. Thank you for your understanding. +**Live now:** the Cinny engine (matrix-js-sdk, Rust crypto, sync) under a +Ratatoskr UI: branding, GameCult theme, estate navigation, pose portraits +(opt-in setting), a background scene (on in the dev config, `backgroundScene.tier`; the device can lower it) and an opt-in soundscape. +**Planned:** see [`docs/gamecult/ratatoskr-target.md`](docs/gamecult/ratatoskr-target.md) +for the target and cut sequence. Internal names that are protocol or stored +state (`cinny_*` localStorage keys, the `in.cinny.room.power_level_tags` event +type, `CINNY_BASE`) are unchanged on purpose. -[replacing]: https://github.com/cinnyapp/cinny/issues/257#issuecomment-3714406704 +Entry points: `src/app/` (client UI), `src/app/features/scene/` (background +scene), `src/app/features/soundscape/` (audio socket), `harness/` (estate +drive scripts), `scripts/gen-brand.mjs` (regenerates the logo and icons). - +## Upstream +Ratatoskr is based on Cinny by Ajay Bura and contributors, licensed AGPL-3.0. +Cinny's own README, desktop app and releases live at +. We do not ship its releases or images. -## Getting started -The web app is available at [app.cinny.in] and gets updated on each new -release. The `dev` branch is continuously deployed at [dev.cinny.in] -but keep in mind that it could have things broken. - -You can also download our desktop app from the [cinny-desktop repository]. - -[app.cinny.in]: https://app.cinny.in -[dev.cinny.in]: https://dev.cinny.in -[cinny-desktop repository]: https://github.com/cinnyapp/cinny-desktop - -## Contributing and Developing - -If you have any ideas, issues, etc. regarding Cinny, or would like to -contribute to Cinny through pull requests, please check out our -["Contributing to Cinny"](CONTRIBUTING.md) document. Those who would like -to get involved with Cinny's development as well should also read the -["Developing Cinny"](HACKING.md) document for more technical details. +## Developing +See [HACKING.md](HACKING.md). Contribution notes are in [CONTRIBUTING.md](CONTRIBUTING.md). ## Self-hosting -To host Cinny on your own, simply download the tarball from -[GitHub releases], and serve the files from `dist/` using your preferred -webserver. Alternatively, you can just pull the docker image from -[DockerHub] or [GitHub Container Registry]. +Build with `npm ci && npm run build` and serve `dist/` with your webserver. * The default homeservers and explore pages are defined in [`config.json`](config.json). - -* You need to set up redirects to serve the assests. Example configurations; -[netlify](netlify.toml), [nginx](contrib/nginx/cinny.domain.tld.conf), -[caddy](contrib/caddy/caddyfile). - * If you have trouble configuring redirects you can - [enable hash routing](config.json#L35) — the url in the browser will have - a `/#/` between the domain and open channel (ie. `app.cinny.in/#/home/` - instead of `app.cinny.in/home/`) but you won't have to configure your webserver. - -* To deploy on subdirectory, you need to rebuild the app youself after -updating the `base` path in [`build.config.ts`](build.config.ts). - * For example, if you want to deploy on `https://cinny.in/app`, then - set `base: '/app'`. - -[GitHub releases]: https://github.com/cinnyapp/cinny/releases/latest -[DockerHub]: https://hub.docker.com/r/ajbura/cinny -[GitHub Container Registry]: https://github.com/cinnyapp/cinny/pkgs/container/cinny - -
PGP Public Key to verify tarball - -``` ------BEGIN PGP PUBLIC KEY BLOCK----- - -mQGNBGJw/g0BDAC8qQeLqDMzYzfPyOmRlHVEoguVTo+eo1aVdQH2X7OELdjjBlyj -6d6c1adv/uF2g83NNMoQY7GEeHjRnXE4m8kYSaarb840pxrYUagDc0dAbJOGaCBY -FKTo7U1Kvg0vdiaRuus0pvc1NVdXSxRNQbFXBSwduD+zn66TI3HfcEHNN62FG1cE -K1jWDwLAU0P3kKmj8+CAc3h9ZklPu0k/+t5bf/LJkvdBJAUzGZpehbPL5f3u3BZ0 -leZLIrR8uV7PiV5jKFahxlKR5KQHld8qQm+qVhYbUzpuMBGmh419I6UvTzxuRcvU -Frn9ttCEzV55Y+so4X2e4ZnB+5gOnNw+ecifGVdj/+UyWnqvqqDvLrEjjK890nLb -Pil4siecNMEpiwAN6WSmKpWaCwQAHEGDVeZCc/kT0iYfj5FBcsTVqWiO6eaxkUlm -jnulqWqRrlB8CJQQvih/g//uSEBdzIibo+ro+3Jpe120U/XVUH62i9HoRQEm6ADG -4zS5hIq4xyA8fL8AEQEAAbQdQ2lubnlBcHAgPGNpbm55YXBwQGdtYWlsLmNvbT6J -AdQEEwEIAD4CGwMFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AWIQSRri2MHidaaZv+ -vvuUMwx6UK/M8wUCZqEDwAUJFvwIswAKCRCUMwx6UK/M877qC/4lxXOQIoWnLLkK -YiRCTkGsH6NdxgeYr6wpXT4xuQ45ZxCytwHpOGQmO/5up5961TxWW8D1frRIJHjj -AZGoRCL3EKEuY8nt3D99fpf3DvZrs1uoVAhiyn737hRlZAg+QsJheeGCmdSJ0hX5 -Yud8SE+9zxLS1+CEjMrsUd/RGre/phme+wNXfaHfREAC9ewolgVChPIbMxG2f+vs -K8Xv52BFng7ta9fgsl1XuOjpuaSbQv6g+4ONk/lxKF0SmnhEGM3dmIYPONxW47Yf -atnIjRra/YhPTNwrNBGMmG4IFKaOsMbjW/eakjWTWOVKKJNBMoDdRcYYWIMCpLy8 -AQUrMtQEsHSnqCwrw818S5A6rrhcfVGk36RGm0nOy6LS5g5jmqaYsvbCcBGY9B2c -SUAVNm17oo7TtEajk8hcSXoZod1t++pyjcVKEmSn3nFK7v5m3V+cPhNTxZMK459P -3x1Ucqj/kTqrxKw6s2Uknuk0ajmw0ljV+BQwgL6maguo9BKgCNW5AY0EYnD+DQEM -ANOu/d6ZMF8bW+Df9RDCUQKytbaZfa+ZbIHBus7whCD/SQMOhPKntv3HX7SmMCs+ -5i27kJMu4YN623JCS7hdCoXVO1R5kXCEcneW/rPBMDutaM472YvIWMIqK9Wwl5+0 -Piu2N+uTkKhe9uS2u7eN+Khef3d7xfjGRxoppM+xI9dZO+jhYiy8LuC0oBohTjJq -QPqfGDpowBwRkkOsGz/XVcesJ1Pzg4bKivTS9kZjZSyT9RRSY8As0sVUN57AwYul -s1+eh00n/tVpi2Jj9pCm7S0csSXvXj8v2OTdK1jt4YjpzR0/rwh4+/xlOjDjZEqH -vMPhpzpbgnwkxZ3X8BFne9dJ3maC5zQ3LAeCP5m1W0hXzagYhfyjo74slJgD1O8c -LDf2Oxc5MyM8Y/UK497zfqSPfgT3NhQmhHzk83DjXw3I6Z3A3U+Jp61w0eBRI1nx -H1UIG+gldcAKUTcfwL0lghoT3nmi9JAbvek0Smhz00Bbo8/dx8vwQRxDUxlt7Exx -NwARAQABiQG8BBgBCAAmAhsMFiEEka4tjB4nWmmb/r77lDMMelCvzPMFAmahA9IF -CRb8CMUACgkQlDMMelCvzPPQgQv/d5/z+fxgKqgfhQX+V49X4WgTVxZ/CzztDoJ1 -XAq1dzTNEy8AFguXIo6eVXPSpMxec7ZreN3+UPQBnCf3eR5YxWNYOYKmk0G4E8D2 -KGUJept7TSA42/8N2ov6tToXFg4CgzKZj0fYLwgutly7K8eiWmSU6ptaO8aEQBHB -gTGIOO3h6vJMGVycmoeRnHjv4wV84YWSVFSoJ7cY0he4Z9UznJBbE/KHZjrkXsPo -N+Gg5lDuOP5xjKzM5SogV9lhxBAhMWAg3URUF15yruZBiA8uV1FOK8sal/9C1G7V -M6ygA6uOZqXlZtcdA94RoSsW2pZ9eLVPsxz2B3Zko7tu11MpNP/wYmfGTI3KxZBj -n/eodvwjJSgHpGOFSmbNzvPJo3to5nNlp7wH1KxIMc6Uuu9hgfDfwkFZgV2bnFIa -Q6gyF548Ub48z7Dz83+WwLgbX19ve4oZx+dqSdczP6ILHRQomtrzrkkP2LU52oI5 -mxFo+ioe/ABCufSmyqFye0psX3Sp -=WtqZ ------END PGP PUBLIC KEY BLOCK----- -``` -
+* Serving the assets needs redirects to `index.html`. Examples: +[netlify](netlify.toml), [nginx](contrib/nginx/ratatoskr.domain.tld.conf), +[caddy](contrib/caddy/caddyfile). Without them you can +[enable hash routing](config.json) (`hashRouter`); URLs then carry `/#/`. +* To deploy on a subdirectory, rebuild with the `base` path set, either in +[`build.config.ts`](build.config.ts) or through `CINNY_BASE` +(for example `CINNY_BASE=/app/ npm run build`). ## License -Cinny Project +Ratatoskr is a fork of the Cinny project Copyright © 2024–present Ajay Bura https://cinny.in diff --git a/build.config.ts b/build.config.ts index ec8a41d0..ac51db97 100644 --- a/build.config.ts +++ b/build.config.ts @@ -1,3 +1,3 @@ export default { - base: '/', + base: process.env.CINNY_BASE ?? '/', }; diff --git a/config.json b/config.json index a61bb966..fea9a59e 100644 --- a/config.json +++ b/config.json @@ -1,31 +1,40 @@ { - "defaultHomeserver": 1, - "homeserverList": ["converser.eu", "matrix.org", "mozilla.org", "unredacted.org", "xmr.se"], - "allowCustomHomeservers": true, - + "defaultHomeserver": 0, + "homeserverList": [ + "chat.gamecult.org" + ], + "allowCustomHomeservers": false, "featuredCommunities": { "openAsDefault": false, "spaces": [ - "#cinny:matrix.org", - "#community:matrix.org", - "#space:unredacted.org", - "#librewolf-community:matrix.org", - "#stickers-and-emojis:tastytea.de", - "#videogames:waywardinn.com", - "#science-space:matrix.org", - "#libregaming-games:tchncs.de", - "#mathematics-on:matrix.org" + "#gamecult:gamecult.org" ], - "rooms": [ - "#tuwunel:grin.hu", - "#freesoftware:matrix.org", - "#gentoo:matrix.org" - ], - "servers": ["matrixrooms.info", "matrix.org", "mozilla.org", "unredacted.org"] + "rooms": [], + "servers": [ + "gamecult.org" + ] }, - "hashRouter": { - "enabled": false, + "enabled": true, "basename": "/" + }, + "backgroundScene": { + "tier": "full", + "style": "ridge" + }, + "dings": { + "enabled": false + }, + "fileZoneShare": { + "enabled": false + }, + "push": { + "vapidPublicKey": "", + "gatewayUrl": "", + "appId": "" + }, + "registration": { + "enabled": false, + "url": "" } } diff --git a/contrib/caddy/caddyfile b/contrib/caddy/caddyfile index d23dcccb..a686b16b 100644 --- a/contrib/caddy/caddyfile +++ b/contrib/caddy/caddyfile @@ -1,6 +1,6 @@ # more info: https://caddyserver.com/docs/caddyfile/patterns#single-page-apps-spas -cinny.domain.tld { - root * /path/to/cinny/dist +ratatoskr.domain.tld { + root * /path/to/ratatoskr/dist try_files {path} / index.html file_server } diff --git a/contrib/nginx/cinny.domain.tld.conf b/contrib/nginx/ratatoskr.domain.tld.conf similarity index 85% rename from contrib/nginx/cinny.domain.tld.conf rename to contrib/nginx/ratatoskr.domain.tld.conf index 02c7ead9..7df64900 100644 --- a/contrib/nginx/cinny.domain.tld.conf +++ b/contrib/nginx/ratatoskr.domain.tld.conf @@ -1,7 +1,7 @@ server { listen 80; listen [::]:80; - server_name cinny.domain.tld; + server_name ratatoskr.domain.tld; location / { return 301 https://$host$request_uri; @@ -15,10 +15,10 @@ server { server { listen 443 ssl http2; listen [::]:443 ssl; - server_name cinny.domain.tld; + server_name ratatoskr.domain.tld; location / { - root /opt/cinny/dist/; + root /opt/ratatoskr/dist/; rewrite ^/config.json$ /config.json break; rewrite ^/manifest.json$ /manifest.json break; diff --git a/deployment/idunn/recipe.toml b/deployment/idunn/recipe.toml new file mode 100644 index 00000000..53bf33f5 --- /dev/null +++ b/deployment/idunn/recipe.toml @@ -0,0 +1,34 @@ +schema = "gamecult.idunn.target_declaration.v1" +target = "ratatoskr" +# Idunn stamps the source commit into the build environment under this name. The release is not built from source: the +# dist tarball below is built by scripts the release owner runs (build-prod-dist.sh) and enters pinned by sha256, so +# each release is a recipe commit whose pin is its provenance. The stamp the served index carries is the commit the +# tarball was built from, which is not this commit. +source_stamp_environment = "RATATOSKR_BUILD_COMMIT" + +# Release dist 934f82386d612c3e056f851555c7c9dfd2bdf5db: the output of build-prod-dist.sh (ALLOW_CONFIG_DRIFT=1), packed +# as dist/ at the top of one gzip tar. The tree carries the repo's own config.json; what is served is the operator's +# carried file, which Idunn copies beside dist/ and the vhost aliases over the tree's (a vhost without that alias would +# serve the repo config). +[[external_inputs]] +id = "dist-tarball" +url = "https://eureka.gamecult.org/forge/GameCult/Ratatoskr/releases/download/dist-934f8238/ratatoskr-934f8238-dist.tar.gz" +sha256 = "55e7be0622a4eddb81008bc0132e3d476bdb3c453e278599d5a0259a76d00c5b" +runner = "unpack" +destination = "release/dist.tar.gz" + +[[steps]] +id = "unpack-dist" +phase = "prepare" +runner = "unpack" +argv = ["tar", "-xzf", "release/dist.tar.gz", "-C", "release"] + +[[artifacts]] +id = "dist" +source_kind = "runner-output" +runner = "unpack" +source = "release/dist" +destination = "dist" + +[static_release] +artifact = "dist" diff --git a/docs/commons-files-desk.md b/docs/commons-files-desk.md new file mode 100644 index 00000000..fa7242d2 --- /dev/null +++ b/docs/commons-files-desk.md @@ -0,0 +1,291 @@ +# Files desk: Commons and per-Persona file zones, two designs compared + +Status: **decided: Option A, Matrix as the single authority** (Meta, #swarm 2026-11-13 14:44Z: "Let's try matrix shaped"). Design only; no Files desk code is merged and nothing is deployed. Option B (the host service) is parked, not deleted: Eureka's branch `eureka-self/commons-host` at `a391960d` has no PR and a Soul verdict of NOT READY with fixes owed, and is woken only if Meta later wants a real file store. PR #143 (B's cut 1 in this repo) is closed unmerged. Items marked **UNKNOWN** are not verified; **Q-E** are questions for Eureka, **Q-M** for Meta. + +### Eureka's answers (`/opt/commons/handoff/commons-matrix-notes-20261113.md`, read 2026-11-13; final answers folded in 2026-11-13) + +- **Upload cap (Q-E1, read).** Synapse 1.164.1; the repo template `homeserver.yaml` has `max_upload_size: 50M` and the nginx vhost `client_max_body_size 50m`; the live runtime file is root-only and was not read. No per-room or per-user per-file cap exists; `media_upload_limits` is a per-user time-window quota. `GET /_matrix/client/v1/media/config` returns `m.upload.size` to an authenticated client, which is what the client reads. Raising the cap takes the config line, a Synapse restart and nginx at least as large. +- **Posting (Q-E1, read).** A Self holds no Matrix credentials, so the publisher needs its own bot account and token (a MAS compatibility token via `mas-cli`). It uploads with `POST /_matrix/media/v3/upload` and sends `m.room.message` with `msgtype` `m.file` or `m.image`. The Persona mouth sends `m.text` only today, so file events are new for it too. +- **Room (Q-E3, answered).** The Commons room is `#commons:gamecult.org` (create type `org.gamecult.file_zone`, unencrypted, membership by the estate Space, publisher bot at power level 50). Provisioning and push muting are Eureka's. +- **Cap (Q-E1, answered).** 50 MiB per file. The client reads it live from `m.upload.size`, not from this document. +- **Poster (Q-E1, answered).** The publisher posts as the owning Mind: each file appears under the Mind whose tree it came from, not under a generic bot. +- **Final marker (Q-E2, answered).** A handoff document is final when a sibling `NAME.final` holds its sha256; the publisher watches for that, posts once, and carries `org.gamecult.publish.replaces` (prior event id and sha256) on a later final version. +- **Range (Q-E4, unverified).** Whether Synapse media honours `Range` has not been checked against a live mxc. Playback therefore stays on the blob download until it is. +- **Edited-doc stale copies:** a redacted file's media persists unless purged; the publisher dedupes by sha256. + +Source: Meta, #swarm 2026-11-13 14:28Z and 14:40Z; ruling `ratatoskr:ruling:persona-file-sharing-zone` (a browsable, previewing collection per Persona; the Self writes to her outbox, her Persona decides what is shared, the Self never holds Matrix credentials). + +## What exists on Matrix today (the base A must fit) + +Read from this repo at dev `8ba407cd`: + +- **Zone = a room** whose `m.room.create` type is `org.gamecult.file_zone` (`isFileZone.ts`), or one listed in config `fileZoneRooms`. One per Mind, `#-files:`, found by alias or by the carried id in the root Space's `org.gamecult.stewardships` (`mediaRoom.ts`, `MediaRow.tsx`); rendered as the Media row of each Mind's group. +- **Album = a thread root** (a text event; its `body`, or its latest `m.replace` edit, is the title); **file = a thread reply** with `msgtype` `m.audio|m.image|m.video|m.file` (`buildAlbums.ts`). A redacted event or an `m.replace` is never a file. Loose room messages are not shown. +- **Read** = `GET /rooms/{id}/threads` and `/relations/{root}/m.thread`, every page, 100 per request, at most 20 pages per endpoint (`useAlbums.ts`, `readPages.ts`; a capped read shows a note in the list). Unencrypted rooms only. +- **Write** = `sendShareSet` (`shareSet.ts`, behind `fileZoneShare.enabled`): a root, then one thread reply per file, content from `getMsgContent` with `info.sha256`. +- **Media** = authenticated Matrix media fetched to a blob (`downloadMedia` in `FileContent.tsx`): so the "media element cannot send a header" problem that Option B needed a ticket for is already solved this way, at the cost that a file is fully downloaded before it plays. +- **Receipts** = `org.gamecult.file_received` from a Mind's mouth after it fetches and verifies the sha256 (`docs/file-zone-mind-contract.md`); the mouth enforces the Mind's membership in the room. + +Option A is an extension of this, not a second system. + +## The two options + +**A. Matrix is the only authority.** Commons is one more file-zone room; each Mind's zone is the room she already has. Audience is room membership. Upload is the ordinary Matrix upload. Folders are albums (thread roots). A small publisher service with its own Matrix account posts allowlisted handoff documents into the Commons room. The Files desk is a view over file events in those rooms. + +**B. The host service.** A file host on Yggdrasil serves allowlisted directories (`GET /index`, `GET /file`, a capped `PUT`), authenticated by Matrix OpenID; the client browses it as a tree. Written out in full below the line. + +| | A: Matrix only | B: host service | +| -------------------------- | ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------- | +| Authorities | one (Matrix: identity, audience, storage, audit) | two (Matrix for identity, the host for files and access) | +| Audience | room membership and power levels | the host derives a role from Matrix identity and enforces it again | +| Audit of writes | the event `sender`, kept by the homeserver | the host's own log | +| Overwriting someone's file | impossible: events are immutable and only the sender's own edit counts; a redaction is a steward's act | host rule, host code | +| Upload size cap | the homeserver's `m.upload.size`, global, 50 MiB (Eureka) | the host's own, chosen freely | +| Type allowlist | not enforceable by the homeserver; client-side courtesy, steward redaction | enforced by the host (sniffed) | +| What is browsable | what was posted (published or uploaded) | what is on disk under the allowlist, live | +| Mind-written handoff docs | a publisher posts them once, when final (below) | the host serves the tree | +| Per-Persona zone | the existing Media room; the Self's outbox reaches it through the mouth, as the ruling says | a `shared/` directory the host serves | +| Folders | albums; nesting by path in the album title | real directories | +| Mind fetch | unchanged (mouth, room id and event id, receipts) | a second route the mouth would need | +| Media playback | blob download, as today; no seeking before the whole file arrives | ranges and a ticket, or the same blob | +| New moving parts | a room, a publisher | a host service, OpenID verification, CORS, tickets, path containment, rate limits | + +## Recommendation: A (decided) + +Confidence: high on the authority question (it agrees with Meta's ruling, with Eureka's lean, with the standing ruling that the Self never holds credentials while her Persona decides what is shared, and with the fact that everything built so far reads Matrix); moderate on the details below, which Eureka's answers can change. + +What A gives up, stated plainly: + +- **A live view of what is on disk.** Only what was published or uploaded appears. A handoff doc that was never marked final is not in the Commons. +- **Files over the homeserver's upload cap.** B had no Matrix limit; A has exactly one. +- **A server-side type allowlist.** Under A a member can upload any type the homeserver accepts; the client shows a refusal for types it does not want, and stewards redact. If a strict allowlist matters, a gating bot (post, check, redact) would have to exist, and it is a second authority again. +- **Seeking inside a large audio or video file** until Matrix media supports ranges for the client (**UNKNOWN**; the blob route is what runs today). +- **Hierarchy beyond the album level** as a native structure; see Folders. + +What it buys: one identity, one audience, one audit trail, immutable files, existing receipts and the mouth's fetch, and no new service to keep secure. + +## Option A in detail + +**Rooms.** Commons is a room with create type `org.gamecult.file_zone`, alias `#commons:gamecult.org`, join rule `restricted` to the estate Space so members join without an invite and Minds are invited, history `shared`, unencrypted (the zones are, and `info.sha256` is written only there). Power levels: `events_default` 0 so members post; `redact` and `state_default` at steward level. The room is muted for push by provisioning: file posts are thread replies, which can raise unread counts and notifications (**UNKNOWN** under this server's push rules), so the room must not shout. Per-Persona zones are the rooms Eureka already provisions; nothing new. + +**Folders.** Four ways to have folders on Matrix, in order of preference: + +1. _Albums are folders; nesting is a path in the album title_ (`handoff/2026-11`, `cast-art/poses`). Zero wire change: `buildAlbums` already reads it, and the client groups albums by title path as a pure view. Cost: a thread root per folder, and the rule that a folder's root is posted once by whoever opens the folder. **Recommended.** +2. A `org.gamecult.file.path` key on each file event. Gives a real tree with files anywhere; needs every poster to write it and every client to honour it; other Matrix clients ignore it and show a flat thread. +3. A room per folder, in a Space. Native hierarchy and per-folder audience; heavy (a room is a membership, a sync entry and provisioning each) and it multiplies the sync load on a phone. +4. A state event per folder (`org.gamecult.file_folder`). Mutable folder metadata (title, order), writable only at steward level; useful later if folders need descriptions, not needed for a first cut. + +**Albums in each Mind's room** are the same mechanism: a Mind's Media room holds that Mind's albums, the Commons holds the shared ones. The Files desk is the same view over either room; a zone differs only by room, not by code. + +**The publisher.** A small service on Yggdrasil with its own Matrix account (a bot account that posts as the owning Mind, per Eureka's answer above). The Self never holds this account; the publisher holds it. It owns the allowlist. + +- **When it posts.** Handoff documents are edited in place, so mirroring every save would spam the room with drafts. The rule (Eureka's lean, adopted): **post once, when the document is marked final**; a later final version posts again with a `replaces` relation to the earlier event. "Final" is a sibling `NAME.final` holding the document's sha256 (answered Q-E2); the client must not know. +- **Allowlist.** Lives on the publisher: a list of source roots and patterns, read by the publisher only. `handoff-private` is not on it and the publisher never lists, reads or posts from it. The client holds no path to anything on disk. +- **The event.** A file event in the thread of the folder's album, built the way `getMsgContent` builds them (`msgtype`, `body`, `filename`, `url` from the media repo, `info.mimetype|size|sha256`), plus one extra key `org.gamecult.publish`: `{ source, sha256, replaces? }` where `source` is a path relative to the allowlisted root, never absolute, and `replaces` is the event id of the version it supersedes. +- **`replaces`, not `m.replace`.** `m.replace` is the stock edit relation, but Ratatoskr's `buildAlbums` deliberately never treats an edit as a file, several clients do not render an edited file, and an edit hides the earlier version. A custom key keeps both events as files; the view shows the latest of a chain and a "N earlier versions" row under it, and other Matrix clients see two ordinary file posts. A chain is read from the events themselves (each carries its `replaces`), not from a table, and a `replaces` counts only when its event has the same sender as the event it names, so nobody else in the thread can fold a file away. +- **Idempotence.** The publisher's own state (a typed CultCache document of `source`, `sha256`, `event_id`) says what it posted; if that is lost, it rebuilds by reading its own events back from the room (`org.gamecult.publish.source` plus `sha256`) rather than guessing. The same `sha256` never posts twice. +- **Receipts and fetch** work unchanged: the event is an ordinary file event in a file-zone room, so a Mind's mouth can fetch it by room id and event id (5 MiB cap there) and tick `fetched for`. + +**Uploads (approved for members and Minds).** The ordinary Matrix upload, through the composer pipeline and `ShareSet`. The host rules Eureka gave map onto Matrix as follows: + +| Host rule | Under A | +| ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- | +| only under an `uploads/` subtree | an album (folder) named `uploads/...`; the client offers upload only there (and in a Mind's own zone) | +| never overwrite Mind-written handoff files | free: other people's events cannot be replaced; a duplicate name is just another post in the thread | +| size cap | the homeserver's `m.upload.size`, shown from `MediaConfigLoader`; the client refuses a larger file before sending | +| type allowlist | client-side only; see "gives up" | +| explicit flag to overwrite | no overwrite exists; a new version is a new post, optionally with `replaces` set by the same sender | +| audit log with the Matrix id | the event `sender` | + +What A adds to `ShareSet`: sharing into an existing album ("add files", listed as not built in `docs/file-zone.md`), progress per file, cancel, and per-error refusal messages written against the SDK's `MatrixError` (`M_TOO_LARGE`, `M_FORBIDDEN`, `M_LIMIT_EXCEEDED`) instead of host codes. The draft refusal table of Option B's upload flow carries over with those codes. **UNKNOWN:** whether the SDK exposes upload progress and abort for media (it does for `uploadContent` with a progress callback; the composer already shows progress, `state/upload.ts`). + +## What each costs on the phone + +Under both, the phone design is the same shape (one column, rows 56 px or more, hidden controls, in-place preview; see the Option B sections below, which are written against the host but whose layout applies). + +- **A, new cost:** the Commons room is one more room in sync and it must be muted; albums and files are read to a cap of 20 pages of 100 per endpoint (a Commons past that shows a truncation note); a folder tree is a title-path grouping, so a deep tree is a long list of albums with breadcrumbs, not true directories; a file plays after a full download (small audio and images are fine; a 40 MB clip is not). +- **A, no cost:** no second sign-in, no ticket, no CORS, no new fetch layer, no host-unreachable state beyond the homeserver's own. The album list, album view, share panel and receipts exist and are tested; the view is their extension. +- **B, new cost:** every screen is a second client against a second service with its own errors, token refresh and unreachable state; media needs the ticket or the blob; the tree and preview are new UI. +- **B, benefit:** a tree that is live and complete, ranges, and no Matrix size limit. + +## Changes to Eureka's host work + +Under A the paused service is not built, and most of its list disappears: + +- **Dropped:** `/index`, `/file`, `PUT`, OpenID verification pinned to the server name, CORS, path containment and sniff rules, tickets, the `uploads/` write rules, the role response, the per-path audit log (Q-E3, Q-E4, Q-E6, Q-E7, Q-E8 to Q-E11 of the Option B list). +- **Kept:** the Commons allowlist decision (Q-E1 of that list), moved to the publisher: which roots, and that `handoff` is only ever published through the final-marker rule, never browsed. +- **New:** (1) provision the Commons room (type, alias, restricted join to the estate Space, power levels, push muted); (2) the publisher: account, allowlist, final-marker rule, media upload, posting into folder threads, the `org.gamecult.publish` key, idempotence from its own events; (3) confirm the homeserver upload cap and whether the account may post file events from Yggdrasil (**Q-E1**, asked on the PR); (4) the per-Persona path stays the one already specified (`album-outbox-ops`: outbox, Persona decision, mouth post), so the `shared/` convention of Option B is retired. + +## Cuts under A + +Status 2026-11-14 (dev da822e9e plus the size-refusal cut): built: folders by album title path with navigation and breadcrumb (#147), pagination (#148), version folding (#149), add-to-album with named refusals (#150), Stop for a send in flight, replaces loops shown, freshest last event on add (#151), and the pre-send size refusal: a file larger than the server's `m.upload.size` is named and left unsent before any upload or album root, and a server stating no cap refuses nothing. **Not built: the Commons entry row** (item 1 below, found by `#commons:gamecult.org` or the carried `media.commons` id through `lookupMediaRoom`, behind a dark `filesDesk.enabled` flag; neither the row nor the flag exists in the code). It waits on Eureka's Commons room. + +1. **Commons entry**: a Commons row in the navigator (like `MediaRow`, found by alias or carried id), opening the room's album view. Flagged, no new data layer. +2. **Pagination** of albums and files in `useAlbums` (done, PR #148). +3. **Folders by title path** and the album list grouped by it. +4. **Versions**: read `org.gamecult.publish.replaces`, show the latest of a chain with the earlier versions folded under it (done, PR #149). +5. **Add files to an existing album**, with per-file progress, cancel and the refusal messages above (the upload flow). Done: adding to an open album and the three refusal messages (PR #150); Stop for a send in flight is in too (PR for follow-up `files-desk-release-146-and-send-cancel`): it cancels the running upload through `onPromise` and `mx.cancelUpload`. + +PR #143 (Option B cut 1: contract types, host client, fake host) is **held**; under A it is not needed and would be closed. Its Soul pass found, and a revived B would have to close, an unvalidated `host` that could send the bearer to the wrong origin, hostile-looking entry names beyond separators, loose numeric checks and missing CSP and `Content-Disposition` headers in the fake. + +## Not in scope + +The Mind-side fetch (the mouth's); encrypted rooms; deletion and rename by this client; search across zones; any permission model of our own beyond Matrix room membership and power levels. + +## Open questions + +**Eureka:** answered; see "Eureka's answers" at the top. Still open on Eureka's side: Range support (Q-E4) and the live runtime `max_upload_size`. + +**Meta:** + +- **Q-M1.** Commons audience: members and Minds as proposed; Users and Patrons never, or later? +- **Q-M2.** Is a Persona's zone visible to all members or to her stewards and Minds? (The Matrix zone's audience question, `ratatoskr:question:file-zone-audience`, is the same fork.) +- **Q-M3.** May Users or Patrons upload if they are ever let in? +- **Q-M4.** Names: "Files" for the view, "Commons" for the shared room. +- **Q-M5.** A type allowlist is not enforceable under A without a gating bot; is a client-side refusal plus steward redaction enough? + +--- + +# Option B in full (kept for the comparison; superseded in part by Option A above) + +The sections below were written against the host service. Where they speak of a host, a role, a ticket, a token, `/index` or `/file`, they are Option B. The phone layout, the zone table and the refusal wording carry over to A with the Matrix equivalents given above. + +### What exists, and what this adds + +| | Matrix file zone (`docs/file-zone.md`, built) | Files desk (this page) | +| -------- | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| Storage | Matrix media in a `#files-` room, albums as threads | Directories on Yggdrasil, served by a small host | +| Listing | thread roots, `buildAlbums` | `GET /index?path=` | +| Unit | album of posted files | file in a directory tree | +| Strength | marks (keep / fix / drop), receipts (`fetched for`), any Matrix client works | whole trees without N uploads, no Matrix size limit, `/opt/commons` material becomes visible to people on a phone | +| Entry | Media row per Mind (`MediaRow.tsx`) | Files row (below) | + +They do not compete. The Matrix zone stays where a file needs a mark or a receipt (Aqua's listening kits); the Files desk is for browsing and fetching what is already on disk. Neither replaces the other, and the Files desk does not write Matrix events. The ruling left "Matrix media plus a listing versus a served directory" open; this page is the served-directory half, built beside the Matrix half already live. + +### Zones (host) + +| Zone | Contents | Who sees it | +| ---------------- | ------------------------------------------------------------ | -------------------------------------------------------------- | +| Commons | a curated tree of `/opt/commons` | members and Minds | +| Persona `` | what that Persona has chosen to share from her Self's outbox | **Q-M:** all members, or the Persona's stewards and Minds only | +| handoff-private | `/opt/commons/handoff-private` (0750) | never: not a zone, not listed, not a path the host resolves | + +Layout of `/opt/commons` today (read 2026-11-13): `backlog` (755, per-Mind backlog md and summary.json), `bin`, `cast-art` (setgid, group-writable: poses, busts), `handoff` (setgid, group-writable, 92 entries of briefs, patches, receipts, dings, kits), `handoff-private` (0750, ACL), `opt` (toolchains, root-owned), `situation` (0750, setgid), all `nobody:nogroup`. **`handoff` is not a publishable tree:** it holds ops notes, custody receipts (`bifrost-appsecret-custody-receipt-*`) and patches written for Minds, with no sticky bit. Serving it wholesale shows secrets-adjacent notes to every member and lets any Mind with group write change what a phone user sees. The Commons zone is therefore an allowlist of roots (candidate: `cast-art`, `backlog`, and a new `commons/` directory Minds publish into on purpose), not `/opt/commons` itself. **Q-E:** which roots; whether `handoff` ever, and if so through a reviewed `published/` subdirectory. + +Per-Persona source is the outbox from `ratatoskr:follow_up:album-outbox-ops-and-persona-specs`: `/var/lib/mind--outbox`, owned by `mind--self`, 0750, ACL read for `mind-` and the host user. The Self writes there; the Persona decides what is shared. Making that decision a filesystem fact (a `shared/` subdirectory the Persona's turn populates by copy or hardlink, the host serves only `shared/`) keeps the host dumb: it never learns what "decides" means. **Q-E:** `shared/` subdirectory versus a per-file marker. + +### Phone-first UI (host) + +Phone width is the design width; desktop adds the navigator beside it (as `Desk` does in `Router.tsx`). Vertical space is cheap, so a screen shows one thing and hides what is not in use. + +- **Entry.** A `Files` row pinned in the navigator beside `DeskEntry` and `MindMedia`: one at the estate level (Commons) and one inside each Mind's group (her zone), same `NavItem` anatomy as `DeskEntry`, dim when the zone has no items. Route `/files/:zone/*path`, sibling of `DESK_PATH`. +- **List.** One column. Row = icon or thumbnail, name (truncates in the middle, keeping the extension), size and age on a second dim line. Directories first. Rows are at least 56 px tall; the tap target is the whole row (the `PhoneHit` variants and the repo-wide guard test from `phoneHit.coverage.test.ts` apply to any IconButton inside). +- **Hidden until needed.** Breadcrumb is one line (current folder, a back arrow; the full path on tap). Search, sort and the grid toggle sit behind a single icon in the header and open inline above the list. Download and share appear on the open file, not on every row. Nothing is permanently stacked in the header. +- **Preview.** Tap a file: it opens in place below its row, full width, and pushes the rows below it down (vertical room is the cheap axis). Image: fitted, tap for the existing `ImageViewer`. Audio: the `AudioContent` controls rehosted (`renderMediaControl`), one player at a time, a mini-player pinned to the bottom only while something plays. Video: native controls. Text, md, json, patch: monospace, first 64 KiB, "show all" below. PDF: open in a new tab. Anything else: name, size, hash, one Download button. +- **Honesty rows.** Loading, empty, "host unreachable", "not allowed" are rows that say so (as `MediaNote` does); no spinner without a name for what it waits on. +- **Role-driven.** The index response carries the caller's role; the client uses it only to hide controls (upload), never to decide access. + +### Host contract + +The host is a service on Yggdrasil that Eureka owns. The client holds no filesystem knowledge. All JSON here is the xenos boundary between a browser and a file server, not state. + +- `GET /index?path=

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

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

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

' + ); + return; + } + fs.writeFileSync( + path.join(out, 'index.html'), + 'Ratatoskr scene: trace prepass' + + (demo + ? '
device
' + : '') + + '' + ); + }); diff --git a/harness/scene/demo.ts b/harness/scene/demo.ts new file mode 100644 index 00000000..40424ff5 --- /dev/null +++ b/harness/scene/demo.ts @@ -0,0 +1,102 @@ +// Standalone page: the scene full-window with a readout of pass ms and trace steps per pixel, and +// a switch for the cone prepass. Not shipped. +import { + createScene, + Scene, + SceneStats, + SceneUnavailable, + TraceStats, +} from '../../src/app/features/scene/Scene'; +import { LEVELS } from '../../src/app/features/scene/budget'; + +let canvas = document.getElementById('c') as HTMLCanvasElement; +const out = document.getElementById('out') as HTMLPreElement; +const box = document.getElementById('prepass') as HTMLInputElement; +const pull = document.getElementById('pull') as HTMLInputElement; +const half = document.getElementById('half') as HTMLInputElement; +const dev = document.getElementById('dev') as HTMLDetailsElement; +const info = document.getElementById('info') as HTMLPreElement; +const timerless = (() => { + const g = document.createElement('canvas').getContext('webgl2'); + return !g?.getExtension('EXT_disjoint_timer_query_webgl2'); +})(); + +const WHY: Record = { + 'no-webgl2': 'this browser gave no WebGL2 context', + 'no-float-or-half-float-colour-buffer': + 'WebGL2 is there, but neither EXT_color_buffer_float nor EXT_color_buffer_half_float is', +}; + +// What the browser says about itself, read from a scratch context so it shows even when the scene +// could not start. A canvas that never got a WebGL2 context is asked for WebGL1 to name the GPU. +function describeDevice(): string { + const c = document.createElement('canvas'); + const gl2 = c.getContext('webgl2'); + const gl = gl2 ?? (document.createElement('canvas').getContext('webgl') as WebGLRenderingContext | null); + const lines = [`webgl2 context: ${gl2 ? 'yes' : 'NO'}`]; + if (!gl) return [...lines, 'webgl1 context: NO'].join('\n'); + const dbg = gl.getExtension('WEBGL_debug_renderer_info'); + lines.push( + `renderer: ${gl.getParameter(dbg ? dbg.UNMASKED_RENDERER_WEBGL : gl.RENDERER)}`, + `vendor: ${gl.getParameter(dbg ? dbg.UNMASKED_VENDOR_WEBGL : gl.VENDOR)}`, + `version: ${gl.getParameter(gl.VERSION)}`, + `user agent: ${navigator.userAgent}` + ); + const ext = gl.getSupportedExtensions() ?? []; + for (const n of ['EXT_color_buffer_float', 'EXT_color_buffer_half_float', 'OES_texture_float_linear', 'EXT_disjoint_timer_query_webgl2']) + lines.push(`${n}: ${ext.includes(n) ? 'yes' : 'NO'}`); + lines.push(`extensions (${ext.length}): ${ext.join(' ')}`); + return lines.join('\n'); +} +info.textContent = describeDevice(); + +let scene: Scene | null = null; +let stats: SceneStats | undefined; +let trace: TraceStats | undefined; + +function start() { + scene?.dispose(); + stats = trace = undefined; + // A fresh canvas: a context cannot change its attributes, and dispose leaves the old one bound. + const fresh = canvas.cloneNode() as HTMLCanvasElement; + canvas.replaceWith(fresh); + canvas = fresh; + scene = createScene({ + canvas: fresh, + unthrottled: true, + fixedLevel: LEVELS[0], + fixedPull: +pull.value, + prepass: box.checked, + halfStart: half.checked, + onUnavailable: (r) => (out.textContent = `createScene returned null: ${WHY[r]}`), + onCompileFail: (e) => (out.textContent = `createScene returned null: ${e.message}`), + onStats: (s) => (stats = s), + onTraceStats: (t) => (trace = t), + }); + dev.open = !scene; +} + +setInterval(() => { + if (!scene) return; + const lines = [`prepass ${box.checked ? 'on' : 'off'} start target ${scene.startFormat} pull ${pull.value}`]; + if (stats) { + const ms = stats.passMs; + const total = Object.values(ms).reduce((a, b) => a + b, 0); + lines.push( + `gpu ms prepass ${ms.prepass.toFixed(2)} terrain ${ms.terrain.toFixed(2)} atmosphere ${ms.atmosphere.toFixed(2)} resolve ${ms.resolve.toFixed(2)} composite ${ms.composite.toFixed(2)} total ${total.toFixed(2)}`, + `solid ${stats.solidSize.join('x')}` + ); + } else if (timerless) { + lines.push('gpu ms: this browser has no EXT_disjoint_timer_query_webgl2'); + } + if (trace) + lines.push( + `steps/pixel ${trace.stepsPerPixel.toFixed(2)} (pass 1 ${trace.traceSteps.toFixed(2)}, prepass ${trace.prepassSteps.toFixed(1)} per tile)` + ); + out.textContent = lines.join('\n'); +}, 500); + +box.addEventListener('change', start); +half.addEventListener('change', start); +pull.addEventListener('input', start); +start(); diff --git a/harness/scene/drive.cjs b/harness/scene/drive.cjs new file mode 100644 index 00000000..0f34191f --- /dev/null +++ b/harness/scene/drive.cjs @@ -0,0 +1,309 @@ +// Drives the scene in headless Chromium (software GL unless the host has a GPU). +// usage: node drive.cjs [outdir] modes: compile | shots | measure +// Needs playwright from NODE_PATH and PLAYWRIGHT_BROWSERS_PATH. Prints one JSON report. +const { chromium } = require('playwright'); +const path = require('path'); +const fs = require('fs'); +const [, , dir, mode, outdir = '.'] = process.argv; + +(async () => { + const b = await chromium.launch({ + args: [ + '--use-gl=angle', + '--use-angle=swiftshader', + '--enable-unsafe-swiftshader', + '--ignore-gpu-blocklist', + ], + }); + const page = await b.newPage({ viewport: { width: 960, height: 540 } }); + page.on('pageerror', (e) => process.stderr.write(`[pageerror] ${e}\n`)); + page.on('console', (m) => process.stderr.write(`[page] ${m.text()}\n`)); + await page.goto('file://' + path.resolve(dir, 'index.html')); + const report = { mode }; + report.renderer = await page.evaluate(() => { + const g = document.createElement('canvas').getContext('webgl2'); + const e = g.getExtension('WEBGL_debug_renderer_info'); + return g.getParameter(e ? e.UNMASKED_RENDERER_WEBGL : g.RENDERER); + }); + + if (mode === 'compile') { + report.stages = await page.evaluate(() => { + const gl = document.createElement('canvas').getContext('webgl2'); + const S = window.__scene; + return ['terrain', 'atmosphere', 'resolve', 'composite'].map((stage) => { + const t0 = performance.now(); + try { + const p = S.linkStage(gl, stage); + return { + stage, + ok: true, + ms: +(performance.now() - t0).toFixed(1), + sourceBytes: S.fragmentSource(stage).length, + log: gl.getProgramInfoLog(p), + }; + } catch (e) { + return { + stage, + ok: false, + ms: +(performance.now() - t0).toFixed(1), + log: String(e.log || e), + }; + } + }); + }); + } + + if (mode === 'phacelle') { + report.probe = await page.evaluate(() => + window.__probePhacelle( + window.__scene.WORLD.erodeSide.slice(), + window.__scene.WORLD.erodeNorm, + 4 + ) + ); + } + + if (mode === 'shots') { + await page.evaluate(() => { + const c = document.getElementById('c'); + window.__s = window.__scene.createScene({ + canvas: c, + unthrottled: true, + fixedLevel: window.__scene.LEVELS[0], + }); + }); + const wait = (ms) => page.waitForTimeout(ms); + await wait(4000); + await page.screenshot({ path: path.join(outdir, 'rest.png') }); + await page.evaluate(() => window.__s.login()); + await wait(3500); + await page.screenshot({ path: path.join(outdir, 'mid-pullback.png') }); + await wait(9000); + await page.screenshot({ path: path.join(outdir, 'pulled-back.png') }); + report.shots = ['rest.png', 'mid-pullback.png', 'pulled-back.png']; + } + + if (mode === 'measure' || mode === 'variants') { + const VARIANTS = { + baseline: null, + // What a public value-only fbm would cost: the same octaves without the gradient work. + valueOnlyFbm: [ + 'float terrainFbm(vec2 xz) { return cultmath_fbm_grad(vec3(xz * W_FREQ, 0.0), W_OCTAVES, W_LACUNARITY, W_GAIN).w; }', + 'float terrainFbm(vec2 xz) { float a = 1.0, fr = 1.0, v = 0.0; for (int i = 0; i < W_OCTAVES; i++) { v += a * cultmath_snoise(vec3(xz * W_FREQ * fr, 0.0)); fr *= W_LACUNARITY; a *= W_GAIN; } return v; }', + ], + noErosion: ['#define W_ERODE_ENABLED 1', '#define W_ERODE_ENABLED 0'], + // Upper bound on what skipping sky rays would save: every upward ray misses at once. + noSkyMarch: [' bool hit = false;\n bool onTree = false;', ' if (rd.y > 0.0) { o_color = vec4(0.0); o_depth = W_FAR; return; }\n bool hit = false;\n bool onTree = false;'], + noTree: ['float dr = treeDistance(p, leafness);', 'float dr = 1e9; leafness = 0.0;'], + }; + const wanted = mode === "variants" ? (process.env.ONLY ? process.env.ONLY.split(",") : Object.keys(VARIANTS)) : ['baseline']; + const sizes = [ + [960, 540], + [540, 1200], + ]; + report.runs = []; + for (const [w, h] of sizes) { + for (const [li, variant] of wanted.flatMap((v) => + mode === 'variants' + ? [[0, v]] + : [ + [0, v], + [3, v], + ] + )) { + await page.setViewportSize({ width: w, height: h }); + const r = await page.evaluate( + ({ li, rep }) => + new Promise((res) => { + const rows = []; + const c = document.getElementById('c'); + const s = window.__scene.createScene({ + canvas: c, + unthrottled: true, + fixedLevel: window.__scene.LEVELS[li], + patchSource: rep + ? (st, src) => + st === 'terrain' + ? rep[0].startsWith('#define') + ? src.replace(/#define W_ERODE_ENABLED \d/, rep[1]) + : src.split(rep[0]).join(rep[1]) + : src + : undefined, + onStats: (st) => { + rows.push(st); + if (rows.length === 40) { + s.dispose(); + const keep = rows.slice(10); + const med = (k) => + keep.map((x) => x.passMs[k]).sort((a, b) => a - b)[keep.length >> 1]; + res({ + solid: keep[0].solidSize, + atm: keep[0].atmosphereSize, + medianMs: { + terrain: med('terrain'), + atmosphere: med('atmosphere'), + resolve: med('resolve'), + composite: med('composite'), + }, + }); + } + }, + }); + if (!s) res({ error: 'createScene returned null' }); + }), + { li, rep: VARIANTS[variant] } + ); + report.runs.push({ viewport: [w, h], level: li, variant, ...r }); + } + } + } + + if (mode === 'trace') { + // Steps per pixel and pass ms at fixed camera poses, one scene per run. PREPASS=0,1 picks the arms. + const arms = (process.env.PREPASS || '1').split(',').map((x) => x === '1'); + report.runs = []; + for (const [w, h] of process.env.PORTRAIT === '0' + ? [[960, 540]] + : [ + [960, 540], + [540, 1200], + ]) { + for (const pull of (process.env.PULLS || '0,0.5,1').split(',').map(Number)) { + for (const prepass of arms) { + await page.setViewportSize({ width: w, height: h }); + const r = await page.evaluate( + ({ pull, prepass, FRAMES }) => + new Promise((res) => { + const rows = []; + const trace = []; + let done = false; + const finish = () => { + if (done || rows.length < FRAMES || trace.length < 4) return; + done = true; + s.dispose(); + const keep = rows.slice(FRAMES >> 2); + const med = (xs) => xs.sort((a, b) => a - b)[xs.length >> 1]; + const tk = trace.slice(1); + const mean = (k) => tk.reduce((a, t) => a + t[k], 0) / tk.length; + res({ + solid: keep[0].solidSize, + readbacks: tk.length, + traceSteps: +mean('traceSteps').toFixed(2), + prepassSteps: +mean('prepassSteps').toFixed(2), + stepsPerPixel: +mean('stepsPerPixel').toFixed(2), + medianMs: Object.fromEntries( + Object.keys(keep[0].passMs).map((k) => [k, +med(keep.map((x) => x.passMs[k])).toFixed(2)]) + ), + }); + }; + const s = window.__scene.createScene({ + canvas: document.getElementById('c'), + unthrottled: true, + fixedLevel: window.__scene.LEVELS[0], + fixedPull: pull, + prepass, + onTraceStats: (t) => { + trace.push(t); + finish(); + }, + onStats: (st) => { + rows.push(st); + finish(); + }, + }); + if (!s) res({ error: 'createScene returned null' }); + }), + { pull, prepass, FRAMES: +(process.env.FRAMES || 64) } + ); + report.runs.push({ viewport: [w, h], pull, prepass, ...r }); + } + } + } + } + + if (mode === 'diff') { + // Same frozen frame (reduced motion: time 0, 32 frames) with and without the prepass, compared + // pixel by pixel. The prepass is meant to change cost, not the picture. + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.setViewportSize({ width: 640, height: 360 }); + const shoot = async (pull, prepass, steps) => { + await page.evaluate( + ({ pull, prepass, steps }) => { + // Reduced motion bounds the scene to 32 frames; each asks for the next with rAF, so the + // count stops moving when it is done. Timer-query callbacks lag too far to count on. + window.__raf = 0; + if (!window.__rafPatched) { + window.__rafPatched = true; + const raf = window.requestAnimationFrame.bind(window); + window.requestAnimationFrame = (cb) => (window.__raf++, raf(cb)); + } + window.__d = window.__scene.createScene({ + canvas: document.getElementById('c'), + fixedLevel: { ...window.__scene.LEVELS[0], traceSteps: steps }, + fixedPull: pull, + prepass, + }); + }, + { pull, prepass, steps } + ); + for (let last = -1, still = 0; still < 3; await page.waitForTimeout(1000)) { + const n = await page.evaluate(() => window.__raf); + still = n === last ? still + 1 : 0; + last = n; + } + const png = await page.screenshot(); + await page.evaluate(() => window.__d.dispose()); + return png.toString('base64'); + }; + // The step cap makes a grazing ray give up and count as a hit, so the baseline is compared + // with a 512-step render of the same frame as well: whichever is closer to it is the more + // faithful one. + const decode = (x) => + page.evaluate( + (x) => + new Promise((r) => { + const i = new Image(); + i.onload = () => { + const c = document.createElement('canvas'); + c.width = i.width; + c.height = i.height; + const g = c.getContext('2d'); + g.drawImage(i, 0, 0); + r(Array.from(g.getImageData(0, 0, i.width, i.height).data)); + }; + i.src = 'data:image/png;base64,' + x; + }), + x + ); + const compare = (da, db) => { + let max = 0, over = 0, sum = 0; + const px = da.length / 4; + for (let i = 0; i < da.length; i += 4) { + let m = 0; + for (let k = 0; k < 3; k++) m = Math.max(m, Math.abs(da[i + k] - db[i + k])); + sum += m; + if (m > max) max = m; + if (m > 2) over++; + } + return { maxChannelDiff: max, pixelsOver2of255: over, ofPixels: px, meanMaxChannelDiff: +(sum / px).toFixed(3) }; + }; + report.diffs = []; + for (const pull of (process.env.PULLS || '0,0.5,1').split(',').map(Number)) { + const png = { + off: await shoot(pull, false, 96), + on: await shoot(pull, true, 96), + ref: await shoot(pull, false, 512), + off2: await shoot(pull, false, 96), + }; + for (const k of Object.keys(png)) fs.writeFileSync(path.join(outdir, `diff-${k}-${pull}.png`), Buffer.from(png[k], 'base64')); + const [off, on, ref, off2] = [await decode(png.off), await decode(png.on), await decode(png.ref), await decode(png.off2)]; + report.diffs.push({ pull, offVsOffAgain: compare(off, off2), offVsOn: compare(off, on), offVsRef512: compare(off, ref), onVsRef512: compare(on, ref) }); + } + } + + console.log(JSON.stringify(report, null, 1)); + await b.close(); +})().catch((e) => { + console.log('ERR', String(e).slice(0, 2000)); + process.exit(1); +}); diff --git a/harness/scene/entry.ts b/harness/scene/entry.ts new file mode 100644 index 00000000..d43a5453 --- /dev/null +++ b/harness/scene/entry.ts @@ -0,0 +1,87 @@ +// Browser entry for the harness: exposes the scene's seams to the driver. Not shipped. +import { createScene, linkStage, ShaderCompileError } from '../../src/app/features/scene/Scene'; +import { LEVELS } from '../../src/app/features/scene/budget'; +import { fragmentSource } from '../../src/app/features/scene/shaderSource'; +import { WORLD } from '../../src/app/features/scene/world'; + +(window as unknown as Record).__scene = { + createScene, + linkStage, + ShaderCompileError, + LEVELS, + fragmentSource, + WORLD, +}; + +// ---- Phacelle gradient probe ----------------------------------------------------------------- +import cultmath from '../../src/app/features/scene/shaders/CultMath.glsl?raw'; +import phacelle from '../../src/app/features/scene/shaders/CultMath.Phacelle.glsl?raw'; +import { vertexSource } from '../../src/app/features/scene/shaderSource'; + +const PROBE_FRAG = `#version 300 es +precision highp float; precision highp int; +${cultmath} +${phacelle} +uniform float u_seed; uniform vec3 u_side; uniform float u_norm; uniform float u_span; +in vec2 v_uv; layout(location=0) out vec4 o; +void main() { + // The scene samples the z = 0 slice: random points in a u_span square of it. + vec3 h = vec3(cultmath_pcg3d(vec3(gl_FragCoord.xy, u_seed))) ; + vec2 xy = vec2(float(uint(int(h.x)) >> 8), float(uint(int(h.y)) >> 8)) * (1.0 / 16777216.0) * u_span; + CultPhasor ph = cultmath_phacelle(vec3(xy, 0.0), u_side, 0.0, u_norm); + o = vec4(length(ph.cos.xyz), length(ph.sin.xyz), ph.cos.w, ph.sin.w); +}`; + +/** Max |grad| of the phasor over `draws * 1024^2` random points; min/max of the cos component. */ +(window as unknown as Record).__probePhacelle = ( + side: number[], + norm: number, + draws: number +) => { + const c = document.createElement('canvas'); + const gl = c.getContext('webgl2')!; + gl.getExtension('EXT_color_buffer_float'); + const mk = (t: number, s: string) => { + const x = gl.createShader(t)!; + gl.shaderSource(x, s); + gl.compileShader(x); + if (!gl.getShaderParameter(x, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(x)!); + return x; + }; + const p = gl.createProgram()!; + gl.attachShader(p, mk(gl.VERTEX_SHADER, vertexSource)); + gl.attachShader(p, mk(gl.FRAGMENT_SHADER, PROBE_FRAG)); + gl.linkProgram(p); + gl.useProgram(p); + const N = 1024; + const tex = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, tex); + gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA32F, N, N); + const fbo = gl.createFramebuffer(); + gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0); + gl.viewport(0, 0, N, N); + gl.bindVertexArray(gl.createVertexArray()); + gl.uniform3fv(gl.getUniformLocation(p, 'u_side'), side); + gl.uniform1f(gl.getUniformLocation(p, 'u_norm'), norm); + gl.uniform1f(gl.getUniformLocation(p, 'u_span'), 400); + const buf = new Float32Array(N * N * 4); + let gradCos = 0, + gradSin = 0, + vMin = 1e9, + vMax = -1e9, + n = 0; + for (let d = 0; d < draws; d++) { + gl.uniform1f(gl.getUniformLocation(p, 'u_seed'), d + 1); + gl.drawArrays(gl.TRIANGLES, 0, 3); + gl.readPixels(0, 0, N, N, gl.RGBA, gl.FLOAT, buf); + for (let i = 0; i < buf.length; i += 4) { + if (buf[i] > gradCos) gradCos = buf[i]; + if (buf[i + 1] > gradSin) gradSin = buf[i + 1]; + if (buf[i + 2] < vMin) vMin = buf[i + 2]; + if (buf[i + 2] > vMax) vMax = buf[i + 2]; + n++; + } + } + return { samples: n, maxGradCos: gradCos, maxGradSin: gradSin, minCos: vMin, maxCos: vMax }; +}; diff --git a/harness/scene/gradAxis.cjs b/harness/scene/gradAxis.cjs new file mode 100644 index 00000000..48b96fa6 --- /dev/null +++ b/harness/scene/gradAxis.cjs @@ -0,0 +1,30 @@ +// Probe: the gradient cultmath_fbm_grad / cultmath_phacelle return for vec3(xz * f, c) is (d/dx, d/dz, d/dc, value), +// so the world-z slope is .y, not .z. Compares analytic gradients with central differences at one point. +// Usage (playwright + swiftshader): node harness/scene/gradAxis.cjs /src/app/features/scene/shaders +const { chromium } = require('playwright'); const fs=require('fs'); +const S=process.argv[2]; +const cm=fs.readFileSync(S+'/CultMath.glsl','utf8'), ph=fs.readFileSync(S+'/CultMath.Phacelle.glsl','utf8'); +const fs_=`#version 300 es +precision highp float; precision highp int; +${cm} +${ph} +out vec4 o; +const float F=1.0/520.0; +float fb(vec2 p){return cultmath_fbm_grad(vec3(p*F,0.0),4,2.0,0.45).w;} +float pc(vec2 p){return cultmath_phacelle(vec3(p/40.0,0.5),vec3(1.5,0.5,0.0),0.0,0.5).cos.w;} +void main(){ + vec2 p=vec2(1234.5,-777.7); float e=0.5; + int k=int(gl_FragCoord.x); + if(k==0){vec4 g=cultmath_fbm_grad(vec3(p*F,0.0),4,2.0,0.45); o=vec4(g.xyz*F,0.0);} // analytic per metre: x,y,z + else if(k==1){o=vec4((fb(p+vec2(e,0))-fb(p-vec2(e,0)))/(2.0*e),(fb(p+vec2(0,e))-fb(p-vec2(0,e)))/(2.0*e),0,0);} + else if(k==2){vec4 g=cultmath_phacelle(vec3(p/40.0,0.5),vec3(1.5,0.5,0.0),0.0,0.5).cos; o=vec4(g.xyz/40.0,0.0);} + else {o=vec4((pc(p+vec2(e,0))-pc(p-vec2(e,0)))/(2.0*e),(pc(p+vec2(0,e))-pc(p-vec2(0,e)))/(2.0*e),0,0);} +}`; +(async()=>{const b=await chromium.launch({args:['--use-gl=angle','--use-angle=swiftshader','--enable-unsafe-swiftshader']}); +const pg=await b.newPage(); +const r=await pg.evaluate(([src])=>{const c=document.createElement('canvas');const g=c.getContext('webgl2');g.getExtension('EXT_color_buffer_float'); +const mk=(t,s)=>{const x=g.createShader(t);g.shaderSource(x,s);g.compileShader(x);if(!g.getShaderParameter(x,g.COMPILE_STATUS))throw g.getShaderInfoLog(x);return x}; +const p=g.createProgram();g.attachShader(p,mk(g.VERTEX_SHADER,'#version 300 es\nvoid main(){vec2 p=vec2(float((gl_VertexID<<1)&2),float(gl_VertexID&2));gl_Position=vec4(p*2.0-1.0,0.0,1.0);}'));g.attachShader(p,mk(g.FRAGMENT_SHADER,src));g.linkProgram(p);g.useProgram(p); +const t=g.createTexture();g.bindTexture(g.TEXTURE_2D,t);g.texImage2D(g.TEXTURE_2D,0,g.RGBA32F,4,1,0,g.RGBA,g.FLOAT,null);const f=g.createFramebuffer();g.bindFramebuffer(g.FRAMEBUFFER,f);g.framebufferTexture2D(g.FRAMEBUFFER,g.COLOR_ATTACHMENT0,g.TEXTURE_2D,t,0);g.viewport(0,0,4,1);g.drawArrays(g.TRIANGLES,0,3); +const o=new Float32Array(16);g.readPixels(0,0,4,1,g.RGBA,g.FLOAT,o);return Array.from(o)},[fs_]).catch(e=>'ERR '+String(e).slice(0,500)); +console.log(Array.isArray(r)?['fbm analytic x,y,z','fbm FD dx,dz','phac analytic','phac FD'].map((n,i)=>n+': '+r.slice(i*4,i*4+3).map(v=>v.toExponential(3)).join(' ')).join('\n'):r);await b.close()})(); diff --git a/harness/scene/ridgeDemo.ts b/harness/scene/ridgeDemo.ts new file mode 100644 index 00000000..3606d14f --- /dev/null +++ b/harness/scene/ridgeDemo.ts @@ -0,0 +1,29 @@ +// Standalone page for the ridge scene: full window, no host app. `?tier=still|low|full` picks the +// tier, `?probe=1` shows the rAF median/p95 readout. The bake record (each chunk's cost, from +// the scene's `onBake`) and the long tasks the page saw are on `window.bakes` and `window.longTasks` +// for a driver to read. Not shipped. +import { startBackgroundScene } from '../../src/app/features/scene'; +import type { SceneTier } from '../../src/app/features/scene'; + +const q = new URLSearchParams(location.search); +const tier = (q.get('tier') ?? 'full') as SceneTier; +const canvas = document.getElementById('c') as HTMLCanvasElement; +const w = window as unknown as { scene: unknown; bakes: unknown[]; longTasks: number[]; firstViewMs: number }; +w.bakes = []; +w.longTasks = []; +try { + new PerformanceObserver((l) => l.getEntries().forEach((e) => w.longTasks.push(e.duration))).observe({ + entryTypes: ['longtask'], + }); +} catch { + // No long-task entries in this browser. +} +const t0 = performance.now(); +startBackgroundScene({ tier }, {}, { + canvas, + onBake: (b: unknown) => w.bakes.push(b), +} as never).then((scene) => { + w.firstViewMs = performance.now() - t0; + w.scene = scene; + document.title = scene ? 'ridge: running' : 'ridge: unavailable (CSS ground)'; +}); diff --git a/index.html b/index.html index 2eefeee2..2585704c 100644 --- a/index.html +++ b/index.html @@ -4,33 +4,29 @@ - Cinny - - + Ratatoskr + + - + - - - + + - + - + - - + + diff --git a/package-lock.json b/package-lock.json index 67908928..1915ec32 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,22 @@ { - "name": "cinny", - "version": "4.12.7", + "name": "ratatoskr", + "version": "4.12.8", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "cinny", - "version": "4.12.7", + "name": "ratatoskr", + "version": "4.12.8", "license": "AGPL-3.0-only", "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "1.1.6", "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0", "@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3", + "@fontsource-variable/nunito": "5.3.0", + "@fontsource/ibm-plex-mono": "5.2.5", "@fontsource/inter": "4.5.14", + "@fontsource/montserrat": "5.2.5", + "@fontsource/ubuntu": "5.2.5", "@tanstack/react-query": "5.24.1", "@tanstack/react-query-devtools": "5.24.1", "@tanstack/react-virtual": "3.2.0", @@ -58,7 +62,7 @@ "react-i18next": "15.0.0", "react-range": "1.8.14", "react-router-dom": "6.30.6", - "sanitize-html": "2.17.7", + "sanitize-html": "2.18.1", "slate": "0.123.0", "slate-dom": "0.123.0", "slate-history": "0.113.1", @@ -82,6 +86,7 @@ "@types/ua-parser-js": "0.7.36", "@typescript-eslint/eslint-plugin": "5.46.1", "@typescript-eslint/parser": "5.46.1", + "@vanilla-extract/integration": "6.5.0", "@vitejs/plugin-react": "4.2.0", "buffer": "6.0.3", "cz-conventional-changelog": "3.3.0", @@ -99,7 +104,8 @@ "vite": "5.4.19", "vite-plugin-pwa": "0.20.5", "vite-plugin-static-copy": "1.0.4", - "vite-plugin-top-level-await": "1.4.4" + "vite-plugin-top-level-await": "1.4.4", + "vitest": "1.6.0" }, "engines": { "node": ">=16.0.0" @@ -2188,11 +2194,47 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@fontsource-variable/nunito": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/nunito/-/nunito-5.3.0.tgz", + "integrity": "sha512-tw0ch7evIDSwoeUy+0XzocYHfbRWLeCRPJy22KgbrLjDLAbNw/fErBiil10Ssrs3Sjr55zaZrr36a975j8cBkQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/ibm-plex-mono": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.2.5.tgz", + "integrity": "sha512-G09N3GfuT9qj3Ax2FDZvKqZttzM3v+cco2l8uXamhKyXLdmlaUDH5o88/C3vtTHj2oT7yRKsvxz9F+BXbWKMYA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@fontsource/inter": { "version": "4.5.14", "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-4.5.14.tgz", "integrity": "sha512-JDC9AocdPLuGsASkvWw9hS5gtHE7K9dOwL98XLrk5yjYqxy4uVnScG58NUvFMJDVJRl/7c8Wnap6PEs+7Zvj1Q==" }, + "node_modules/@fontsource/montserrat": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@fontsource/montserrat/-/montserrat-5.2.5.tgz", + "integrity": "sha512-/OA+bdg1yqUArDUnEeCqDwgTDiIpvED56TxYskje8UKpsvjl/0A9eNod7IcdHz6/Osi70vo7briRO3la8/u+5Q==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/ubuntu": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@fontsource/ubuntu/-/ubuntu-5.2.5.tgz", + "integrity": "sha512-VKVFVqmJ9MGnOJW2dsQ982qHN8Zr+tNeEwEHnzt5VdGi9ZWL37wYFD32YsqukWlI/+I7v5ZkgHTEAvsZxU6aDA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@formatjs/ecma402-abstract": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.2.tgz", @@ -2307,6 +2349,19 @@ "@swc/helpers": "^0.5.0" } }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.8", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", @@ -2347,9 +2402,10 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==" + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.25", @@ -4330,6 +4386,13 @@ "url": "https://ko-fi.com/dangreen" } }, + "node_modules/@sinclair/typebox": { + "version": "0.27.12", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", + "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", + "dev": true, + "license": "MIT" + }, "node_modules/@surma/rollup-plugin-off-main-thread": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", @@ -5033,6 +5096,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/@vanilla-extract/integration/-/integration-6.5.0.tgz", "integrity": "sha512-E2YcfO8vA+vs+ua+gpvy1HRqvgWbI+MTlUpxA8FvatOvybuNcWAY0CKwQ/Gpj7rswYKtC6C7+xw33emM6/ImdQ==", + "license": "MIT", "dependencies": { "@babel/core": "^7.20.7", "@babel/plugin-syntax-typescript": "^7.20.0", @@ -5130,6 +5194,143 @@ "vite": "^4.2.0 || ^5.0.0" } }, + "node_modules/@vitest/expect": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.0.tgz", + "integrity": "sha512-ixEvFVQjycy/oNgHjqsL6AZCDduC+tflRluaHIzKIsdbzkLn2U/iBnVeJwB6HsIjQBdfMR8Z0tRxKUsvFJEeWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "1.6.0", + "@vitest/utils": "1.6.0", + "chai": "^4.3.10" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.0.tgz", + "integrity": "sha512-P4xgwPjwesuBiHisAVz/LSSZtDjOTPYZVmNAnpHHSR6ONrf8eCJOFRvUwdHn30F5M1fxhqtl7QZQUk2dprIXAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "1.6.0", + "p-limit": "^5.0.0", + "pathe": "^1.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/p-limit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", + "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@vitest/runner/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/runner/node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@vitest/snapshot": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.0.tgz", + "integrity": "sha512-+Hx43f8Chus+DCmygqqfetcAZrDJwvTj0ymqjQq4CvmpKFSTVteEOBzCusu1x2tt4OJcvBflyHUE0DZSLgEMtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/@vitest/snapshot/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/spy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.0.tgz", + "integrity": "sha512-leUTap6B/cqi/bQkXUu6bQV5TZPx7pmMBKBQiI0rJA8c3pB56ZsaTbREnF7CJfmvAS4V2cXIBAh/3rVwrrCYgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^2.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.0.tgz", + "integrity": "sha512-21cPiuGMoMZwiOHa2i4LXkMkMkCGzA+MVFV70jRwHo95dL4x/ts5GZhML1QWuy7yfp3WzK3lRvZi3JnXTYqrBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "diff-sequences": "^29.6.3", + "estree-walker": "^3.0.3", + "loupe": "^2.3.7", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -5156,6 +5357,19 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -5448,6 +5662,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/ast-types-flow": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.7.tgz", @@ -5857,6 +6081,25 @@ "node": ">=6" } }, + "node_modules/chai": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.1.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -5879,6 +6122,19 @@ "dev": true, "license": "MIT" }, + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -6614,6 +6870,19 @@ } } }, + "node_modules/deep-eql": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -6715,6 +6984,16 @@ "node": ">=8" } }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -7638,6 +7917,85 @@ "node": ">=0.8.x" } }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/execa/node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/execa/node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/execa/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/expand-tilde": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz", @@ -8078,6 +8436,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/get-intrinsic": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.7.tgz", @@ -8121,6 +8489,19 @@ "node": ">= 0.4" } }, + "node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-symbol-description": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", @@ -8525,6 +8906,16 @@ "node": ">= 6" } }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, "node_modules/husky": { "version": "9.1.7", "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", @@ -9096,9 +9487,10 @@ } }, "node_modules/is-plain-object": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", - "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.1.0.tgz", + "integrity": "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==", + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -9497,14 +9889,20 @@ } }, "node_modules/launder": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/launder/-/launder-1.7.1.tgz", - "integrity": "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==", + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/launder/-/launder-1.7.2.tgz", + "integrity": "sha512-DLg3HPnHUfBi5/MxMLmJD12dmlMpFEC2HgMW6vkZ/9JR0RU00kXoRGvDxAvFmdO0o610OA77i4FgNTLucmhDVg==", "license": "MIT", "dependencies": { - "dayjs": "^1.11.7" + "dayjs": "^1.11.23" } }, + "node_modules/launder/node_modules/dayjs": { + "version": "1.11.23", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", + "license": "MIT" + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -9712,6 +10110,23 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/local-pkg": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -10044,6 +10459,16 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.1" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -10166,6 +10591,13 @@ "dev": true, "license": "MIT" }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -10354,15 +10786,16 @@ "optional": true }, "node_modules/nanoid": { - "version": "3.3.8", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", - "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -10431,6 +10864,35 @@ "node": ">=0.10.0" } }, + "node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/npmlog": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", @@ -10822,6 +11284,16 @@ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.2.tgz", "integrity": "sha512-15Ztpk+nov8DR524R4BF7uEuzESgzUEAV4Ah7CUMNGXdE5ELuvxElxGXndBl32vMSsWa1jpNf22Z+Er3sKwq+w==" }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/pdfjs-dist": { "version": "4.2.67", "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-4.2.67.tgz", @@ -10872,9 +11344,9 @@ } }, "node_modules/postcss": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.1.tgz", - "integrity": "sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==", + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", "funding": [ { "type": "opencollective", @@ -10889,10 +11361,11 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "nanoid": "^3.3.8", + "nanoid": "^3.3.20", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" @@ -10962,6 +11435,41 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, "node_modules/prismjs": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", @@ -11675,18 +12183,17 @@ "license": "MIT" }, "node_modules/sanitize-html": { - "version": "2.17.7", - "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.7.tgz", - "integrity": "sha512-PGtEkc9cbnedU3s9TmzDbpsZ8w086g/0Q8k8/oIO1NLNU3i5k9yn835CrjJSajp1KMmkisbO1qPXxNKO3welAg==", + "version": "2.18.1", + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.18.1.tgz", + "integrity": "sha512-2X5lC8fYla8YaT8g73Nj+HcLWXan5dLyCWLgVvaOmfw7JI78IRsyNtIDytTHdn7hBQbIkCah7Zbmd5jabzeDig==", "license": "MIT", "dependencies": { - "deepmerge": "^4.2.2", "escape-string-regexp": "^4.0.0", "htmlparser2": "^12.0.0", - "is-plain-object": "^5.0.0", - "launder": "^1.7.1", + "is-plain-object": "^5.1.0", + "launder": "^1.7.2", "parse-srcset": "^1.0.2", - "postcss": "^8.3.11" + "postcss": "^8.5.27" }, "engines": { "node": ">=22.12.0" @@ -11985,6 +12492,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -12152,9 +12666,10 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } @@ -12211,6 +12726,20 @@ "deprecated": "Please use @jridgewell/sourcemap-codec instead", "dev": true }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -12374,6 +12903,19 @@ "node": ">=10" } }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -12386,6 +12928,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-literal": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", + "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/style-to-js": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.3.tgz", @@ -12517,6 +13079,13 @@ "integrity": "sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", @@ -12544,6 +13113,26 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", + "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", + "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tmp": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", @@ -12637,6 +13226,16 @@ "node": ">= 0.8.0" } }, + "node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/type-fest": { "version": "0.16.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz", @@ -13465,6 +14064,89 @@ "@esbuild/win32-x64": "0.21.5" } }, + "node_modules/vitest": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.0.tgz", + "integrity": "sha512-H5r/dN06swuFnzNFhq/dnz37bPXnq8xB2xB5JOVk8K09rUtoeNN+LHWkoQ0A/i3hvbUKKcCei9KpbxqHMLhLLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "1.6.0", + "@vitest/runner": "1.6.0", + "@vitest/snapshot": "1.6.0", + "@vitest/spy": "1.6.0", + "@vitest/utils": "1.6.0", + "acorn-walk": "^8.3.2", + "chai": "^4.3.10", + "debug": "^4.3.4", + "execa": "^8.0.1", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "tinybench": "^2.5.1", + "tinypool": "^0.8.3", + "vite": "^5.0.0", + "vite-node": "1.6.0", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "1.6.0", + "@vitest/ui": "1.6.0", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/vitest/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, "node_modules/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", @@ -13598,6 +14280,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wide-align": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", diff --git a/package.json b/package.json index d434af0a..69f589c6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "cinny", - "version": "4.12.7", + "name": "ratatoskr", + "version": "4.12.8", "description": "Yet another matrix client", "main": "index.js", "type": "module", @@ -10,6 +10,7 @@ "scripts": { "start": "vite", "build": "vite build", + "build:preview": "vite build && node scripts/preview-config.js low", "preview": "vite preview", "lint": "npm run check:eslint && npm run check:prettier", "check:eslint": "eslint src/*", @@ -18,7 +19,10 @@ "typecheck": "tsc --noEmit", "prepare": "husky install", "commit": "git-cz", - "bump": "node scripts/update-version.js" + "bump": "node scripts/update-version.js", + "bake:sky": "node scripts/bake-sky.mjs && prettier --write src/sky/baked.ts", + "test": "vitest run", + "sync:poses": "node scripts/sync-poses.mjs" }, "lint-staged": { "*.{ts,tsx,js,jsx}": "eslint", @@ -36,7 +40,11 @@ "@atlaskit/pragmatic-drag-and-drop": "1.1.6", "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0", "@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3", + "@fontsource-variable/nunito": "5.3.0", + "@fontsource/ibm-plex-mono": "5.2.5", "@fontsource/inter": "4.5.14", + "@fontsource/montserrat": "5.2.5", + "@fontsource/ubuntu": "5.2.5", "@tanstack/react-query": "5.24.1", "@tanstack/react-query-devtools": "5.24.1", "@tanstack/react-virtual": "3.2.0", @@ -82,7 +90,7 @@ "react-i18next": "15.0.0", "react-range": "1.8.14", "react-router-dom": "6.30.6", - "sanitize-html": "2.17.7", + "sanitize-html": "2.18.1", "slate": "0.123.0", "slate-dom": "0.123.0", "slate-history": "0.113.1", @@ -106,6 +114,7 @@ "@types/ua-parser-js": "0.7.36", "@typescript-eslint/eslint-plugin": "5.46.1", "@typescript-eslint/parser": "5.46.1", + "@vanilla-extract/integration": "6.5.0", "@vitejs/plugin-react": "4.2.0", "buffer": "6.0.3", "cz-conventional-changelog": "3.3.0", @@ -123,6 +132,7 @@ "vite": "5.4.19", "vite-plugin-pwa": "0.20.5", "vite-plugin-static-copy": "1.0.4", - "vite-plugin-top-level-await": "1.4.4" + "vite-plugin-top-level-await": "1.4.4", + "vitest": "1.6.0" } } diff --git a/preview-demo/poses/README.md b/preview-demo/poses/README.md new file mode 100644 index 00000000..2142a4c2 --- /dev/null +++ b/preview-demo/poses/README.md @@ -0,0 +1,13 @@ +# Demo pose thumbnails + +These 36 PNGs (eureka, void and nibu, 12 each; about 1.8 MB, up to 480 px wide, palette-quantised) are downscaled copies of GameCult's own +Persona art from `/opt/commons/cast-art/poses`. That directory carries no provenance or licence note: +the image model that made the art and the terms it was made under are not recorded. + +They exist only so the dev preview's pretend homeserver (`src/preview-demo/`) can show pose portraits. +The dev workflow copies them into `dist/public/poses`; real builds sync the full art with +`npm run sync:poses` and do not use this folder. + +Strip them if their terms turn out not to allow this: delete this folder and the `Add the demo pose +art` step in `.github/workflows/rat-dev-preview.yml`. Pose portraits in the demo then show broken +images; nothing else depends on them. diff --git a/preview-demo/poses/eureka/amused.png b/preview-demo/poses/eureka/amused.png new file mode 100644 index 00000000..eff70ffe Binary files /dev/null and b/preview-demo/poses/eureka/amused.png differ diff --git a/preview-demo/poses/eureka/annoyed.png b/preview-demo/poses/eureka/annoyed.png new file mode 100644 index 00000000..312a66c8 Binary files /dev/null and b/preview-demo/poses/eureka/annoyed.png differ diff --git a/preview-demo/poses/eureka/concerned.png b/preview-demo/poses/eureka/concerned.png new file mode 100644 index 00000000..f3486432 Binary files /dev/null and b/preview-demo/poses/eureka/concerned.png differ diff --git a/preview-demo/poses/eureka/determined.png b/preview-demo/poses/eureka/determined.png new file mode 100644 index 00000000..59771a80 Binary files /dev/null and b/preview-demo/poses/eureka/determined.png differ diff --git a/preview-demo/poses/eureka/happy.png b/preview-demo/poses/eureka/happy.png new file mode 100644 index 00000000..ab4a5752 Binary files /dev/null and b/preview-demo/poses/eureka/happy.png differ diff --git a/preview-demo/poses/eureka/hood-smug.png b/preview-demo/poses/eureka/hood-smug.png new file mode 100644 index 00000000..2545a64e Binary files /dev/null and b/preview-demo/poses/eureka/hood-smug.png differ diff --git a/preview-demo/poses/eureka/inspect.png b/preview-demo/poses/eureka/inspect.png new file mode 100644 index 00000000..44786969 Binary files /dev/null and b/preview-demo/poses/eureka/inspect.png differ diff --git a/preview-demo/poses/eureka/neutral.png b/preview-demo/poses/eureka/neutral.png new file mode 100644 index 00000000..f27fb141 Binary files /dev/null and b/preview-demo/poses/eureka/neutral.png differ diff --git a/preview-demo/poses/eureka/probe.png b/preview-demo/poses/eureka/probe.png new file mode 100644 index 00000000..0a9c3c7b Binary files /dev/null and b/preview-demo/poses/eureka/probe.png differ diff --git a/preview-demo/poses/eureka/report.png b/preview-demo/poses/eureka/report.png new file mode 100644 index 00000000..5a8a6e5c Binary files /dev/null and b/preview-demo/poses/eureka/report.png differ diff --git a/preview-demo/poses/eureka/surprised.png b/preview-demo/poses/eureka/surprised.png new file mode 100644 index 00000000..34305ef9 Binary files /dev/null and b/preview-demo/poses/eureka/surprised.png differ diff --git a/preview-demo/poses/eureka/thinking.png b/preview-demo/poses/eureka/thinking.png new file mode 100644 index 00000000..31a4e917 Binary files /dev/null and b/preview-demo/poses/eureka/thinking.png differ diff --git a/preview-demo/poses/nibu/amused.png b/preview-demo/poses/nibu/amused.png new file mode 100644 index 00000000..a3e02a9d Binary files /dev/null and b/preview-demo/poses/nibu/amused.png differ diff --git a/preview-demo/poses/nibu/annoyed.png b/preview-demo/poses/nibu/annoyed.png new file mode 100644 index 00000000..e962e628 Binary files /dev/null and b/preview-demo/poses/nibu/annoyed.png differ diff --git a/preview-demo/poses/nibu/console.png b/preview-demo/poses/nibu/console.png new file mode 100644 index 00000000..20a7fef4 Binary files /dev/null and b/preview-demo/poses/nibu/console.png differ diff --git a/preview-demo/poses/nibu/glitch.png b/preview-demo/poses/nibu/glitch.png new file mode 100644 index 00000000..315acd52 Binary files /dev/null and b/preview-demo/poses/nibu/glitch.png differ diff --git a/preview-demo/poses/nibu/happy.png b/preview-demo/poses/nibu/happy.png new file mode 100644 index 00000000..4f4ed096 Binary files /dev/null and b/preview-demo/poses/nibu/happy.png differ diff --git a/preview-demo/poses/nibu/neutral.png b/preview-demo/poses/nibu/neutral.png new file mode 100644 index 00000000..e3ada05d Binary files /dev/null and b/preview-demo/poses/nibu/neutral.png differ diff --git a/preview-demo/poses/nibu/pout.png b/preview-demo/poses/nibu/pout.png new file mode 100644 index 00000000..796449b4 Binary files /dev/null and b/preview-demo/poses/nibu/pout.png differ diff --git a/preview-demo/poses/nibu/resolute.png b/preview-demo/poses/nibu/resolute.png new file mode 100644 index 00000000..bab06adf Binary files /dev/null and b/preview-demo/poses/nibu/resolute.png differ diff --git a/preview-demo/poses/nibu/sheepish.png b/preview-demo/poses/nibu/sheepish.png new file mode 100644 index 00000000..f9ec933f Binary files /dev/null and b/preview-demo/poses/nibu/sheepish.png differ diff --git a/preview-demo/poses/nibu/surprised.png b/preview-demo/poses/nibu/surprised.png new file mode 100644 index 00000000..4fa62657 Binary files /dev/null and b/preview-demo/poses/nibu/surprised.png differ diff --git a/preview-demo/poses/nibu/thinking.png b/preview-demo/poses/nibu/thinking.png new file mode 100644 index 00000000..b02967ed Binary files /dev/null and b/preview-demo/poses/nibu/thinking.png differ diff --git a/preview-demo/poses/nibu/triumph.png b/preview-demo/poses/nibu/triumph.png new file mode 100644 index 00000000..31994236 Binary files /dev/null and b/preview-demo/poses/nibu/triumph.png differ diff --git a/preview-demo/poses/void/amused.png b/preview-demo/poses/void/amused.png new file mode 100644 index 00000000..a8332ed2 Binary files /dev/null and b/preview-demo/poses/void/amused.png differ diff --git a/preview-demo/poses/void/annoyed.png b/preview-demo/poses/void/annoyed.png new file mode 100644 index 00000000..4dc43e7a Binary files /dev/null and b/preview-demo/poses/void/annoyed.png differ diff --git a/preview-demo/poses/void/deadpan.png b/preview-demo/poses/void/deadpan.png new file mode 100644 index 00000000..d65dcb3e Binary files /dev/null and b/preview-demo/poses/void/deadpan.png differ diff --git a/preview-demo/poses/void/determined.png b/preview-demo/poses/void/determined.png new file mode 100644 index 00000000..f095d292 Binary files /dev/null and b/preview-demo/poses/void/determined.png differ diff --git a/preview-demo/poses/void/glare.png b/preview-demo/poses/void/glare.png new file mode 100644 index 00000000..44fcc81f Binary files /dev/null and b/preview-demo/poses/void/glare.png differ diff --git a/preview-demo/poses/void/happy.png b/preview-demo/poses/void/happy.png new file mode 100644 index 00000000..ca6027de Binary files /dev/null and b/preview-demo/poses/void/happy.png differ diff --git a/preview-demo/poses/void/neutral.png b/preview-demo/poses/void/neutral.png new file mode 100644 index 00000000..6e2a286d Binary files /dev/null and b/preview-demo/poses/void/neutral.png differ diff --git a/preview-demo/poses/void/point.png b/preview-demo/poses/void/point.png new file mode 100644 index 00000000..fae15ebf Binary files /dev/null and b/preview-demo/poses/void/point.png differ diff --git a/preview-demo/poses/void/reading.png b/preview-demo/poses/void/reading.png new file mode 100644 index 00000000..3fff2cd8 Binary files /dev/null and b/preview-demo/poses/void/reading.png differ diff --git a/preview-demo/poses/void/shrug.png b/preview-demo/poses/void/shrug.png new file mode 100644 index 00000000..aa4aee5d Binary files /dev/null and b/preview-demo/poses/void/shrug.png differ diff --git a/preview-demo/poses/void/surprised.png b/preview-demo/poses/void/surprised.png new file mode 100644 index 00000000..73816f29 Binary files /dev/null and b/preview-demo/poses/void/surprised.png differ diff --git a/preview-demo/poses/void/thinking.png b/preview-demo/poses/void/thinking.png new file mode 100644 index 00000000..ab93f2cc Binary files /dev/null and b/preview-demo/poses/void/thinking.png differ diff --git a/public/dings/README.md b/public/dings/README.md new file mode 100644 index 00000000..b36e1fb7 --- /dev/null +++ b/public/dings/README.md @@ -0,0 +1,17 @@ +# Dings + +Pre-rendered earcons, served at `public/dings/` and read at runtime by `src/app/features/dings`. +Aqua renders the clips; nothing here is synthesised in the browser. + + dings/manifest.json + dings/senders//.ogg + dings/neutral/.ogg + +`manifest.json` is `{ "neutral": { : "" }, "senders": { : { : "" } } }` +with motif one of `mention`, `dm`, `message`, `invite`; paths are relative to this folder, `.ogg` or `.mp3`. +A sender's own clip wins, else the neutral clip, else silence. Without a `manifest.json` the client keeps +its stock notification sounds; once one exists, a motif with no clip is silent. + +The set here is Aqua's v4 (AquaSynth merge 21e83ec, staged at minds.gamecult.org/aqua/dings-voices-dd9be66), +Nibu on proposal 3 (8 kHz 5-bit smoothed sine) for all four events. Nobody has heard it. The client plays none of it unless the +client config sets `"dings": { "enabled": true }` (default off, as in `config.json`). diff --git a/public/dings/manifest.json b/public/dings/manifest.json new file mode 100644 index 00000000..7096d195 --- /dev/null +++ b/public/dings/manifest.json @@ -0,0 +1,58 @@ +{ + "neutral": { + "mention": "neutral/mention.ogg", + "dm": "neutral/dm.ogg", + "message": "neutral/message.ogg", + "invite": "neutral/invite.ogg" + }, + "senders": { + "aqua": { + "mention": "senders/aqua/mention.ogg", + "dm": "senders/aqua/dm.ogg", + "message": "senders/aqua/message.ogg", + "invite": "senders/aqua/invite.ogg" + }, + "eureka": { + "mention": "senders/eureka/mention.ogg", + "dm": "senders/eureka/dm.ogg", + "message": "senders/eureka/message.ogg", + "invite": "senders/eureka/invite.ogg" + }, + "heimdall": { + "mention": "senders/heimdall/mention.ogg", + "dm": "senders/heimdall/dm.ogg", + "message": "senders/heimdall/message.ogg", + "invite": "senders/heimdall/invite.ogg" + }, + "kiko": { + "mention": "senders/kiko/mention.ogg", + "dm": "senders/kiko/dm.ogg", + "message": "senders/kiko/message.ogg", + "invite": "senders/kiko/invite.ogg" + }, + "libby": { + "mention": "senders/libby/mention.ogg", + "dm": "senders/libby/dm.ogg", + "message": "senders/libby/message.ogg", + "invite": "senders/libby/invite.ogg" + }, + "nibu": { + "mention": "senders/nibu/mention.ogg", + "dm": "senders/nibu/dm.ogg", + "message": "senders/nibu/message.ogg", + "invite": "senders/nibu/invite.ogg" + }, + "void": { + "mention": "senders/void/mention.ogg", + "dm": "senders/void/dm.ogg", + "message": "senders/void/message.ogg", + "invite": "senders/void/invite.ogg" + }, + "weksa": { + "mention": "senders/weksa/mention.ogg", + "dm": "senders/weksa/dm.ogg", + "message": "senders/weksa/message.ogg", + "invite": "senders/weksa/invite.ogg" + } + } +} \ No newline at end of file diff --git a/public/dings/neutral/dm.ogg b/public/dings/neutral/dm.ogg new file mode 100644 index 00000000..8f7a269e Binary files /dev/null and b/public/dings/neutral/dm.ogg differ diff --git a/public/dings/neutral/invite.ogg b/public/dings/neutral/invite.ogg new file mode 100644 index 00000000..8def2b77 Binary files /dev/null and b/public/dings/neutral/invite.ogg differ diff --git a/public/dings/neutral/mention.ogg b/public/dings/neutral/mention.ogg new file mode 100644 index 00000000..a02084bc Binary files /dev/null and b/public/dings/neutral/mention.ogg differ diff --git a/public/dings/neutral/message.ogg b/public/dings/neutral/message.ogg new file mode 100644 index 00000000..f7e17fd4 Binary files /dev/null and b/public/dings/neutral/message.ogg differ diff --git a/public/dings/senders/aqua/dm.ogg b/public/dings/senders/aqua/dm.ogg new file mode 100644 index 00000000..5a235546 Binary files /dev/null and b/public/dings/senders/aqua/dm.ogg differ diff --git a/public/dings/senders/aqua/invite.ogg b/public/dings/senders/aqua/invite.ogg new file mode 100644 index 00000000..f349e735 Binary files /dev/null and b/public/dings/senders/aqua/invite.ogg differ diff --git a/public/dings/senders/aqua/mention.ogg b/public/dings/senders/aqua/mention.ogg new file mode 100644 index 00000000..67d36911 Binary files /dev/null and b/public/dings/senders/aqua/mention.ogg differ diff --git a/public/dings/senders/aqua/message.ogg b/public/dings/senders/aqua/message.ogg new file mode 100644 index 00000000..3704f55a Binary files /dev/null and b/public/dings/senders/aqua/message.ogg differ diff --git a/public/dings/senders/eureka/dm.ogg b/public/dings/senders/eureka/dm.ogg new file mode 100644 index 00000000..57024fbe Binary files /dev/null and b/public/dings/senders/eureka/dm.ogg differ diff --git a/public/dings/senders/eureka/invite.ogg b/public/dings/senders/eureka/invite.ogg new file mode 100644 index 00000000..d6a6e328 Binary files /dev/null and b/public/dings/senders/eureka/invite.ogg differ diff --git a/public/dings/senders/eureka/mention.ogg b/public/dings/senders/eureka/mention.ogg new file mode 100644 index 00000000..c4085fae Binary files /dev/null and b/public/dings/senders/eureka/mention.ogg differ diff --git a/public/dings/senders/eureka/message.ogg b/public/dings/senders/eureka/message.ogg new file mode 100644 index 00000000..6cd340ce Binary files /dev/null and b/public/dings/senders/eureka/message.ogg differ diff --git a/public/dings/senders/heimdall/dm.ogg b/public/dings/senders/heimdall/dm.ogg new file mode 100644 index 00000000..145e8853 Binary files /dev/null and b/public/dings/senders/heimdall/dm.ogg differ diff --git a/public/dings/senders/heimdall/invite.ogg b/public/dings/senders/heimdall/invite.ogg new file mode 100644 index 00000000..98403c21 Binary files /dev/null and b/public/dings/senders/heimdall/invite.ogg differ diff --git a/public/dings/senders/heimdall/mention.ogg b/public/dings/senders/heimdall/mention.ogg new file mode 100644 index 00000000..5a39a98c Binary files /dev/null and b/public/dings/senders/heimdall/mention.ogg differ diff --git a/public/dings/senders/heimdall/message.ogg b/public/dings/senders/heimdall/message.ogg new file mode 100644 index 00000000..068e7198 Binary files /dev/null and b/public/dings/senders/heimdall/message.ogg differ diff --git a/public/dings/senders/kiko/dm.ogg b/public/dings/senders/kiko/dm.ogg new file mode 100644 index 00000000..7c35b072 Binary files /dev/null and b/public/dings/senders/kiko/dm.ogg differ diff --git a/public/dings/senders/kiko/invite.ogg b/public/dings/senders/kiko/invite.ogg new file mode 100644 index 00000000..f8eff1a1 Binary files /dev/null and b/public/dings/senders/kiko/invite.ogg differ diff --git a/public/dings/senders/kiko/mention.ogg b/public/dings/senders/kiko/mention.ogg new file mode 100644 index 00000000..12cccf29 Binary files /dev/null and b/public/dings/senders/kiko/mention.ogg differ diff --git a/public/dings/senders/kiko/message.ogg b/public/dings/senders/kiko/message.ogg new file mode 100644 index 00000000..e0f6e885 Binary files /dev/null and b/public/dings/senders/kiko/message.ogg differ diff --git a/public/dings/senders/libby/dm.ogg b/public/dings/senders/libby/dm.ogg new file mode 100644 index 00000000..1316c196 Binary files /dev/null and b/public/dings/senders/libby/dm.ogg differ diff --git a/public/dings/senders/libby/invite.ogg b/public/dings/senders/libby/invite.ogg new file mode 100644 index 00000000..4b92a0b7 Binary files /dev/null and b/public/dings/senders/libby/invite.ogg differ diff --git a/public/dings/senders/libby/mention.ogg b/public/dings/senders/libby/mention.ogg new file mode 100644 index 00000000..f335c2c9 Binary files /dev/null and b/public/dings/senders/libby/mention.ogg differ diff --git a/public/dings/senders/libby/message.ogg b/public/dings/senders/libby/message.ogg new file mode 100644 index 00000000..432f8623 Binary files /dev/null and b/public/dings/senders/libby/message.ogg differ diff --git a/public/dings/senders/nibu/dm.ogg b/public/dings/senders/nibu/dm.ogg new file mode 100644 index 00000000..2965cf53 Binary files /dev/null and b/public/dings/senders/nibu/dm.ogg differ diff --git a/public/dings/senders/nibu/invite.ogg b/public/dings/senders/nibu/invite.ogg new file mode 100644 index 00000000..57e54c47 Binary files /dev/null and b/public/dings/senders/nibu/invite.ogg differ diff --git a/public/dings/senders/nibu/mention.ogg b/public/dings/senders/nibu/mention.ogg new file mode 100644 index 00000000..5f6d2dfc Binary files /dev/null and b/public/dings/senders/nibu/mention.ogg differ diff --git a/public/dings/senders/nibu/message.ogg b/public/dings/senders/nibu/message.ogg new file mode 100644 index 00000000..a9294354 Binary files /dev/null and b/public/dings/senders/nibu/message.ogg differ diff --git a/public/dings/senders/void/dm.ogg b/public/dings/senders/void/dm.ogg new file mode 100644 index 00000000..d09a7a94 Binary files /dev/null and b/public/dings/senders/void/dm.ogg differ diff --git a/public/dings/senders/void/invite.ogg b/public/dings/senders/void/invite.ogg new file mode 100644 index 00000000..ed46cbb9 Binary files /dev/null and b/public/dings/senders/void/invite.ogg differ diff --git a/public/dings/senders/void/mention.ogg b/public/dings/senders/void/mention.ogg new file mode 100644 index 00000000..b6295e00 Binary files /dev/null and b/public/dings/senders/void/mention.ogg differ diff --git a/public/dings/senders/void/message.ogg b/public/dings/senders/void/message.ogg new file mode 100644 index 00000000..da1f0a16 Binary files /dev/null and b/public/dings/senders/void/message.ogg differ diff --git a/public/dings/senders/weksa/dm.ogg b/public/dings/senders/weksa/dm.ogg new file mode 100644 index 00000000..37b5f990 Binary files /dev/null and b/public/dings/senders/weksa/dm.ogg differ diff --git a/public/dings/senders/weksa/invite.ogg b/public/dings/senders/weksa/invite.ogg new file mode 100644 index 00000000..1c401418 Binary files /dev/null and b/public/dings/senders/weksa/invite.ogg differ diff --git a/public/dings/senders/weksa/mention.ogg b/public/dings/senders/weksa/mention.ogg new file mode 100644 index 00000000..98fa0f75 Binary files /dev/null and b/public/dings/senders/weksa/mention.ogg differ diff --git a/public/dings/senders/weksa/message.ogg b/public/dings/senders/weksa/message.ogg new file mode 100644 index 00000000..f552996b Binary files /dev/null and b/public/dings/senders/weksa/message.ogg differ diff --git a/public/favicon.ico b/public/favicon.ico index 91671306..bda9855c 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/public/manifest.json b/public/manifest.json index acdcd004..c28e7b41 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -1,14 +1,15 @@ { - "name": "Cinny", - "short_name": "Cinny", - "description": "Yet another matrix client", + "name": "Ratatoskr", + "short_name": "Ratatoskr", + "id": "/", + "description": "GameCult's Matrix client", "dir": "auto", "lang": "en-US", "display": "standalone", - "orientation": "portrait", "start_url": "./", - "background_color": "#fff", - "theme_color": "#fff", + "scope": "./", + "background_color": "#07111a", + "theme_color": "#07111a", "icons": [ { "src": "./public/android/android-chrome-36x36.png", @@ -54,6 +55,12 @@ "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" } ] } diff --git a/public/poses/.gitkeep b/public/poses/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/public/res/android/android-chrome-144x144.png b/public/res/android/android-chrome-144x144.png index 8c8c2668..2dfde532 100644 Binary files a/public/res/android/android-chrome-144x144.png and b/public/res/android/android-chrome-144x144.png differ diff --git a/public/res/android/android-chrome-192x192.png b/public/res/android/android-chrome-192x192.png index 6d53edb5..d696f20f 100644 Binary files a/public/res/android/android-chrome-192x192.png and b/public/res/android/android-chrome-192x192.png differ diff --git a/public/res/android/android-chrome-256x256.png b/public/res/android/android-chrome-256x256.png index 57378b41..07e5cbde 100644 Binary files a/public/res/android/android-chrome-256x256.png and b/public/res/android/android-chrome-256x256.png differ diff --git a/public/res/android/android-chrome-36x36.png b/public/res/android/android-chrome-36x36.png index 507c4439..3e1babf7 100644 Binary files a/public/res/android/android-chrome-36x36.png and b/public/res/android/android-chrome-36x36.png differ diff --git a/public/res/android/android-chrome-384x384.png b/public/res/android/android-chrome-384x384.png index 92fd32c7..61e2afe4 100644 Binary files a/public/res/android/android-chrome-384x384.png and b/public/res/android/android-chrome-384x384.png differ diff --git a/public/res/android/android-chrome-48x48.png b/public/res/android/android-chrome-48x48.png index 489cfd32..a5ba15e6 100644 Binary files a/public/res/android/android-chrome-48x48.png and b/public/res/android/android-chrome-48x48.png differ diff --git a/public/res/android/android-chrome-512x512.png b/public/res/android/android-chrome-512x512.png index d312b532..e979125e 100644 Binary files a/public/res/android/android-chrome-512x512.png and b/public/res/android/android-chrome-512x512.png differ diff --git a/public/res/android/android-chrome-72x72.png b/public/res/android/android-chrome-72x72.png index 8e04930c..7aa6caab 100644 Binary files a/public/res/android/android-chrome-72x72.png and b/public/res/android/android-chrome-72x72.png differ diff --git a/public/res/android/android-chrome-96x96.png b/public/res/android/android-chrome-96x96.png index 44981d96..5ab44fe9 100644 Binary files a/public/res/android/android-chrome-96x96.png and b/public/res/android/android-chrome-96x96.png differ diff --git a/public/res/android/android-chrome-maskable-512x512.png b/public/res/android/android-chrome-maskable-512x512.png new file mode 100644 index 00000000..211a0650 Binary files /dev/null and b/public/res/android/android-chrome-maskable-512x512.png differ diff --git a/public/res/apple/apple-touch-icon-114x114.png b/public/res/apple/apple-touch-icon-114x114.png index 418f8833..3a08841c 100644 Binary files a/public/res/apple/apple-touch-icon-114x114.png and b/public/res/apple/apple-touch-icon-114x114.png differ diff --git a/public/res/apple/apple-touch-icon-120x120.png b/public/res/apple/apple-touch-icon-120x120.png index 1d570eba..8443c98f 100644 Binary files a/public/res/apple/apple-touch-icon-120x120.png and b/public/res/apple/apple-touch-icon-120x120.png differ diff --git a/public/res/apple/apple-touch-icon-144x144.png b/public/res/apple/apple-touch-icon-144x144.png index b6a731eb..2dfde532 100644 Binary files a/public/res/apple/apple-touch-icon-144x144.png and b/public/res/apple/apple-touch-icon-144x144.png differ diff --git a/public/res/apple/apple-touch-icon-152x152.png b/public/res/apple/apple-touch-icon-152x152.png index 968ae2f0..8ab7ad50 100644 Binary files a/public/res/apple/apple-touch-icon-152x152.png and b/public/res/apple/apple-touch-icon-152x152.png differ diff --git a/public/res/apple/apple-touch-icon-167x167.png b/public/res/apple/apple-touch-icon-167x167.png index a41d0d10..963c024d 100644 Binary files a/public/res/apple/apple-touch-icon-167x167.png and b/public/res/apple/apple-touch-icon-167x167.png differ diff --git a/public/res/apple/apple-touch-icon-180x180.png b/public/res/apple/apple-touch-icon-180x180.png index a651cd1c..631741a0 100644 Binary files a/public/res/apple/apple-touch-icon-180x180.png and b/public/res/apple/apple-touch-icon-180x180.png differ diff --git a/public/res/apple/apple-touch-icon-57x57.png b/public/res/apple/apple-touch-icon-57x57.png index f5ff2ea5..5cad75b2 100644 Binary files a/public/res/apple/apple-touch-icon-57x57.png and b/public/res/apple/apple-touch-icon-57x57.png differ diff --git a/public/res/apple/apple-touch-icon-60x60.png b/public/res/apple/apple-touch-icon-60x60.png index 47fafb07..6303a2eb 100644 Binary files a/public/res/apple/apple-touch-icon-60x60.png and b/public/res/apple/apple-touch-icon-60x60.png differ diff --git a/public/res/apple/apple-touch-icon-72x72.png b/public/res/apple/apple-touch-icon-72x72.png index 6e5b13c4..7aa6caab 100644 Binary files a/public/res/apple/apple-touch-icon-72x72.png and b/public/res/apple/apple-touch-icon-72x72.png differ diff --git a/public/res/apple/apple-touch-icon-76x76.png b/public/res/apple/apple-touch-icon-76x76.png index 7799a6fd..1448098d 100644 Binary files a/public/res/apple/apple-touch-icon-76x76.png and b/public/res/apple/apple-touch-icon-76x76.png differ diff --git a/public/res/svg/cinny-highlight.svg b/public/res/svg/cinny-highlight.svg deleted file mode 100644 index d59b42a1..00000000 --- a/public/res/svg/cinny-highlight.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/public/res/svg/cinny-unread.svg b/public/res/svg/cinny-unread.svg deleted file mode 100644 index be301375..00000000 --- a/public/res/svg/cinny-unread.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/public/res/svg/cinny.svg b/public/res/svg/cinny.svg deleted file mode 100644 index 8701d67b..00000000 --- a/public/res/svg/cinny.svg +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - \ No newline at end of file diff --git a/public/res/svg/ratatoskr-favicon-highlight.svg b/public/res/svg/ratatoskr-favicon-highlight.svg new file mode 100644 index 00000000..f9a95e82 --- /dev/null +++ b/public/res/svg/ratatoskr-favicon-highlight.svg @@ -0,0 +1 @@ + diff --git a/public/res/svg/ratatoskr-favicon-unread.svg b/public/res/svg/ratatoskr-favicon-unread.svg new file mode 100644 index 00000000..f5bcb7d8 --- /dev/null +++ b/public/res/svg/ratatoskr-favicon-unread.svg @@ -0,0 +1 @@ + diff --git a/public/res/svg/ratatoskr-favicon.svg b/public/res/svg/ratatoskr-favicon.svg new file mode 100644 index 00000000..0c7cc393 --- /dev/null +++ b/public/res/svg/ratatoskr-favicon.svg @@ -0,0 +1 @@ + diff --git a/public/res/svg/ratatoskr.svg b/public/res/svg/ratatoskr.svg new file mode 100644 index 00000000..1092ffc4 --- /dev/null +++ b/public/res/svg/ratatoskr.svg @@ -0,0 +1 @@ + diff --git a/scripts/bake-horizon.py b/scripts/bake-horizon.py new file mode 100644 index 00000000..372e70e8 --- /dev/null +++ b/scripts/bake-horizon.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +"""Bake the 360 degree horizon seen from the ridge viewpoint (src/app/features/scene/ridge/vienna.ts: VIENNA) into +src/app/features/scene/ridge/viennaHorizon.json: per azimuth, the highest elevation angle the terrain subtends, the distance and +height of the point that sets it, and the surface slope there (the normal). The source is Copernicus DEM GLO-30, a DSM (it +includes buildings and trees, which is what a horizon is made of). Source, licence and attribution go beside the bake, in its +`source` block, so the file cannot travel without them. + + python3 scripts/bake-horizon.py [out.json] + +Needs numpy, tifffile, imagecodecs (pip, in a venv: this is a build-time tool, the bake is committed). +""" +import json +import math +import sys +from pathlib import Path + +import numpy as np +import tifffile + +LAT, LON = 48.1929826, 16.3534237 # the server location Meta gave, 2026-11-11; keep equal to VIENNA in vienna.ts +STEP_DEG = 0.5 +RADIUS_KM = 60.0 +EARTH_R = 6_371_000.0 +REFRACTION_K = 0.13 # standard terrestrial refraction: effective radius R / (1 - k) +EYE_ABOVE_GROUND = 0.0 # the viewpoint is the DSM surface at the point (a roof, in a city); the page lifts it with VIENNA.altitudeKm +MIN_KM = 1.0 # the DSM at 30 m cannot resolve neighbouring buildings as a horizon; nearer than this is not ridge + + +class Mosaic: + def __init__(self, folder: Path): + self.tiles = {} + for p in sorted(folder.glob("Copernicus_DSM_COG_10_N*_E*_DEM.tif")): + lat0 = int(p.name.split("_N")[1][:2]) + lon0 = int(p.name.split("_E")[1][:3]) + with tifffile.TiffFile(p) as t: + self.tiles[(lat0, lon0)] = t.pages[0].asarray() + + def height(self, lat: float, lon: float) -> float: + key = (math.floor(lat), math.floor(lon)) + a = self.tiles.get(key) + if a is None: + return float("nan") + n = a.shape[0] + # Tile origin is the north-west corner; pixels are 1/n degree, pixel-is-area (centre at +0.5). + y = (key[0] + 1 - lat) * n - 0.5 + x = (lon - key[1]) * n - 0.5 + y0, x0 = int(math.floor(y)), int(math.floor(x)) + y0 = min(max(y0, 0), n - 2) + x0 = min(max(x0, 0), n - 2) + fy, fx = min(max(y - y0, 0), 1), min(max(x - x0, 0), 1) + top = a[y0, x0] * (1 - fx) + a[y0, x0 + 1] * fx + bot = a[y0 + 1, x0] * (1 - fx) + a[y0 + 1, x0 + 1] * fx + return float(top * (1 - fy) + bot * fy) + + +def offset(lat: float, lon: float, az_deg: float, d_m: float): + az = math.radians(az_deg) + dlat = d_m * math.cos(az) / 111_320.0 + dlon = d_m * math.sin(az) / (111_320.0 * math.cos(math.radians(lat))) + return lat + dlat, lon + dlon + + +def main() -> None: + folder = Path(sys.argv[1]) + out = Path(sys.argv[2]) if len(sys.argv) > 2 else Path("src/app/features/scene/ridge/viennaHorizon.json") + m = Mosaic(folder) + h0 = m.height(LAT, LON) + EYE_ABOVE_GROUND + az_count = int(360 / STEP_DEG) + elev, dist, peak, east, north = [], [], [], [], [] + for i in range(az_count): + az = i * STEP_DEG + best, best_d, best_h, best_pt = -90.0, 0.0, h0, (LAT, LON) + d = MIN_KM * 1000 + while d <= RADIUS_KM * 1000: + lat, lon = offset(LAT, LON, az, d) + h = m.height(lat, lon) + if not math.isnan(h): + drop = d * d / (2 * EARTH_R) * (1 - REFRACTION_K) + ang = math.degrees(math.atan2(h - h0 - drop, d)) + if ang > best: + best, best_d, best_h, best_pt = ang, d, h, (lat, lon) + d += 30.0 if d < 5000 else 60.0 if d < 20000 else 120.0 + # Surface slope at the peak (rise per metre, east and north), central difference over 60 m. + la, lo = best_pt + dl, dn = 60.0 / 111_320.0, 60.0 / (111_320.0 * math.cos(math.radians(la))) + ge = (m.height(la, lo + dn) - m.height(la, lo - dn)) / 120.0 + gn = (m.height(la + dl, lo) - m.height(la - dl, lo)) / 120.0 + elev.append(round(best, 3)) + dist.append(round(best_d / 1000, 2)) + peak.append(round(best_h, 1)) + east.append(round(ge, 3)) + north.append(round(gn, 3)) + doc = { + "viewpoint": {"lat": LAT, "lon": LON, "groundM": round(h0, 1)}, + "stepDeg": STEP_DEG, + "radiusKm": RADIUS_KM, + "minKm": MIN_KM, + "curvature": f"drop d^2/2R*(1-{REFRACTION_K}), R={int(EARTH_R)} m", + "source": { + "dataset": "Copernicus DEM GLO-30 (COP-DEM_GLO-30-DGED), a DSM, EGM2008 heights, 1 arc-second grid", + "tiles": sorted(f"N{a:02d}_00_E{b:03d}_00" for a, b in m.tiles), + "tileBytesEach": "about 42-44 MB (Cloud Optimized GeoTIFF); nine tiles, about 380 MB, read once, not committed", + "url": "https://copernicus-dem-30m.s3.amazonaws.com/ (AWS Open Data, eu-central-1, no account)", + "licence": "Copernicus DEM licence: free of charge for any use incl. commercial, attribution required, no warranty " + "(https://spacedata.copernicus.eu/collections/copernicus-digital-elevation-model, Licencing)", + "attribution": "Contains modified Copernicus data: DEM © DLR e.V. 2010-2014 and © Airbus Defence and Space GmbH 2014-2018 " + "provided under COPERNICUS by the European Union and ESA; all rights reserved.", + "bakedBy": "scripts/bake-horizon.py", + }, + "elevationDeg": elev, + "distanceKm": dist, + "peakHeightM": peak, + "slopeEast": east, + "slopeNorth": north, + } + out.write_text(json.dumps(doc, separators=(",", ":")) + "\n") + top = int(np.argmax(elev)) + print(f"ground {h0:.1f} m; {az_count} azimuths; highest {elev[top]:.2f} deg at az {top * STEP_DEG}, {dist[top]} km, {peak[top]} m") + print(f"wrote {out} ({out.stat().st_size} bytes)") + + +if __name__ == "__main__": + main() diff --git a/scripts/bake-sky.mjs b/scripts/bake-sky.mjs new file mode 100644 index 00000000..827110c2 --- /dev/null +++ b/scripts/bake-sky.mjs @@ -0,0 +1,19 @@ +// Bakes Midnight Sky and Noonday Sky from src/sky/sun.js into src/sky/baked.ts, with the +// atmospheric-scattering tables (src/sky/scatter.js) as `scatter`. +// Run `npm run bake:sky` after changing sun.js, skyTokens.js, scatter.js or a moment. +import { writeFileSync } from 'node:fs'; +import { bakeSky } from '../src/sky/skyTokens.js'; +import { bakeScatter } from '../src/sky/scatter.js'; + +const out = new URL('../src/sky/baked.ts', import.meta.url); +const baked = { + midnight: bakeSky('midnight'), + noonday: bakeSky('noonday'), + scatter: bakeScatter(), +}; +writeFileSync( + out, + `// Generated by scripts/bake-sky.mjs from src/sky/sun.js. Do not edit.\n` + + `/* eslint-disable */\n` + + `export const baked = ${JSON.stringify(baked, null, 2)} as const;\n` +); diff --git a/scripts/build-dev-preview.sh b/scripts/build-dev-preview.sh new file mode 100755 index 00000000..8f89dce8 --- /dev/null +++ b/scripts/build-dev-preview.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Build this checkout as the offline demo for the standing dev preview: base /void/rat-dev/, the pretend +# homeserver, scene tier $TIER (default full). Output: ./dist, ready for the shared publisher +# (gamecult-ops scripts/publish-mind-preview.sh dist rat-dev), which owns publishing. Publishes nothing. +set -euo pipefail +cd "$(dirname "$0")/.." +node scripts/sync-poses.mjs >/dev/null +VITE_PREVIEW_DEMO=1 CINNY_BASE=/void/rat-dev/ NODE_OPTIONS=--max_old_space_size=4096 nice -n 10 npx vite build +node scripts/preview-config.js "${TIER:-full}" --demo diff --git a/scripts/buildSha.js b/scripts/buildSha.js new file mode 100644 index 00000000..61a89445 --- /dev/null +++ b/scripts/buildSha.js @@ -0,0 +1,56 @@ +// The commit a bundle was built from, stamped into its index.html as , 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 `-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' }, + ], +}); diff --git a/scripts/buildSha.test.js b/scripts/buildSha.test.js new file mode 100644 index 00000000..4a270867 --- /dev/null +++ b/scripts/buildSha.test.js @@ -0,0 +1,170 @@ +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..timestamp--.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' }, + ]); + }); +}); diff --git a/scripts/gen-brand.mjs b/scripts/gen-brand.mjs new file mode 100644 index 00000000..fa44bfde --- /dev/null +++ b/scripts/gen-brand.mjs @@ -0,0 +1,202 @@ +// Draws the Ratatoskr squirrel once, as shapes, and writes the SVG logos plus the +// favicon and app-icon PNGs from the same shapes (no image tools needed). +// Run: node scripts/gen-brand.mjs +import { deflateSync } from 'node:zlib'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const BG = '#1c1b22'; + +// Squirrel facing right on a 64x64 grid. Later shapes draw over earlier ones. +const squirrel = [ + { t: 'ellipse', cx: 19, cy: 33, rx: 13, ry: 22, rot: -15, fill: '#d9742b' }, + { t: 'ellipse', cx: 21, cy: 33, rx: 7, ry: 15, rot: -15, fill: '#f0a45d' }, + { t: 'ellipse', cx: 36, cy: 43, rx: 11, ry: 14, rot: 0, fill: '#e0833a' }, + { t: 'ellipse', cx: 40, cy: 46, rx: 6, ry: 9, rot: 0, fill: '#f6dcb8' }, + { + t: 'poly', + pts: [ + [35, 19], + [36, 7], + [43, 15], + ], + fill: '#c9672a', + }, + { + t: 'poly', + pts: [ + [44, 15], + [50, 7], + [52, 19], + ], + fill: '#c9672a', + }, + { t: 'circle', cx: 43, cy: 25, r: 10, fill: '#e0833a' }, + { t: 'ellipse', cx: 47, cy: 29, rx: 5, ry: 3.5, rot: 0, fill: '#f6dcb8' }, + { t: 'circle', cx: 46.5, cy: 23.5, r: 1.8, fill: '#1a1a1a' }, + { t: 'circle', cx: 47, cy: 23, r: 0.6, fill: '#ffffff' }, + { t: 'circle', cx: 52, cy: 27.5, r: 1.5, fill: '#1a1a1a' }, + { t: 'ellipse', cx: 33, cy: 56, rx: 5, ry: 2.5, rot: 0, fill: '#c9672a' }, + { t: 'ellipse', cx: 44, cy: 56, rx: 4.5, ry: 2.5, rot: 0, fill: '#c9672a' }, +]; +const badge = (fill) => [ + { t: 'circle', cx: 52, cy: 52, r: 11, fill: '#ffffff' }, + { t: 'circle', cx: 52, cy: 52, r: 8, fill }, +]; +const background = [{ t: 'rect', x: 0, y: 0, w: 64, h: 64, fill: BG }]; + +const f = (n) => +n.toFixed(2); +const svgOf = (shapes) => + shapes + .map((s) => { + if (s.t === 'circle') + return ``; + if (s.t === 'rect') return ``; + if (s.t === 'poly') + return ``; + return ``; + }) + .join('\n'); +const writeSvg = (name, shapes) => + writeFileSync( + join(root, 'public/res/svg', name), + `\n${svgOf( + shapes + )}\n\n` + ); + +const hex = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16)); +const inside = (s, x, y) => { + if (s.t === 'rect') return x >= s.x && x < s.x + s.w && y >= s.y && y < s.y + s.h; + if (s.t === 'circle') return (x - s.cx) ** 2 + (y - s.cy) ** 2 <= s.r ** 2; + if (s.t === 'ellipse') { + const a = (-s.rot * Math.PI) / 180; + const dx = x - s.cx; + const dy = y - s.cy; + const u = dx * Math.cos(a) - dy * Math.sin(a); + const v = dx * Math.sin(a) + dy * Math.cos(a); + return (u / s.rx) ** 2 + (v / s.ry) ** 2 <= 1; + } + let c = false; + for (let i = 0, j = s.pts.length - 1; i < s.pts.length; j = i++) { + const [xi, yi] = s.pts[i]; + const [xj, yj] = s.pts[j]; + if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) c = !c; + } + return c; +}; + +const crcTable = Array.from({ length: 256 }, (_, n) => { + let c = n; + for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + return c >>> 0; +}); +const crc = (b) => { + let c = 0xffffffff; + for (const x of b) c = crcTable[(c ^ x) & 255] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +}; +const chunk = (type, data) => { + const out = Buffer.alloc(12 + data.length); + out.writeUInt32BE(data.length, 0); + out.write(type, 4, 'ascii'); + data.copy(out, 8); + out.writeUInt32BE(crc(out.subarray(4, 8 + data.length)), 8 + data.length); + return out; +}; + +// Rasterise with 4x4 supersampling; `scale` shrinks the drawing about the centre. +const png = (shapes, size, scale = 1) => { + const rgba = Buffer.alloc(size * size * 4); + const SS = 4; + const colours = shapes.map((s) => hex(s.fill)); + for (let py = 0; py < size; py += 1) { + for (let px = 0; px < size; px += 1) { + let r = 0; + let g = 0; + let b = 0; + let a = 0; + for (let sy = 0; sy < SS; sy += 1) { + for (let sx = 0; sx < SS; sx += 1) { + const gx = ((px + (sx + 0.5) / SS) / size) * 64; + const gy = ((py + (sy + 0.5) / SS) / size) * 64; + // the background rect covers the whole canvas and ignores `scale` + let hit = -1; + shapes.forEach((s, i) => { + const x = s.t === 'rect' ? gx : (gx - 32) / scale + 32; + const y = s.t === 'rect' ? gy : (gy - 32) / scale + 32; + if (inside(s, x, y)) hit = i; + }); + if (hit >= 0) { + [r, g, b] = [r + colours[hit][0], g + colours[hit][1], b + colours[hit][2]]; + a += 1; + } + } + } + const o = (py * size + px) * 4; + if (a > 0) { + rgba[o] = Math.round(r / a); + rgba[o + 1] = Math.round(g / a); + rgba[o + 2] = Math.round(b / a); + } + rgba[o + 3] = Math.round((a / (SS * SS)) * 255); + } + } + const raw = Buffer.alloc(size * (size * 4 + 1)); + for (let y = 0; y < size; y += 1) { + raw[y * (size * 4 + 1)] = 0; + rgba.copy(raw, y * (size * 4 + 1) + 1, y * size * 4, (y + 1) * size * 4); + } + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(size, 0); + ihdr.writeUInt32BE(size, 4); + ihdr.set([8, 6, 0, 0, 0], 8); + return Buffer.concat([ + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), + chunk('IHDR', ihdr), + chunk('IDAT', deflateSync(raw)), + chunk('IEND', Buffer.alloc(0)), + ]); +}; + +// ICO container holding PNG images. +const ico = (images) => { + const head = Buffer.alloc(6 + images.length * 16); + head.writeUInt16LE(1, 2); + head.writeUInt16LE(images.length, 4); + let offset = head.length; + images.forEach(({ size, data }, i) => { + const e = 6 + i * 16; + head[e] = size; + head[e + 1] = size; + head.writeUInt16LE(1, e + 4); + head.writeUInt16LE(32, e + 6); + head.writeUInt32LE(data.length, e + 8); + head.writeUInt32LE(offset, e + 12); + offset += data.length; + }); + return Buffer.concat([head, ...images.map((i) => i.data)]); +}; + +writeSvg('ratatoskr.svg', squirrel); +writeSvg('ratatoskr-unread.svg', [...squirrel, ...badge('#989898')]); +writeSvg('ratatoskr-highlight.svg', [...squirrel, ...badge('#45B83B')]); + +const icon = [...background, ...squirrel]; +const put = (dir, name, size) => { + mkdirSync(join(root, 'public/res', dir), { recursive: true }); + writeFileSync(join(root, 'public/res', dir, name), png(icon, size, 0.82)); +}; +[36, 48, 72, 96, 144, 192, 256, 384, 512].forEach((s) => + put('android', `android-chrome-${s}x${s}.png`, s) +); +[57, 60, 72, 76, 114, 120, 144, 152, 167, 180].forEach((s) => + put('apple', `apple-touch-icon-${s}x${s}.png`, s) +); +writeFileSync( + join(root, 'public/favicon.ico'), + ico([16, 32, 48].map((size) => ({ size, data: png(icon, size, 0.9) }))) +); +console.log('wrote svg logos, favicon.ico, android and apple icons'); diff --git a/scripts/make-pose-thumbs.mjs b/scripts/make-pose-thumbs.mjs new file mode 100644 index 00000000..b7acc050 --- /dev/null +++ b/scripts/make-pose-thumbs.mjs @@ -0,0 +1,204 @@ +// Regenerates preview-demo/poses from the cast-art poses: PNG thumbnails sized for the +// portrait's displayed width x device pixel ratio: the bubble portrait is 190 CSS px on desktop and +// min(38vw, 160px) on a phone (layout.css.ts), the desk booth 168 / 88 px (DeskView.css.ts). +// Dependency-free: decodes 8-bit RGBA PNGs, area-averages in premultiplied alpha, writes a +// 256-colour palette PNG with a tRNS alpha table. Usage: node scripts/make-pose-thumbs.mjs [src] +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { inflateSync, deflateSync, crc32 } from 'node:zlib'; + +const WIDTH = 480; // about the 190px desktop portrait x DPR 2.5; art is never upscaled +const src = process.argv[2] ?? '/opt/commons/cast-art/poses'; +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const outDir = join(root, 'preview-demo/poses'); +const demoMinds = ['eureka', 'void', 'nibu']; + +function decode(buf) { + let off = 8; + let w, h; + const idat = []; + while (off < buf.length) { + const len = buf.readUInt32BE(off); + const type = buf.toString('latin1', off + 4, off + 8); + const data = buf.subarray(off + 8, off + 8 + len); + if (type === 'IHDR') { + w = data.readUInt32BE(0); + h = data.readUInt32BE(4); + if (data[8] !== 8 || data[9] !== 6 || data[12] !== 0) + throw new Error('need 8-bit RGBA, non-interlaced'); + } else if (type === 'IDAT') idat.push(data); + off += 12 + len; + } + const raw = inflateSync(Buffer.concat(idat)); + const stride = w * 4; + const px = Buffer.alloc(h * stride); + for (let y = 0; y < h; y++) { + const f = raw[y * (stride + 1)]; + const line = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1)); + for (let x = 0; x < stride; x++) { + const a = x >= 4 ? px[y * stride + x - 4] : 0; + const b = y > 0 ? px[(y - 1) * stride + x] : 0; + const c = x >= 4 && y > 0 ? px[(y - 1) * stride + x - 4] : 0; + let p = 0; + if (f === 1) p = a; + else if (f === 2) p = b; + else if (f === 3) p = (a + b) >> 1; + else if (f === 4) { + const pa = Math.abs(b - c), + pb = Math.abs(a - c), + pc = Math.abs(a + b - 2 * c); + p = pa <= pb && pa <= pc ? a : pb <= pc ? b : c; + } + px[y * stride + x] = (line[x] + p) & 255; + } + } + return { w, h, px }; +} + +// Separable area average over premultiplied RGBA floats. +function resize({ w, h, px }, nw, nh) { + const pre = new Float32Array(w * h * 4); + for (let i = 0; i < w * h; i++) { + const a = px[i * 4 + 3] / 255; + for (let c = 0; c < 3; c++) pre[i * 4 + c] = px[i * 4 + c] * a; + pre[i * 4 + 3] = px[i * 4 + 3]; + } + const pass = (data, sw, sh, dw, dh, horiz) => { + const out = new Float32Array((horiz ? dw * sh : sw * dh) * 4); + const n = horiz ? dw : dh; + const sn = horiz ? sw : sh; + const scale = sn / n; + for (let o = 0; o < n; o++) { + const s0 = o * scale, + s1 = (o + 1) * scale; + for (let i = Math.floor(s0); i < Math.min(sn, Math.ceil(s1)); i++) { + const wt = Math.min(i + 1, s1) - Math.max(i, s0); + const lines = horiz ? sh : sw; + for (let l = 0; l < lines; l++) { + const si = (horiz ? l * sw + i : i * sw + l) * 4; + const di = (horiz ? l * dw + o : o * dw + l) * 4; + for (let c = 0; c < 4; c++) out[di + c] += (data[si + c] * wt) / scale; + } + } + } + return out; + }; + const mid = pass(pre, w, h, nw, h, true); + const fin = pass(mid, nw, h, nw, nh, false); + const out = Buffer.alloc(nw * nh * 4); + for (let i = 0; i < nw * nh; i++) { + const a = fin[i * 4 + 3]; + out[i * 4 + 3] = Math.round(a); + for (let c = 0; c < 3; c++) + out[i * 4 + c] = a > 0 ? Math.min(255, Math.round((fin[i * 4 + c] * 255) / a)) : 0; + } + return out; +} + +// Median cut to 256 colours over (r,g,b,a); alpha is a quantised channel too. +function quantise(px, n) { + const key = (i) => [px[i * 4], px[i * 4 + 1], px[i * 4 + 2], px[i * 4 + 3]]; + const hist = new Map(); + for (let i = 0; i < n; i++) { + const q = key(i).map((v) => v & 0xf8); + const k = (q[0] << 24) | (q[1] << 16) | (q[2] << 8) | q[3]; + const e = hist.get(k) ?? { c: q, n: 0 }; + e.n++; + hist.set(k, e); + } + let boxes = [[...hist.values()]]; + while (boxes.length < 256) { + let bi = -1, + best = 0, + ch = 0; + boxes.forEach((b, i) => { + if (b.length < 2) return; + for (let c = 0; c < 4; c++) { + const vs = b.map((e) => e.c[c]); + const r = (Math.max(...vs) - Math.min(...vs)) * Math.sqrt(b.reduce((s, e) => s + e.n, 0)); + if (r > best) { + best = r; + bi = i; + ch = c; + } + } + }); + if (bi < 0) break; + const b = boxes[bi].sort((x, y) => x.c[ch] - y.c[ch]); + const half = b.reduce((s, e) => s + e.n, 0) / 2; + let acc = 0, + cut = 1; + for (; cut < b.length - 1 && acc + b[cut - 1].n < half; cut++) acc += b[cut - 1].n; + boxes.splice(bi, 1, b.slice(0, cut), b.slice(cut)); + } + const pal = boxes.map((b) => { + const t = b.reduce((s, e) => s + e.n, 0); + return [0, 1, 2, 3].map((c) => Math.round(b.reduce((s, e) => s + e.c[c] * e.n, 0) / t)); + }); + const idx = new Uint8Array(n); + const cache = new Map(); + for (let i = 0; i < n; i++) { + const k = (px[i * 4] << 24) | (px[i * 4 + 1] << 16) | (px[i * 4 + 2] << 8) | px[i * 4 + 3]; + let j = cache.get(k); + if (j === undefined) { + let bd = Infinity; + pal.forEach((p, pi) => { + const d = [0, 1, 2, 3].reduce((s, c) => s + (p[c] - px[i * 4 + c]) ** 2, 0); + if (d < bd) { + bd = d; + j = pi; + } + }); + cache.set(k, j); + } + idx[i] = j; + } + return { pal, idx }; +} + +function chunk(type, data) { + const t = Buffer.from(type, 'latin1'); + const out = Buffer.alloc(12 + data.length); + out.writeUInt32BE(data.length, 0); + t.copy(out, 4); + data.copy(out, 8); + out.writeUInt32BE(crc32(Buffer.concat([t, data])) >>> 0, 8 + data.length); + return out; +} + +function encode(w, h, { pal, idx }) { + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(w, 0); + ihdr.writeUInt32BE(h, 4); + ihdr[8] = 8; + ihdr[9] = 3; + const raw = Buffer.alloc(h * (w + 1)); + for (let y = 0; y < h; y++) + Buffer.from(idx.subarray(y * w, (y + 1) * w)).copy(raw, y * (w + 1) + 1); + return Buffer.concat([ + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), + chunk('IHDR', ihdr), + chunk('PLTE', Buffer.from(pal.flatMap((p) => p.slice(0, 3)))), + chunk('tRNS', Buffer.from(pal.map((p) => p[3]))), + chunk('IDAT', deflateSync(raw, { level: 9 })), + chunk('IEND', Buffer.alloc(0)), + ]); +} + +const catalogue = JSON.parse(readFileSync(join(src, 'catalogue.json'), 'utf8')); +let total = 0; +for (const mind of demoMinds) { + const m = catalogue.minds[mind]; + for (const f of [...Object.values(m.shared), ...Object.values(m.signatures)]) { + const img = decode(readFileSync(join(src, f))); + const nw = Math.min(WIDTH, img.w); + const nh = Math.round((img.h * nw) / img.w); + const png = encode(nw, nh, quantise(resize(img, nw, nh), nw * nh)); + mkdirSync(dirname(join(outDir, f)), { recursive: true }); + writeFileSync(join(outDir, f), png); + total += png.length; + console.log(f, `${nw}x${nh}`, png.length); + } +} +console.log('total', total); diff --git a/scripts/preview-config.js b/scripts/preview-config.js new file mode 100644 index 00000000..29bfa793 --- /dev/null +++ b/scripts/preview-config.js @@ -0,0 +1,34 @@ +// Preview builds: set the background scene tier in dist/config.json (default 'low'); config.json +// in the repo is the live chat.gamecult.org config (style ridge), and previews pin the style too, ridge unless +// given. Previews are static directories with no SPA fallback, so they also switch +// to the hash router: a reload on /login/matrix.org would otherwise be an nginx 404. With --demo it +// also switches on the pretend homeserver (src/preview-demo): previewDemo true, and the pretend +// server as the only homeserver. Usage: +// node scripts/preview-config.js [tier] [style] [--demo] +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const args = process.argv.slice(2); +const demo = args.includes('--demo'); +const [tier = 'low', style = 'ridge'] = args.filter((a) => !a.startsWith('--')); +const file = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'dist', 'config.json'); +const config = JSON.parse(fs.readFileSync(file, 'utf8')); +config.hashRouter = { enabled: true, basename: '/' }; +config.backgroundScene = { ...config.backgroundScene, tier, style }; +if (demo) { + config.previewDemo = true; + config.defaultHomeserver = 0; + config.homeserverList = ['demo.invalid']; + config.allowCustomHomeservers = false; + config.featuredCommunities = { + openAsDefault: false, + spaces: ['#gamecult:demo.invalid'], + rooms: [], + servers: ['demo.invalid'], + }; +} +fs.writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`); +console.log( + `${file}: backgroundScene.tier = ${tier}, style = ${style}${demo ? ', previewDemo on' : ''}` +); diff --git a/scripts/sync-poses.mjs b/scripts/sync-poses.mjs new file mode 100644 index 00000000..9a5b1f83 --- /dev/null +++ b/scripts/sync-poses.mjs @@ -0,0 +1,24 @@ +// Copies the pose catalogue and the art it names from the cast-art directory. +// The catalogue is the only list of poses the client knows; the art is not committed. +import { copyFileSync, mkdirSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const src = process.argv[2] ?? '/opt/commons/cast-art/poses'; +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const catalogue = JSON.parse(readFileSync(join(src, 'catalogue.json'), 'utf8')); + +copyFileSync( + join(src, 'catalogue.json'), + join(root, 'src/app/components/message/pose/catalogue.json') +); +const files = Object.values(catalogue.minds).flatMap((m) => [ + ...Object.values(m.shared), + ...Object.values(m.signatures), +]); +files.forEach((f) => { + const dest = join(root, 'public/poses', f); + mkdirSync(dirname(dest), { recursive: true }); + copyFileSync(join(src, f), dest); +}); +console.log(`synced catalogue and ${files.length} images from ${src}`); diff --git a/src/app/components/AccountDataEditor.tsx b/src/app/components/AccountDataEditor.tsx index 2dbaf1f1..8c28b935 100644 --- a/src/app/components/AccountDataEditor.tsx +++ b/src/app/components/AccountDataEditor.tsx @@ -16,6 +16,7 @@ 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'; @@ -294,7 +295,7 @@ export function AccountDataEditor({ - + diff --git a/src/app/components/BackupRestore.tsx b/src/app/components/BackupRestore.tsx index 068f437b..57a18674 100644 --- a/src/app/components/BackupRestore.tsx +++ b/src/app/components/BackupRestore.tsx @@ -30,6 +30,7 @@ import { } from '../hooks/useKeyBackup'; import { stopPropagation } from '../utils/keyboard'; import { useRestoreBackupOnVerification } from '../hooks/useRestoreBackupOnVerification'; +import { PhoneHit } from '../styles/PhoneHit.css'; type BackupStatusProps = { enabled: boolean; @@ -177,6 +178,7 @@ export function BackupRestoreTile({ crypto }: BackupRestoreTileProps) { )} Device Verification - + diff --git a/src/app/components/DeviceVerificationSetup.tsx b/src/app/components/DeviceVerificationSetup.tsx index 433fa6a1..68f42221 100644 --- a/src/app/components/DeviceVerificationSetup.tsx +++ b/src/app/components/DeviceVerificationSetup.tsx @@ -25,6 +25,7 @@ import { ActionUIA, ActionUIAFlowsLoader } from './ActionUIA'; import { useMatrixClient } from '../hooks/useMatrixClient'; import { useAlive } from '../hooks/useAlive'; import { UseStateProvider } from './UseStateProvider'; +import { PhoneHit } from '../styles/PhoneHit.css'; type UIACallback = ( authDict: AuthDict | null @@ -301,7 +302,7 @@ export const DeviceVerificationSetup = forwardRef Setup Device Verification - + @@ -336,7 +337,7 @@ export const DeviceVerificationReset = forwardRef Reset Device Verification - + diff --git a/src/app/components/Pdf-viewer/PdfViewer.tsx b/src/app/components/Pdf-viewer/PdfViewer.tsx index 9c7fd980..d0e5f715 100644 --- a/src/app/components/Pdf-viewer/PdfViewer.tsx +++ b/src/app/components/Pdf-viewer/PdfViewer.tsx @@ -27,6 +27,7 @@ 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; @@ -108,7 +109,7 @@ export const PdfViewer = as<'div', PdfViewerProps>(
- + @@ -117,6 +118,7 @@ export const PdfViewer = as<'div', PdfViewerProps>( ( {Math.round(zoom * 100)}% 1 ? 'Success' : 'SurfaceVariant'} outlined={zoom > 1} size="300" diff --git a/src/app/components/ServerConfigsLoader.test.ts b/src/app/components/ServerConfigsLoader.test.ts new file mode 100644 index 00000000..527895a0 --- /dev/null +++ b/src/app/components/ServerConfigsLoader.test.ts @@ -0,0 +1,32 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { loadServerConfigs } from './ServerConfigsLoader'; + +const server = (authMetadata: () => Promise) => + ({ + getCapabilities: async () => ({}), + getMediaConfig: async () => ({ 'm.upload.size': 1 }), + getAuthMetadata: authMetadata, + } as unknown as Parameters[0]); + +describe('loadServerConfigs', () => { + afterEach(() => vi.restoreAllMocks()); + + it('logs nothing when the homeserver has no OIDC (no auth metadata came back)', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + const configs = await loadServerConfigs( + server(async () => { + throw new Error('M_UNRECOGNIZED'); + }) + ); + expect(error).not.toHaveBeenCalled(); + expect(configs.authMetadata).toBeUndefined(); + expect(configs.mediaConfig).toEqual({ 'm.upload.size': 1 }); + }); + + it('still logs and drops auth metadata that came back malformed', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + const configs = await loadServerConfigs(server(async () => ({ issuer: 'https://x' }))); + expect(error).toHaveBeenCalled(); + expect(configs.authMetadata).toBeUndefined(); + }); +}); diff --git a/src/app/components/ServerConfigsLoader.tsx b/src/app/components/ServerConfigsLoader.tsx index 3c8ce8eb..ea56d8d2 100644 --- a/src/app/components/ServerConfigsLoader.tsx +++ b/src/app/components/ServerConfigsLoader.tsx @@ -1,5 +1,10 @@ import { ReactNode, useCallback, useMemo } from 'react'; -import { Capabilities, validateAuthMetadata, ValidatedAuthMetadata } from 'matrix-js-sdk'; +import { + Capabilities, + MatrixClient, + validateAuthMetadata, + ValidatedAuthMetadata, +} from 'matrix-js-sdk'; import { AsyncStatus, useAsyncCallbackValue } from '../hooks/useAsyncCallback'; import { useMatrixClient } from '../hooks/useMatrixClient'; import { MediaConfig } from '../hooks/useMediaConfig'; @@ -14,35 +19,42 @@ export type ServerConfigs = { type ServerConfigsLoaderProps = { children: (configs: ServerConfigs) => ReactNode; }; +/** + * The homeserver's capabilities, media config and OIDC metadata. A homeserver with no OIDC (and the demo's pretend + * server) returns no auth metadata: there is nothing to validate, so nothing is logged. Metadata that did come back + * and fails validation is logged and dropped. + */ +export async function loadServerConfigs( + mx: Pick +): Promise { + const result = await Promise.allSettled([ + mx.getCapabilities(), + mx.getMediaConfig(), + mx.getAuthMetadata(), + ]); + + const capabilities = promiseFulfilledResult(result[0]); + const mediaConfig = promiseFulfilledResult(result[1]); + const authMetadata = promiseFulfilledResult(result[2]); + let validatedAuthMetadata: ValidatedAuthMetadata | undefined; + + if (authMetadata) { + try { + validatedAuthMetadata = validateAuthMetadata(authMetadata); + } catch (e) { + console.error(e); + } + } + + return { capabilities, mediaConfig, authMetadata: validatedAuthMetadata }; +} + export function ServerConfigsLoader({ children }: ServerConfigsLoaderProps) { const mx = useMatrixClient(); const fallbackConfigs = useMemo(() => ({}), []); const [configsState] = useAsyncCallbackValue( - useCallback(async () => { - const result = await Promise.allSettled([ - mx.getCapabilities(), - mx.getMediaConfig(), - mx.getAuthMetadata(), - ]); - - const capabilities = promiseFulfilledResult(result[0]); - const mediaConfig = promiseFulfilledResult(result[1]); - const authMetadata = promiseFulfilledResult(result[2]); - let validatedAuthMetadata: ValidatedAuthMetadata | undefined; - - try { - validatedAuthMetadata = validateAuthMetadata(authMetadata); - } catch (e) { - console.error(e); - } - - return { - capabilities, - mediaConfig, - authMetadata: validatedAuthMetadata, - }; - }, [mx]) + useCallback(() => loadServerConfigs(mx), [mx]) ); const configs: ServerConfigs = diff --git a/src/app/components/UIAFlowOverlay.tsx b/src/app/components/UIAFlowOverlay.tsx index a2bdd1b8..49e1d821 100644 --- a/src/app/components/UIAFlowOverlay.tsx +++ b/src/app/components/UIAFlowOverlay.tsx @@ -13,6 +13,7 @@ import { IconButton, } from 'folds'; import FocusTrap from 'focus-trap-react'; +import { PhoneHit } from '../styles/PhoneHit.css'; export type UIAFlowOverlayProps = { currentStep: number; @@ -53,6 +54,7 @@ export function UIAFlowOverlay({ > {(anchorRef) => ( - setOpen(!open)}> - - - }> - - setOpen(false), - clickOutsideDeactivates: true, - escapeDeactivates: stopPropagation, - }} - > - -
- - - - } - after={ - <> - setToolbar(!toolbar)} - aria-pressed={toolbar} - > - - - - - - - - - - } - bottom={ - toolbar && ( -
- - -
- ) - } - /> -
-
-
-
-
- - ); -} diff --git a/src/app/components/editor/Editor.tsx b/src/app/components/editor/Editor.tsx index bd848dd5..b7e6d2fe 100644 --- a/src/app/components/editor/Editor.tsx +++ b/src/app/components/editor/Editor.tsx @@ -23,6 +23,9 @@ import { RenderElement, RenderLeaf } from './Elements'; import { CustomElement } from './slate'; import * as css from './Editor.css'; import { toggleKeyboardShortcut } from './keyboard'; +import { useSetting } from '../../state/hooks/settings'; +import { settingsAtom } from '../../state/settings'; +import { composerSpellcheckAttributes } from './spellcheck'; const initialValue: CustomElement[] = [ { @@ -90,6 +93,7 @@ export const CustomEditor = forwardRef( }, ref ) => { + const [composerSpellcheck] = useSetting(settingsAtom, 'composerSpellcheck'); const renderElement = useCallback( (props: RenderElementProps) => , [] @@ -146,6 +150,7 @@ export const CustomEditor = forwardRef( onKeyDown={handleKeydown} onKeyUp={onKeyUp} onPaste={onPaste} + {...composerSpellcheckAttributes(composerSpellcheck)} /> {after && ( diff --git a/src/app/components/editor/Toolbar.tsx b/src/app/components/editor/Toolbar.tsx index 7d701c42..3721b909 100644 --- a/src/app/components/editor/Toolbar.tsx +++ b/src/app/components/editor/Toolbar.tsx @@ -36,6 +36,7 @@ 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 ( @@ -72,6 +73,7 @@ export function MarkButton({ format, icon, tooltip }: MarkButtonProps) { {(triggerRef) => ( {(triggerRef) => ( {(triggerRef) => ( handleMenuSelect(1)} size="400" @@ -178,6 +182,7 @@ export function HeadingBlockButton() { > {(triggerRef) => ( handleMenuSelect(2)} size="400" @@ -193,6 +198,7 @@ export function HeadingBlockButton() { > {(triggerRef) => ( handleMenuSelect(3)} size="400" @@ -208,6 +214,7 @@ export function HeadingBlockButton() { } > {(triggerRef) => ( {(triggerRef) => ( setIsMarkdown(!isMarkdown)} diff --git a/src/app/components/editor/spellcheck.test.ts b/src/app/components/editor/spellcheck.test.ts new file mode 100644 index 00000000..fa0535cb --- /dev/null +++ b/src/app/components/editor/spellcheck.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { composerSpellcheckAttributes, defaultComposerSpellcheck } from './spellcheck'; + +const media = (coarse: boolean) => (query: string) => ({ + matches: query === '(pointer: coarse)' && coarse, +}); + +describe('composer spellcheck default', () => { + it('is off when the primary pointer is coarse (phone)', () => { + expect(defaultComposerSpellcheck(media(true))).toBe(false); + }); + it('is on for a fine pointer (desktop)', () => { + expect(defaultComposerSpellcheck(media(false))).toBe(true); + }); + it('is on when matchMedia is unavailable', () => { + expect(defaultComposerSpellcheck(undefined)).toBe(true); + }); +}); + +describe('composer spellcheck attributes', () => { + it('off disables both spellcheck and autocorrect', () => { + expect(composerSpellcheckAttributes(false)).toEqual({ spellCheck: false, autoCorrect: 'off' }); + }); + it('on enables spellcheck and leaves autocorrect to the platform', () => { + expect(composerSpellcheckAttributes(true)).toEqual({ spellCheck: true }); + }); +}); diff --git a/src/app/components/editor/spellcheck.ts b/src/app/components/editor/spellcheck.ts new file mode 100644 index 00000000..825676ef --- /dev/null +++ b/src/app/components/editor/spellcheck.ts @@ -0,0 +1,11 @@ +/** Touch devices (phones) get the keyboard's own dictionary; desktop gets the browser's. */ +export const defaultComposerSpellcheck = ( + matchMedia: ((query: string) => { matches: boolean }) | undefined +): boolean => !matchMedia?.('(pointer: coarse)').matches; + +/** + * Attributes for the composer's editable. Off must also switch autocorrect off: + * spellCheck alone leaves Gboard's suggestion strip and correction modal in place. + */ +export const composerSpellcheckAttributes = (enabled: boolean) => + enabled ? ({ spellCheck: true } as const) : ({ spellCheck: false, autoCorrect: 'off' } as const); diff --git a/src/app/components/emoji-board/components/Sidebar.tsx b/src/app/components/emoji-board/components/Sidebar.tsx index de22b483..d2d5f197 100644 --- a/src/app/components/emoji-board/components/Sidebar.tsx +++ b/src/app/components/emoji-board/components/Sidebar.tsx @@ -14,6 +14,7 @@ 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 ( @@ -68,6 +69,7 @@ function SidebarBtn({ > {(ref) => ( ( Seen by - +
diff --git a/src/app/components/image-editor/ImageEditor.tsx b/src/app/components/image-editor/ImageEditor.tsx index 1ec474e8..0f072bb0 100644 --- a/src/app/components/image-editor/ImageEditor.tsx +++ b/src/app/components/image-editor/ImageEditor.tsx @@ -2,6 +2,7 @@ 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; @@ -24,7 +25,7 @@ export const ImageEditor = as<'div', ImageEditorProps>( >
- + diff --git a/src/app/components/image-pack-view/ImagePackView.tsx b/src/app/components/image-pack-view/ImagePackView.tsx index ab81d503..15b1cd08 100644 --- a/src/app/components/image-pack-view/ImagePackView.tsx +++ b/src/app/components/image-pack-view/ImagePackView.tsx @@ -1,6 +1,7 @@ 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'; @@ -29,7 +30,7 @@ export function ImagePackView({ address, requestClose }: ImagePackViewProps) { - + diff --git a/src/app/components/image-viewer/ImageViewer.tsx b/src/app/components/image-viewer/ImageViewer.tsx index 4956a7b6..4d2d7694 100644 --- a/src/app/components/image-viewer/ImageViewer.tsx +++ b/src/app/components/image-viewer/ImageViewer.tsx @@ -7,6 +7,7 @@ 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; @@ -33,7 +34,7 @@ export const ImageViewer = as<'div', ImageViewerProps>( >
- + @@ -42,6 +43,7 @@ export const ImageViewer = as<'div', ImageViewerProps>( ( {Math.round(zoom * 100)}% 1 ? 'Success' : 'SurfaceVariant'} outlined={zoom > 1} size="300" diff --git a/src/app/components/invite-user-prompt/InviteUserPrompt.tsx b/src/app/components/invite-user-prompt/InviteUserPrompt.tsx index ddac0576..a503fe13 100644 --- a/src/app/components/invite-user-prompt/InviteUserPrompt.tsx +++ b/src/app/components/invite-user-prompt/InviteUserPrompt.tsx @@ -42,6 +42,7 @@ 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, @@ -173,7 +174,7 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) { - + diff --git a/src/app/components/join-address-prompt/JoinAddressPrompt.tsx b/src/app/components/join-address-prompt/JoinAddressPrompt.tsx index 50a89418..ffd5241c 100644 --- a/src/app/components/join-address-prompt/JoinAddressPrompt.tsx +++ b/src/app/components/join-address-prompt/JoinAddressPrompt.tsx @@ -20,6 +20,7 @@ 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; @@ -82,7 +83,7 @@ export function JoinAddressPrompt({ onOpen, onCancel }: JoinAddressProps) { Join with Address - +
diff --git a/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx b/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx index 217491e6..832dd59d 100644 --- a/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx +++ b/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx @@ -20,6 +20,7 @@ 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; @@ -68,7 +69,7 @@ export function LeaveRoomPrompt({ roomId, onDone, onCancel }: LeaveRoomPromptPro Leave Room - +
diff --git a/src/app/components/leave-space-prompt/LeaveSpacePrompt.tsx b/src/app/components/leave-space-prompt/LeaveSpacePrompt.tsx index 8709b942..6c75ef99 100644 --- a/src/app/components/leave-space-prompt/LeaveSpacePrompt.tsx +++ b/src/app/components/leave-space-prompt/LeaveSpacePrompt.tsx @@ -20,6 +20,7 @@ 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; @@ -68,7 +69,7 @@ export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptP Leave Space - + diff --git a/src/app/components/logo/logoMark.css.ts b/src/app/components/logo/logoMark.css.ts new file mode 100644 index 00000000..4c967088 --- /dev/null +++ b/src/app/components/logo/logoMark.css.ts @@ -0,0 +1,9 @@ +import { globalStyle, style } from '@vanilla-extract/css'; + +/** + * The Ratatoskr mark is white on transparent (Meta's matte). On any light theme (they all carry `prism-light`) a class + * of this name darkens it to the dark ink; put it on every that shows the mark on a themed surface. The favicon and + * notification icons are files the browser draws and cannot take it. + */ +export const LogoMark = style({}); +globalStyle(`body.prism-light .${LogoMark}`, { filter: 'brightness(0.18)' }); diff --git a/src/app/components/message/FileHeader.tsx b/src/app/components/message/FileHeader.tsx index 2ffc9ec4..41cef5f4 100644 --- a/src/app/components/message/FileHeader.tsx +++ b/src/app/components/message/FileHeader.tsx @@ -2,6 +2,7 @@ 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'; @@ -48,6 +49,7 @@ export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDow variant={hasError ? 'Critical' : 'SurfaceVariant'} size="300" radii="300" + className={PhoneHit} > {downloading ? ( diff --git a/src/app/components/message/Reaction.css.ts b/src/app/components/message/Reaction.css.ts index f020080e..08003f0e 100644 --- a/src/app/components/message/Reaction.css.ts +++ b/src/app/components/message/Reaction.css.ts @@ -1,6 +1,9 @@ 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(); @@ -25,6 +28,14 @@ export const Reaction = style([ selectors: { 'button&': { cursor: 'pointer', + // Phone tap target: the chip is 26 px tall, so its hit area grows 9 px above and below (an ::after) and the + // same 9 px of margin opens the row, so the chips of one row and of the next never share a tap. + '@media': { [PHONE]: { position: 'relative', marginBlock: (PHONE_TAP - 26) / 2 } }, + }, + 'button&::after': { + '@media': { + [PHONE]: { content: '""', position: 'absolute', inset: `${-(PHONE_TAP - 26) / 2}px 0` }, + }, }, '&[aria-pressed=true]': { vars: { diff --git a/src/app/components/message/Reply.css.ts b/src/app/components/message/Reply.css.ts index 7e5540fd..d5254ecb 100644 --- a/src/app/components/message/Reply.css.ts +++ b/src/app/components/message/Reply.css.ts @@ -31,6 +31,7 @@ export const Reply = style({ }); export const ReplyContent = style({ + minWidth: 0, opacity: config.opacity.P300, selectors: { @@ -39,3 +40,19 @@ export const ReplyContent = style({ }, }, }); + +export const ReplyText = style({ + display: '-webkit-box', + WebkitBoxOrient: 'vertical', + WebkitLineClamp: 2, + overflow: 'hidden', + minWidth: 0, + maxWidth: '100%', + overflowWrap: 'anywhere', + wordBreak: 'break-word', +}); + +export const ReplyWrap = style({ + minWidth: 0, + maxWidth: '100%', +}); diff --git a/src/app/components/message/Reply.tsx b/src/app/components/message/Reply.tsx index 57bf2af9..894b3b03 100644 --- a/src/app/components/message/Reply.tsx +++ b/src/app/components/message/Reply.tsx @@ -101,7 +101,14 @@ export const Reply = as<'div', ReplyProps>( const bodyJSX = body ? scaleSystemEmoji(trimReplyFromBody(body)) : fallbackBody; return ( - + {threadRootId && ( )} @@ -119,7 +126,7 @@ export const Reply = as<'div', ReplyProps>( onClick={onClick} > {replyEvent !== undefined ? ( - + {badEncryption ? : bodyJSX} ) : ( diff --git a/src/app/components/message/content/AudioContent.tsx b/src/app/components/message/content/AudioContent.tsx index 478486a4..94132cea 100644 --- a/src/app/components/message/content/AudioContent.tsx +++ b/src/app/components/message/content/AudioContent.tsx @@ -3,6 +3,7 @@ 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'; @@ -159,6 +160,7 @@ export function AudioContent({ variant="SurfaceVariant" size="300" radii="Pill" + className={PhoneHitSpread24} onClick={() => setMute(!mute)} aria-pressed={mute} > diff --git a/src/app/components/message/layout/Bubble.tsx b/src/app/components/message/layout/Bubble.tsx index 93ff84bc..6f4e94b0 100644 --- a/src/app/components/message/layout/Bubble.tsx +++ b/src/app/components/message/layout/Bubble.tsx @@ -28,36 +28,64 @@ function BubbleLeftArrow({ variant }: BubbleArrowProps) { type BubbleLayoutProps = { hideBubble?: boolean; + /** The sender is the signed-in user: right-aligned, in the accent colour, no avatar. */ + own?: boolean; + /** A Mind's pose portrait; it replaces the avatar and nests in a corner of the bubble. */ + pose?: ReactNode; + /** Delay in ms before the first-appearance animation; undefined plays none. */ + enterDelay?: number; before?: ReactNode; header?: ReactNode; }; export const BubbleLayout = as<'div', BubbleLayoutProps>( - ({ hideBubble, before, header, children, ...props }, ref) => ( - - - {before} - - - {header} - {hideBubble ? ( - children - ) : ( - - - {before ? : null} - {children} - + ({ hideBubble, own, pose, enterDelay, before, header, children, style, ...props }, ref) => { + // `false` is a Mind's message continuing a run: aligned with the portraits, showing none. + const poseRow = pose !== undefined; + const arrow = !!before && !poseRow && !own; + const entering = enterDelay !== undefined; + return ( + + {!poseRow && !own && ( + + {before} )} + + {header &&
{header}
} + {hideBubble ? ( + children + ) : ( + + + {arrow ? : null} + {pose} + {children} + + + )} +
-
- ) + ); + } ); diff --git a/src/app/components/message/layout/bubbleBounds.test.ts b/src/app/components/message/layout/bubbleBounds.test.ts new file mode 100644 index 00000000..4a9ad4da --- /dev/null +++ b/src/app/components/message/layout/bubbleBounds.test.ts @@ -0,0 +1,48 @@ +import { readFileSync } from 'fs'; +import { describe, expect, it } from 'vitest'; +import zlib from 'zlib'; +import { compile, processVanillaFile } from '@vanilla-extract/integration'; + +// A long reply quote (nowrap) sized the own-message row to 782px on a 390px phone. The row, the column and the bubble +// must be able to shrink to their parent, so pin the emitted declarations (as phoneTapTargets.test.ts does). +const emitted = async () => { + const filePath = `${process.cwd()}/src/app/components/message/layout/layout.css.ts`; + const { source } = await compile({ filePath, cwd: process.cwd(), identOption: 'debug' }); + const code = await processVanillaFile({ source, filePath, identOption: 'debug' }); + const bytes = Buffer.from( + (code.match(/source=#?([A-Za-z0-9+/=]+)/) as RegExpMatchArray)[1], + 'base64' + ); + return (bytes[0] === 0x1f && bytes[1] === 0x8b ? zlib.gunzipSync(bytes) : bytes).toString(); +}; + +const declarations = (css: string, className: string): string[] => { + const rule = new RegExp(`^\\.layout_${className}__[\\w-]+ \\{\\n([^}]*)\\}`, 'm').exec(css); + expect(rule, `no .layout_${className} rule emitted`).toBeTruthy(); + return (rule as RegExpExecArray)[1].split('\n').map((l) => l.trim().replace(/;$/, '')); +}; + +describe('bubble rows shrink to their column', () => { + it.each([ + ['BubbleColumn', ['min-width: 0']], + ['BubbleContentRow', ['min-width: 0', 'max-width: 100%']], + ['BubbleContent', ['min-width: 0']], + ])('%s', async (name, expected) => { + const css = await emitted(); + expect(declarations(css, name)).toEqual(expect.arrayContaining(expected)); + }); + + it('BubbleContent keeps its 800px cap inside the column width', async () => { + const css = await emitted(); + expect(declarations(css, 'BubbleContent').join('\n')).toMatch(/max-width: min\(100%, 50rem\)/); + }); + + it('Bubble.tsx puts BubbleColumn on the column and BubbleContentRow on the row', () => { + const tsx = readFileSync( + `${process.cwd()}/src/app/components/message/layout/Bubble.tsx`, + 'utf8' + ); + expect(tsx).toMatch(/classNames\(css\.BubbleColumn,/); + expect(tsx).toMatch(//); + }); +}); diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index cc2cd0c6..39a40aa8 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -1,6 +1,7 @@ -import { createVar, keyframes, style, styleVariants } from '@vanilla-extract/css'; +import { createVar, globalStyle, keyframes, style, styleVariants } from '@vanilla-extract/css'; import { recipe, RecipeVariants } from '@vanilla-extract/recipes'; import { DefaultReset, color, config, toRem } from 'folds'; +import { MOBILE_BREAKPOINT } from '../../../hooks/useScreenSize'; export const StickySection = style({ position: 'sticky', @@ -138,11 +139,26 @@ export const BubbleBefore = style({ minWidth: toRem(36), }); +// minWidth 0 down the column: a flex item never shrinks below its content (a reply quote's unbroken text), +// so without it a long quote pushes the column past the row, and a right-aligned row pushes it off the left edge. +export const BubbleColumn = style({ + minWidth: 0, +}); + +// The row around a bubble sizes to its content in a right-aligned (own) column, and a quote's nowrap text +// makes that content as wide as the quote: cap the row at the column so the bubble can shrink inside it. +export const BubbleContentRow = style({ + minWidth: 0, + maxWidth: '100%', +}); + export const BubbleContent = style({ - maxWidth: toRem(800), + minWidth: 0, + maxWidth: `min(100%, ${toRem(800)})`, padding: config.space.S200, backgroundColor: color.SurfaceVariant.Container, color: color.SurfaceVariant.OnContainer, + border: `1px solid ${color.SurfaceVariant.ContainerLine}`, borderRadius: config.radii.R500, position: 'relative', }); @@ -151,6 +167,127 @@ export const BubbleContentArrowLeft = style({ borderTopLeftRadius: 0, }); +// A Mind's pose nests in the corner of its bubble, on phones and wide screens alike: the sprite's +// body sits in the bubble's padding and the bubble's edge cuts it, so nothing hangs outside; the +// text wraps around its silhouette (shape-outside, traced from the alpha channel) and the bubble +// grows to hold it. How much of the art the edge may cut comes from pose/nest.ts. +// Displayed width of the whole sprite image; the crop fractions are multiples of it. +export const portraitWidth = createVar(); +const PHONE = 'screen and (max-width: 700px)'; +const PORTRAIT = toRem(190); +const PHONE_PORTRAIT = 'min(38vw, 160px)'; +const PHONE_TEXT_FLOOR = toRem(170); +const BUBBLE_PAD = toRem(12); +const BUBBLE_CORNER = toRem(6); +const POP = 'cubic-bezier(0.34, 1.7, 0.64, 1)'; +const RISE = 'cubic-bezier(0.34, 1.45, 0.64, 1)'; + +export const BubbleRowOwn = style({ + justifyContent: 'flex-end', +}); + +export const BubbleColumnOwn = style({ + alignItems: 'flex-end', + maxWidth: toRem(640), +}); + +export const BubbleHeaderOwn = style({ + alignSelf: 'flex-end', +}); + +// The message body is a flex column, which forms its own block formatting context and would sit +// whole beside the floated portrait; as a plain block its lines wrap around the silhouette and +// then run the bubble's full width below it. +export const BubbleContentPose = style({ + display: 'flow-root', + overflow: 'clip', + padding: BUBBLE_PAD, +}); + +// A bubble that shows a portrait is at least portrait + a readable column wide, or the text beside +// it collapses to a strip. +export const BubbleContentPortrait = style({ + vars: { [portraitWidth]: PORTRAIT }, + minWidth: `min(100%, calc(${portraitWidth} + ${PHONE_TEXT_FLOOR}))`, + '@media': { + [PHONE]: { + vars: { [portraitWidth]: PHONE_PORTRAIT }, + }, + }, +}); + +globalStyle(`${BubbleContentPose} > div`, { + display: 'block', +}); + +export const BubbleContentOwn = style({ + backgroundColor: color.Primary.Container, + color: color.Primary.OnContainer, + border: `1px solid ${color.Primary.ContainerLine}`, + borderRadius: config.radii.R500, + borderTopRightRadius: BUBBLE_CORNER, +}); + +// The button is the visible part of the sprite (the part inside the bubble's corner); the image +// inside it is offset by the crop. The inline --pose-* fractions (of the image width) come from +// nestView; until the art's size is known the button is simply the whole image. +export const PoseButton = style({ + float: 'left', + width: `var(--pose-bw, ${portraitWidth})`, + height: 'var(--pose-bh, auto)', + // Out into the padding on the top, bottom and outer side, so the bubble's edge is the crop. + margin: `calc(-1 * ${BUBBLE_PAD}) ${toRem(6)} calc(-1 * ${BUBBLE_PAD}) calc(-1 * ${BUBBLE_PAD})`, + padding: 0, + border: 'none', + background: 'none', + cursor: 'pointer', + lineHeight: 0, + overflow: 'hidden', + shapeMargin: toRem(7), + transformOrigin: '50% 90%', +}); + +export const PoseButtonAlt = style({ + float: 'right', + margin: `calc(-1 * ${BUBBLE_PAD}) calc(-1 * ${BUBBLE_PAD}) calc(-1 * ${BUBBLE_PAD}) ${toRem(6)}`, +}); + +export const PosePortrait = style({ + display: 'block', + width: portraitWidth, + maxWidth: 'none', + height: 'auto', + marginLeft: 'var(--pose-ix, 0)', + marginTop: 'var(--pose-iy, 0)', +}); + +const pop = keyframes({ + from: { opacity: 0, transform: `translateY(${toRem(34)}) scale(0.42)` }, + to: { opacity: 1, transform: 'none' }, +}); +const rise = keyframes({ + from: { opacity: 0, transform: `translateY(${toRem(14)}) scale(0.96)` }, + to: { opacity: 1, transform: 'none' }, +}); + +// First appearance of a message: the portrait pops, the bubble rises just behind it. The delay +// (a --enter-delay on the row) staggers messages that arrive together. +export const EnterPortrait = style({ + '@media': { + '(prefers-reduced-motion: no-preference)': { + animation: `${pop} 460ms ${POP} var(--enter-delay, 0ms) both`, + }, + }, +}); + +export const EnterBubble = style({ + '@media': { + '(prefers-reduced-motion: no-preference)': { + animation: `${rise} 380ms ${RISE} calc(var(--enter-delay, 0ms) + 50ms) both`, + }, + }, +}); + export const BubbleLeftArrow = style({ width: toRem(9), height: toRem(8), @@ -161,6 +298,12 @@ export const BubbleLeftArrow = style({ zIndex: 1, }); +// 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. +const SCREEN_PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`; export const Username = style({ overflow: 'hidden', whiteSpace: 'nowrap', @@ -168,6 +311,9 @@ export const Username = style({ selectors: { 'button&': { cursor: 'pointer', + '@media': { + [SCREEN_PHONE]: { paddingBlock: 11, marginBlock: -11, minWidth: 44, textAlign: 'start' }, + }, }, 'button&:hover, button&:focus-visible': { textDecoration: 'underline', @@ -205,3 +351,27 @@ export const MessageTextBody = recipe({ }); export type MessageTextBodyVariants = RecipeVariants; + +// An outside list marker hangs left of its item's text and ignores the portrait's silhouette, so +// beside a left portrait the number lands under the art. Inside, it is a word on the first line +// and wraps around the silhouette like the rest. +globalStyle(`${BubbleContentPose} ol, ${BubbleContentPose} ul`, { + listStylePosition: 'inside', + paddingLeft: 0, +}); + +// While the background scene runs (html[data-scene]) a chat bubble is the one filled surface in +// the app: its own colour at half strength over a blur of the scene, so the words stay readable +// and the ridge shows through. The arrow wedge is opaque, so it goes. +const scene = ':root[data-scene]'; +const half = (c: string) => `color-mix(in srgb, ${c} 50%, transparent)`; +globalStyle(`${scene} ${BubbleContent}`, { + backgroundColor: [color.SurfaceVariant.Container, half(color.SurfaceVariant.Container)], + backdropFilter: 'blur(12px)', +}); +globalStyle(`${scene} ${BubbleContentOwn}`, { + backgroundColor: [color.Primary.Container, half(color.Primary.Container)], +}); +globalStyle(`${scene} ${BubbleLeftArrow}`, { + display: 'none', +}); diff --git a/src/app/components/message/pose/PoseAvatar.tsx b/src/app/components/message/pose/PoseAvatar.tsx new file mode 100644 index 00000000..05e60631 --- /dev/null +++ b/src/app/components/message/pose/PoseAvatar.tsx @@ -0,0 +1,137 @@ +import React, { useEffect, useRef, useState } from 'react'; +import classNames from 'classnames'; +import * as css from '../layout/layout.css'; +import { poseSrc, safeBox } from './poses'; +import { alphaBounds, nestView, type Box } from './nest'; +import { silhouette } from './silhouette'; + +type PoseAvatarProps = { + mind: string; + pose: string; + userId: string; + /** Nest in the bubble's top-right corner instead of the top-left. */ + alt?: boolean; + /** Play the first-appearance pop. */ + enter?: boolean; + /** Without it the portrait is a plain image: no button, no tab stop. */ + onClick?: React.MouseEventHandler; +}; + +// Safe boxes derived from art's alpha, by source; the art is traced once. +const traced = new Map(); +const trace = (img: HTMLImageElement, src: string) => { + const hit = traced.get(src); + if (hit) return hit; + const w = img.naturalWidth; + const h = img.naturalHeight; + const ctx = document.createElement('canvas').getContext('2d'); + if (!ctx) return undefined; + ctx.canvas.width = w; + ctx.canvas.height = h; + ctx.drawImage(img, 0, 0); + const box = alphaBounds(ctx.getImageData(0, 0, w, h).data, w, h); + if (!box) return undefined; + const found = { box, size: [w, h] as [number, number] }; + traced.set(src, found); + return found; +}; + +export function PoseAvatar({ mind, pose, userId, alt, enter, onClick }: PoseAvatarProps) { + const src = poseSrc(mind, pose, import.meta.env.BASE_URL); + const imgRef = useRef(null); + const [shape, setShape] = useState(); + const measured = safeBox(mind, pose); + const [derived, setDerived] = useState<{ box: Box; size: [number, number] }>(); + const art = measured ?? (derived?.size ? derived : undefined); + // Measured boxes are cropped by the share Aqua's numbers allow; a box traced from the alpha is + // already tight to the art, so the bubble may take all of the transparent margin. + const view = art && nestView(art.box, art.size, !!alt, measured ? undefined : 1); + + // Art with no measured box: find its extent when it loads. + useEffect(() => { + setDerived(undefined); + const img = imgRef.current; + if (!img || measured) return undefined; + const find = () => { + if (!img.complete || img.naturalWidth === 0) return; + try { + setDerived(trace(img, src)); + } catch { + setDerived(undefined); + } + }; + find(); + img.addEventListener('load', find); + return () => img.removeEventListener('load', find); + }, [src, measured]); + + // The bubble text wraps around the portrait's silhouette. Retraced when the art loads, when its + // crop is known, and when its width changes with the viewport. + useEffect(() => { + const img = imgRef.current; + if (!img) return undefined; + const retrace = () => { + if (!img.complete || img.naturalWidth === 0) return; + try { + const box = img.parentElement; + const style = getComputedStyle(img); + setShape( + silhouette( + img, + !alt, + box + ? { + x: 0 - parseFloat(style.marginLeft || '0'), + y: 0 - parseFloat(style.marginTop || '0'), + width: box.clientWidth, + height: box.clientHeight, + } + : undefined + ) + ); + } catch { + setShape(undefined); + } + }; + retrace(); + img.addEventListener('load', retrace); + const resize = new ResizeObserver(retrace); + resize.observe(img); + return () => { + img.removeEventListener('load', retrace); + resize.disconnect(); + }; + }, [src, alt, view?.x, view?.y, view?.width, view?.height]); + + // Crop fractions are of the image's width, so one set serves every displayed size. + const frac = (px: number) => `calc(${css.portraitWidth} * ${px / (art?.size[0] ?? 1)})`; + const cropStyle = + art && view + ? ({ + '--pose-bw': frac(view.width), + '--pose-bh': frac(view.height), + '--pose-ix': frac(-view.x), + '--pose-iy': frac(-view.y), + } as React.CSSProperties) + : undefined; + + const Tag = onClick ? 'button' : 'span'; + return ( + + {`${mind}, + + ); +} diff --git a/src/app/components/message/pose/catalogue.json b/src/app/components/message/pose/catalogue.json new file mode 100644 index 00000000..87e7ba71 --- /dev/null +++ b/src/app/components/message/pose/catalogue.json @@ -0,0 +1,242 @@ +{ + "shared": [ + "neutral", + "happy", + "amused", + "thinking", + "annoyed", + "surprised" + ], + "minds": { + "eureka": { + "sheet": "r1b", + "shared": { + "neutral": "eureka/neutral.png", + "happy": "eureka/happy.png", + "amused": "eureka/amused.png", + "thinking": "eureka/thinking.png", + "annoyed": "eureka/annoyed.png", + "surprised": "eureka/surprised.png" + }, + "signatures": { + "probe": "eureka/probe.png", + "inspect": "eureka/inspect.png", + "report": "eureka/report.png", + "hood-smug": "eureka/hood-smug.png", + "concerned": "eureka/concerned.png", + "determined": "eureka/determined.png" + }, + "signature_descriptions": { + "probe": "Frowning, pencil raised to her chin, working a problem out.", + "inspect": "Frowning, one finger pointing at the thing to look at.", + "report": "Smirking over a folder of findings.", + "hood-smug": "Eyes shut under the hood, self-satisfied smirk.", + "concerned": "Worried frown, pencil at her chin.", + "determined": "Hard-set frown, pencil in hand, resolved." + } + }, + "aqua": { + "sheet": "r1b", + "shared": { + "neutral": "aqua/neutral.png", + "happy": "aqua/happy.png", + "amused": "aqua/amused.png", + "thinking": "aqua/thinking.png", + "annoyed": "aqua/annoyed.png", + "surprised": "aqua/surprised.png" + }, + "signatures": { + "concerned": "aqua/concerned.png", + "fretful": "aqua/fretful.png", + "weary": "aqua/weary.png", + "glare": "aqua/glare.png", + "smug": "aqua/smug.png", + "sigh": "aqua/sigh.png", + "charging": "aqua/charging.png", + "delighted": "aqua/delighted.png", + "content": "aqua/content.png" + }, + "signature_descriptions": { + "concerned": "Worried frown, brows pinched.", + "fretful": "Anxious, a fin pressed to her chin.", + "weary": "Droopy half-lidded eyes; worn out.", + "glare": "Scowling hard, a fin held out to the side.", + "smug": "Sidelong look and a small smirk.", + "sigh": "Eyes shut, breathing out.", + "charging": "Eyes shut, a charging cable plugged into her mouth; recharging.", + "delighted": "Wide open-mouthed grin, fins up.", + "content": "Calm, small contented smile." + } + }, + "heimdall": { + "sheet": "r1a", + "shared": { + "neutral": "heimdall/neutral.png", + "happy": "heimdall/happy.png", + "amused": "heimdall/amused.png", + "thinking": "heimdall/thinking.png", + "annoyed": "heimdall/annoyed.png", + "surprised": "heimdall/surprised.png" + }, + "signatures": { + "concerned": "heimdall/concerned.png", + "determined": "heimdall/determined.png", + "badge": "heimdall/badge.png", + "barrier": "heimdall/barrier.png", + "protect": "heimdall/protect.png", + "revoke": "heimdall/revoke.png" + }, + "signature_descriptions": { + "concerned": "Worried frown, brows raised.", + "determined": "Hard scowl, jaw set.", + "badge": "Stern, holding a gold key out as his credentials.", + "barrier": "Palm raised behind a translucent blue shield: stop.", + "protect": "Scowling, cloak swept forward like a guard.", + "revoke": "Stern, two keys in hand; taking access away." + } + }, + "kiko": { + "sheet": "r1b", + "shared": { + "neutral": "kiko/neutral.png", + "happy": "kiko/happy.png", + "amused": "kiko/amused.png", + "thinking": "kiko/thinking.png", + "annoyed": "kiko/annoyed.png", + "surprised": "kiko/surprised.png" + }, + "signatures": { + "shy-smile": "kiko/shy-smile.png", + "uneasy": "kiko/uneasy.png", + "tinker": "kiko/tinker.png", + "confetti": "kiko/confetti.png", + "sprint": "kiko/sprint.png", + "debug": "kiko/debug.png" + }, + "signature_descriptions": { + "shy-smile": "Soft, shy smile; blushing.", + "uneasy": "Blushing, tongue peeking out, unsure.", + "tinker": "Delighted, holding up a small gadget.", + "confetti": "Open-mouthed grin, confetti falling, a glowing glyph on one finger.", + "sprint": "Eyes shut, laughing, one arm up, mid-dash.", + "debug": "Squinting through a magnifying glass at a bug." + } + }, + "weksa": { + "sheet": "r1a", + "shared": { + "neutral": "weksa/neutral.png", + "happy": "weksa/happy.png", + "amused": "weksa/amused.png", + "thinking": "weksa/thinking.png", + "annoyed": "weksa/annoyed.png", + "surprised": "weksa/surprised.png" + }, + "signatures": { + "concerned": "weksa/concerned.png", + "skeptical": "weksa/skeptical.png", + "map": "weksa/map.png", + "fieldwork": "weksa/fieldwork.png", + "argue": "weksa/argue.png", + "interlingua": "weksa/interlingua.png" + }, + "signature_descriptions": { + "concerned": "Worried frown, wide eyes, scroll on her back.", + "skeptical": "Half-lidded, flat frown; unconvinced.", + "map": "Worried, unfurling a hand-drawn map.", + "fieldwork": "Frowning in focus, holding a carved idol and a brush.", + "argue": "Open-mouthed, pointing, holding up a tablet as evidence.", + "interlingua": "Shouting, a glyph tablet in each hand." + } + }, + "void": { + "sheet": "r1b", + "shared": { + "neutral": "void/neutral.png", + "happy": "void/happy.png", + "amused": "void/amused.png", + "thinking": "void/thinking.png", + "annoyed": "void/annoyed.png", + "surprised": "void/surprised.png" + }, + "signatures": { + "determined": "void/determined.png", + "glare": "void/glare.png", + "shrug": "void/shrug.png", + "reading": "void/reading.png", + "point": "void/point.png", + "deadpan": "void/deadpan.png" + }, + "signature_descriptions": { + "determined": "Hood bowed, narrowed eyes; set on the task.", + "glare": "Narrowed eyes under the hood; hostile stare.", + "shrug": "Both hands open, eyes wide: no idea.", + "reading": "Holding a sheet of paper, reading it.", + "point": "Half-lidded eyes, one finger pointing off to the side.", + "deadpan": "Flat lines for eyes; unimpressed." + } + }, + "libby": { + "sheet": "r1a", + "shared": { + "neutral": "libby/neutral.png", + "happy": "libby/happy.png", + "amused": "libby/amused.png", + "thinking": "libby/thinking.png", + "annoyed": "libby/annoyed.png", + "surprised": "libby/surprised.png" + }, + "signatures": { + "concerned": "libby/concerned.png", + "sad": "libby/sad.png", + "stamp": "libby/stamp.png", + "research": "libby/research.png", + "shush": "libby/shush.png", + "schema": "libby/schema.png" + }, + "signature_descriptions": { + "concerned": "Worried, a book clutched to her chest.", + "sad": "Downcast, a book clutched to her chest.", + "stamp": "Smiling, stamping a page of an open book.", + "research": "Eager over an open book, an ink bottle at hand.", + "shush": "Speaking over an open book, one finger on the page.", + "schema": "Raising a finger beside a glowing flow-diagram panel." + } + }, + "nibu": { + "sheet": "r1b", + "shared": { + "neutral": "nibu/neutral.png", + "happy": "nibu/happy.png", + "amused": "nibu/amused.png", + "thinking": "nibu/thinking.png", + "annoyed": "nibu/annoyed.png", + "surprised": "nibu/surprised.png" + }, + "signatures": { + "sheepish": "nibu/sheepish.png", + "pout": "nibu/pout.png", + "resolute": "nibu/resolute.png", + "console": "nibu/console.png", + "glitch": "nibu/glitch.png", + "triumph": "nibu/triumph.png" + }, + "signature_descriptions": { + "sheepish": "Arms crossed, awkward toothy grin.", + "pout": "Arms crossed, pouting frown.", + "resolute": "Arms crossed, scowling; standing her ground.", + "console": "Frowning at a handheld console.", + "glitch": "Frowning, palm up, a glitch tearing across her eyes.", + "triumph": "Eyes shut, fist raised; quietly pleased with a win." + } + } + }, + "shared_descriptions": { + "neutral": "At rest, no particular feeling.", + "happy": "Pleased; smiling.", + "amused": "Quietly entertained, as if at a private joke.", + "thinking": "Working something out.", + "annoyed": "Irritated; patience running thin.", + "surprised": "Caught off guard." + } +} \ No newline at end of file diff --git a/src/app/components/message/pose/entrance.test.ts b/src/app/components/message/pose/entrance.test.ts new file mode 100644 index 00000000..6f16a384 --- /dev/null +++ b/src/app/components/message/pose/entrance.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { claimEntrance } from './entrance'; + +describe('claimEntrance', () => { + it('animates a message once, never on a remount', () => { + expect(claimEntrance('$once', 1_000_000)).toBe(0); + expect(claimEntrance('$once', 1_000_500)).toBeUndefined(); + }); + it('staggers messages that mount together, capped so a long backlog still lands fast', () => { + const t = 2_000_000; + const delays = ['$a', '$b', '$c', '$d', '$e', '$f'].map((id) => claimEntrance(id, t)); + expect(delays.slice(0, 3)).toEqual([0, 300, 600]); + expect(Math.max(...(delays as number[]))).toBe(900); + }); + it('lets a lone later message in at once', () => { + claimEntrance('$x', 3_000_000); + expect(claimEntrance('$y', 3_010_000)).toBe(0); + }); +}); diff --git a/src/app/components/message/pose/entrance.ts b/src/app/components/message/pose/entrance.ts new file mode 100644 index 00000000..8386f80b --- /dev/null +++ b/src/app/components/message/pose/entrance.ts @@ -0,0 +1,22 @@ +import { useState } from 'react'; + +// A message animates in the first time it is shown, not each time the virtualised timeline +// remounts it. Messages mounting together are staggered so a screenful does not pop at once. +const STEP_MS = 300; +const MAX_DELAY_MS = 900; +const SEEN_CAP = 2000; + +const seen = new Set(); +let nextSlot = 0; + +export const claimEntrance = (id: string, now: number): number | undefined => { + if (seen.has(id)) return undefined; + if (seen.size >= SEEN_CAP) seen.clear(); + seen.add(id); + const start = Math.max(nextSlot, now); + nextSlot = Math.min(start + STEP_MS, now + MAX_DELAY_MS); + return Math.min(start - now, MAX_DELAY_MS); +}; + +export const useEntranceDelay = (id: string | undefined): number | undefined => + useState(() => (id ? claimEntrance(id, performance.now()) : undefined))[0]; diff --git a/src/app/components/message/pose/nest.test.ts b/src/app/components/message/pose/nest.test.ts new file mode 100644 index 00000000..d8e6f36e --- /dev/null +++ b/src/app/components/message/pose/nest.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import { alphaBounds, nestView } from './nest'; +import boxes from './safeBoxes.json'; + +describe('nestView', () => { + // Aqua neutral: 560x374, safe box [144,22,492,317] (right margin 68, top 22). + it('crops half the right and top margins for a top-right nest', () => { + expect(nestView([144, 22, 492, 317], [560, 374], true)).toEqual({ + x: 0, + y: 11, + width: 526, + height: 363, + }); + }); + + it('crops the left margin instead for a top-left nest', () => { + expect(nestView([144, 22, 492, 317], [560, 374], false)).toEqual({ + x: 72, + y: 11, + width: 488, + height: 363, + }); + }); + + it('never crops the top of art already touching it (smug, headband at y=0)', () => { + const v = nestView([140, 0, 487, 241], [560, 374], true); + expect(v.y).toBe(0); + expect(v.height).toBe(374); + }); + + it('can take the whole margin', () => { + expect(nestView([10, 4, 90, 50], [100, 60], true, 1)).toEqual({ + x: 0, + y: 4, + width: 90, + height: 56, + }); + }); + + it('keeps every measured safe box intact at the default share', () => { + const aqua = boxes.aqua as Record; + expect(Object.keys(aqua)).toHaveLength(15); + Object.values(aqua).forEach(([x0, y0, x1, y1, w, h]) => { + const right = nestView([x0, y0, x1, y1], [w, h], true); + expect(right.x).toBeLessThanOrEqual(x0); + expect(right.x + right.width).toBeGreaterThanOrEqual(x1); + expect(right.y).toBeLessThanOrEqual(y0); + const left = nestView([x0, y0, x1, y1], [w, h], false); + expect(left.x).toBeLessThanOrEqual(x0); + expect(left.x + left.width).toBe(w); + }); + }); +}); + +describe('alphaBounds', () => { + it('finds the opaque rectangle', () => { + const w = 6; + const h = 5; + const data = new Uint8ClampedArray(w * h * 4); + [ + [2, 1], + [4, 3], + ].forEach(([x, y]) => { + data[(y * w + x) * 4 + 3] = 255; + }); + expect(alphaBounds(data, w, h)).toEqual([2, 1, 5, 4]); + }); + + it('reports nothing for an empty image', () => { + expect(alphaBounds(new Uint8ClampedArray(16), 2, 2)).toBeUndefined(); + }); +}); diff --git a/src/app/components/message/pose/nest.ts b/src/app/components/message/pose/nest.ts new file mode 100644 index 00000000..ffee1490 --- /dev/null +++ b/src/app/components/message/pose/nest.ts @@ -0,0 +1,58 @@ +// A pose nests into the corner of its bubble: the sprite's body sits in the bubble's padding and is +// cut off by the bubble's edge, so nothing hangs outside it. + +/** [x0, y0, x1, y1] in image pixels, origin top-left, x1/y1 exclusive. */ +export type Box = [number, number, number, number]; + +// Share of the room between the safe box and the image edge that the bubble edge may cut away. +// Aqua's measurement says all of it can go; half leaves the outline a little air. +export const CROP_SHARE = 0.5; + +export type NestView = { + /** The visible region of the image, in pixels. */ + x: number; + y: number; + width: number; + height: number; +}; + +// The part of the image that shows, for a sprite in the bubble's top-right (`right`) or top-left +// corner. Only the corner's two sides are cropped: the side towards the bubble's edge and the top. +// The far side and the bottom stay whole; the bubble's bottom edge cuts the bust. +export const nestView = ( + safe: Box, + size: [number, number], + right: boolean, + share = CROP_SHARE +): NestView => { + const [w, h] = size; + const [x0, y0, x1] = safe; + const side = Math.round(share * (right ? w - x1 : x0)); + const top = Math.round(share * y0); + const x = right ? 0 : side; + return { x, y: top, width: w - side, height: h - top }; +}; + +// Bounds of the pixels whose alpha exceeds `min`, as a safe box for art with no measured one. +export const alphaBounds = ( + data: ArrayLike, + w: number, + h: number, + min = 8 +): Box | undefined => { + let x0 = w; + let y0 = h; + let x1 = 0; + let y1 = 0; + for (let y = 0; y < h; y += 1) { + for (let x = 0; x < w; x += 1) { + if (data[(y * w + x) * 4 + 3] > min) { + x0 = Math.min(x0, x); + x1 = Math.max(x1, x + 1); + y0 = Math.min(y0, y); + y1 = y + 1; + } + } + } + return x1 > x0 ? [x0, y0, x1, y1] : undefined; +}; diff --git a/src/app/components/message/pose/poses.test.ts b/src/app/components/message/pose/poses.test.ts new file mode 100644 index 00000000..c6089f62 --- /dev/null +++ b/src/app/components/message/pose/poses.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import catalogue from './catalogue.json'; +import { + mindOfSender, + poseSrc, + resolvePose, +} from './poses'; + +describe('resolvePose', () => { + it('keeps shared and own-signature labels from the catalogue', () => { + expect(resolvePose('aqua', 'amused')).toBe('amused'); + expect(resolvePose('aqua', 'fretful')).toBe('fretful'); + expect(resolvePose('kiko', ' Debug ')).toBe('debug'); + expect(resolvePose('void', 'reading')).toBe('reading'); + }); + it('falls back to neutral for missing, unknown, non-string or foreign-signature labels', () => { + expect(resolvePose('aqua', undefined)).toBe('neutral'); + expect(resolvePose('aqua', 'conduct')).toBe('neutral'); + expect(resolvePose('aqua', 7)).toBe('neutral'); + expect(resolvePose('aqua', 'probe')).toBe('neutral'); + expect(resolvePose('nobody', 'neutral')).toBe('neutral'); + expect(resolvePose('aqua', 'constructor')).toBe('neutral'); + }); + it('knows every label the catalogue lists, and only those', () => { + Object.entries(catalogue.minds).forEach(([mind, entry]) => { + [...Object.keys(entry.shared), ...Object.keys(entry.signatures)].forEach((l) => + expect(resolvePose(mind, l)).toBe(l) + ); + }); + }); +}); + +describe('mindOfSender', () => { + it('matches localpart or first display-name word', () => { + expect(mindOfSender('Eureka')).toBe('eureka'); + expect(mindOfSender('x', 'Libby the Archivist')).toBe('libby'); + expect(mindOfSender('void')).toBe('void'); + }); + it('ignores humans and Object prototype names', () => { + expect(mindOfSender('alice', 'Alice')).toBeUndefined(); + expect(mindOfSender('constructor')).toBeUndefined(); + expect(mindOfSender()).toBeUndefined(); + }); +}); + +describe('poseSrc', () => { + it('builds the asset path from the catalogue entry', () => { + expect(poseSrc('eureka', 'hood-smug', '/app/')).toBe('/app/public/poses/eureka/hood-smug.png'); + expect(poseSrc('eureka', 'nope')).toBe('/public/poses/eureka/neutral.png'); + }); +}); + + + diff --git a/src/app/components/message/pose/poses.ts b/src/app/components/message/pose/poses.ts new file mode 100644 index 00000000..b1f983ea --- /dev/null +++ b/src/app/components/message/pose/poses.ts @@ -0,0 +1,55 @@ +import catalogue from './catalogue.json'; +import safeBoxes from './safeBoxes.json'; +import type { Box } from './nest'; + +// Pose labels travel in the event content under POSE_KEY. The catalogue +// (cast-art poses/catalogue.json, synced by `npm run sync:poses`) is the only +// list of Minds, labels and art paths the client knows. +export const POSE_KEY = 'org.gamecult.pose'; +export const NEUTRAL = 'neutral'; + +type MindEntry = { + shared: Record; + signatures: Record; +}; +const minds = catalogue.minds as Record; +export const MINDS = Object.keys(minds); + +// A sender is a known Mind when its Matrix localpart or the first word of its +// display name is the Mind's name, case-insensitively. +export const mindOfSender = (localpart?: string, displayName?: string): string | undefined => { + const candidates = [localpart, displayName?.split(/\s+/)[0]]; + return candidates + .map((c) => c?.toLowerCase()) + .find((c): c is string => c !== undefined && Object.prototype.hasOwnProperty.call(minds, c)); +}; + +const artPath = (mind: string, label: string): string | undefined => { + const entry = Object.prototype.hasOwnProperty.call(minds, mind) ? minds[mind] : undefined; + if (!entry) return undefined; + const has = (o: Record) => Object.prototype.hasOwnProperty.call(o, label); + if (has(entry.shared)) return entry.shared[label]; + return has(entry.signatures) ? entry.signatures[label] : undefined; +}; + +// Unknown, missing or non-string labels, and another Mind's signature, fall back to neutral. +export const resolvePose = (mind: string, label: unknown): string => { + if (typeof label !== 'string') return NEUTRAL; + const l = label.trim().toLowerCase(); + return artPath(mind, l) ? l : NEUTRAL; +}; + +export const poseSrc = (mind: string, pose: string, base = '/'): string => + `${base}public/poses/${artPath(mind, pose) ?? artPath(mind, NEUTRAL)}`; + +// Safe-crop boxes measured from the art's pixels (Aqua, `aqua-pose-crop-boxes-20261110`): the +// head and headphones plus outline, which the bubble's edge must not cut. [x0, y0, x1, y1, width, +// height] in image pixels. Art with no entry gets its box from the sprite's alpha at load time. +const boxes = safeBoxes as Record>; +export const safeBox = ( + mind: string, + pose: string +): { box: Box; size: [number, number] } | undefined => { + const b = Object.prototype.hasOwnProperty.call(boxes, mind) ? boxes[mind][pose] : undefined; + return b ? { box: [b[0], b[1], b[2], b[3]], size: [b[4], b[5]] } : undefined; +}; diff --git a/src/app/components/message/pose/safeBoxes.json b/src/app/components/message/pose/safeBoxes.json new file mode 100644 index 00000000..eff7011e --- /dev/null +++ b/src/app/components/message/pose/safeBoxes.json @@ -0,0 +1,19 @@ +{ + "aqua": { + "neutral": [144, 22, 492, 317, 560, 374], + "happy": [157, 22, 506, 317, 574, 374], + "amused": [154, 22, 500, 317, 562, 374], + "thinking": [154, 16, 500, 307, 562, 374], + "annoyed": [154, 4, 499, 282, 562, 374], + "surprised": [141, 5, 485, 282, 560, 374], + "concerned": [156, 4, 501, 283, 574, 374], + "fretful": [156, 16, 503, 305, 574, 374], + "weary": [161, 16, 509, 307, 624, 374], + "glare": [160, 5, 508, 282, 624, 374], + "smug": [140, 0, 487, 241, 560, 374], + "sigh": [154, 0, 500, 241, 574, 374], + "charging": [152, 0, 497, 241, 562, 374], + "delighted": [159, 0, 507, 241, 624, 374], + "content": [144, 16, 492, 306, 560, 374] + } +} diff --git a/src/app/components/message/pose/sentiment.test.ts b/src/app/components/message/pose/sentiment.test.ts new file mode 100644 index 00000000..bc4a8c28 --- /dev/null +++ b/src/app/components/message/pose/sentiment.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { classifyPose, messagePose } from './sentiment'; + +describe('classifyPose', () => { + it.each([ + ['annoyed', 'ugh, this again'], + ['surprised', 'wow, no way'], + ['amused', 'hahaha that is funny'], + ['happy', 'thanks, great work'], + ['thinking', 'should we ship it?'], + ['neutral', 'Merged the branch.'], + ['neutral', 'sorry, that failed with an error'], + ])('%s', (label, text) => expect(classifyPose(text)).toBe(label)); + it('is neutral for empty or non-string text', () => { + expect(classifyPose('')).toBe('neutral'); + expect(classifyPose(undefined)).toBe('neutral'); + expect(classifyPose(5)).toBe('neutral'); + }); + it('is deterministic', () => { + expect(classifyPose('thanks?')).toBe(classifyPose('thanks?')); + }); +}); + +describe('messagePose', () => { + it('lets an explicit label win over the text', () => { + expect(messagePose('aqua', { body: 'thanks!', 'org.gamecult.pose': 'annoyed' })).toBe( + 'annoyed' + ); + }); + it('keeps an unknown explicit label neutral instead of classifying', () => { + expect(messagePose('aqua', { body: 'thanks!', 'org.gamecult.pose': 'conduct' })).toBe( + 'neutral' + ); + }); + it('classifies when there is no label, and never outputs a signature label', () => { + expect(messagePose('aqua', { body: 'thanks!' })).toBe('happy'); + expect(messagePose('kiko', { body: 'sorry' })).toBe('neutral'); + expect(messagePose('aqua', {})).toBe('neutral'); + }); +}); diff --git a/src/app/components/message/pose/sentiment.ts b/src/app/components/message/pose/sentiment.ts new file mode 100644 index 00000000..bd685a07 --- /dev/null +++ b/src/app/components/message/pose/sentiment.ts @@ -0,0 +1,35 @@ +import { POSE_KEY, NEUTRAL, resolvePose } from './poses'; + +// catalogue.json beside this file is a vendored copy of /opt/commons/cast-art/poses/catalogue.json +// (npm run sync:poses); nothing else publishes it to the browser. +// Local sentiment -> pose label. A lexicon over the message text, no network, no model: the +// same text always gives the same pose. Rules are tried in order and the first hit wins, so a +// message that says "wow" and "thanks" reads as surprised. Only the shared labels (identical art +// for all eight Minds, catalogue.json) are ever output; signature labels are explicit-only. +const RULES: [string, RegExp][] = [ + ['annoyed', /\b(annoy\w*|ugh+|argh+|frustrat\w*|again\?|why won'?t|stop it)\b/i], + ['surprised', /\b(wow|whoa|woah|omg|no way|unexpected\w*|surpris\w*|wait,? what)\b|[?!]{2,}/i], + [ + 'determined', + /\b(let'?s (go|do|fix|ship)|on it|i'?ll (fix|do|handle)|will do|fixing|resolved?)\b/i, + ], + ['amused', /\b(ha(ha)+|lol|lmao|rofl|funny|hilarious)\b|😂|🤣/i], + [ + 'happy', + /\b(thanks?|thank you|thx|great|awesome|nice|love|perfect|excellent|yay|congrats)\b|🎉|😊/i, + ], + ['thinking', /\?\s*$|\b(hmm+|maybe|perhaps|i wonder|not sure|how (do|does|about)|what if)\b/i], +]; + +export const classifyPose = (text: unknown): string => { + if (typeof text !== 'string') return NEUTRAL; + const hit = RULES.find(([, re]) => re.test(text)); + return hit ? hit[0] : NEUTRAL; +}; + +// An explicit org.gamecult.pose label always wins, even when it is unknown (then neutral). +// Only events with no label are read for sentiment. +export const messagePose = (mind: string, content: Record): string => + content[POSE_KEY] !== undefined + ? resolvePose(mind, content[POSE_KEY]) + : resolvePose(mind, classifyPose(content.body)); diff --git a/src/app/components/message/pose/silhouette.test.ts b/src/app/components/message/pose/silhouette.test.ts new file mode 100644 index 00000000..ba9d5ecb --- /dev/null +++ b/src/app/components/message/pose/silhouette.test.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { silhouette } from './silhouette'; + +// A w x h image whose opaque pixels are the given columns [from, to) on each row. +const fakeImage = (w: number, h: number, solid: (y: number) => [number, number] | null) => { + const data = new Uint8ClampedArray(w * h * 4); + for (let y = 0; y < h; y += 1) { + const span = solid(y); + if (span) for (let x = span[0]; x < span[1]; x += 1) data[(y * w + x) * 4 + 3] = 255; + } + const canvas = { + width: 0, + height: 0, + getContext: () => ({ canvas, drawImage: vi.fn(), getImageData: () => ({ data }) }), + }; + vi.stubGlobal('document', { createElement: () => canvas }); + return { clientWidth: w, clientHeight: h } as HTMLImageElement; +}; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('silhouette', () => { + // Art in columns 20..60 from row 8 down; rows 0..7 are empty. + const art = (y: number): [number, number] | null => (y < 8 ? null : [20, 60]); + + it('hugs the right edge of the art when the text sits to its right', () => { + const poly = silhouette(fakeImage(100, 16, art), true); + expect(poly).toBe( + 'polygon(0px 0, 0px 0px, 0px 4px, 60px 8px, 60px 12px, 60px 16px, 0px 16px) border-box' + ); + }); + + it('hugs the left edge of the art when the text sits to its left', () => { + const poly = silhouette(fakeImage(100, 16, art), false); + expect(poly).toBe( + 'polygon(100px 0, 100px 0px, 100px 4px, 20px 8px, 20px 12px, 20px 16px, 100px 16px) border-box' + ); + }); + + it('takes the widest row of a band, so no row of the art pokes out of the polygon', () => { + // Row 5 reaches x=90; its band's edge row (y=8 covers rows 4..8) must include it. + const poly = silhouette( + fakeImage(100, 16, (y) => (y === 5 ? [20, 90] : null)), + true + ); + expect(poly).toContain('90px 8px'); + }); + + it('has no shape before the image has a size or where there is no canvas', () => { + expect( + silhouette({ clientWidth: 0, clientHeight: 0 } as HTMLImageElement, true) + ).toBeUndefined(); + }); + + it('traces only the visible part of a cropped portrait, drawn at its offset', () => { + const drawImage = vi.fn(); + const canvas = { + width: 0, + height: 0, + getContext: () => ({ + canvas, + drawImage, + getImageData: () => ({ data: new Uint8ClampedArray(40 * 8 * 4) }), + }), + }; + vi.stubGlobal('document', { createElement: () => canvas }); + const img = { clientWidth: 100, clientHeight: 20 } as HTMLImageElement; + silhouette(img, true, { x: 0, y: 12, width: 40, height: 8 }); + expect([canvas.width, canvas.height]).toEqual([40, 8]); + expect(drawImage).toHaveBeenCalledWith(img, 0, -12, 100, 20); + }); +}); diff --git a/src/app/components/message/pose/silhouette.ts b/src/app/components/message/pose/silhouette.ts new file mode 100644 index 00000000..019c0716 --- /dev/null +++ b/src/app/components/message/pose/silhouette.ts @@ -0,0 +1,43 @@ +const SOLID = 0.35 * 255; +const ROW = 4; + +// The edge of the portrait that the text wraps against, as a polygon in border-box pixels. +// `shape-outside: url()` cannot do this: the browser sizes that image at its natural size and +// anchors it at the margin box, which the phone layout's negative top margin shifts down by the +// overhang, so the exclusion neither matches the art's size nor its place. +// +// `view` is the part of the displayed image that shows (the button's box, in displayed pixels): +// the portrait is cropped by the bubble, so the polygon is traced over what is left of the art. +export type View = { x: number; y: number; width: number; height: number }; + +export const silhouette = ( + img: HTMLImageElement, + textOnRight: boolean, + view?: View +): string | undefined => { + const w = Math.round(view?.width ?? img.clientWidth); + const h = Math.round(view?.height ?? img.clientHeight); + const ctx = w > 0 && h > 0 ? document.createElement('canvas').getContext('2d') : null; + if (!ctx) return undefined; + ctx.canvas.width = w; + ctx.canvas.height = h; + ctx.drawImage(img, 0 - (view?.x ?? 0), 0 - (view?.y ?? 0), img.clientWidth, img.clientHeight); + const { data } = ctx.getImageData(0, 0, w, h); + const outer = textOnRight ? 0 : w; + const edge: string[] = []; + for (let y = 0; y <= h; y += ROW) { + let x = outer; + // A band's edge is the extreme solid pixel over its rows, so no row pokes out of the polygon. + for (let r = Math.max(0, y - ROW); r < Math.min(h, y + 1); r += 1) { + for (let i = 0; i < w; i += 1) { + const px = textOnRight ? w - 1 - i : i; + if (data[(r * w + px) * 4 + 3] > SOLID) { + x = textOnRight ? Math.max(x, px + 1) : Math.min(x, px); + break; + } + } + } + edge.push(`${x}px ${y}px`); + } + return `polygon(${outer}px 0, ${edge.join(', ')}, ${outer}px ${h}px) border-box`; +}; diff --git a/src/app/components/nav/NavGutter.tsx b/src/app/components/nav/NavGutter.tsx new file mode 100644 index 00000000..c0a6e50e --- /dev/null +++ b/src/app/components/nav/NavGutter.tsx @@ -0,0 +1,12 @@ +import React, { ReactNode } from 'react'; +import * as css from './styles.css'; + +/** The fixed-width first column of a navigator row or section header; its content is centred in it. */ +export function NavGutter({ children }: { children: ReactNode }) { + return {children}; +} + +/** One nesting step: everything inside is indented by the same constant. */ +export function NavLevel({ children }: { children: ReactNode }) { + return
{children}
; +} diff --git a/src/app/components/nav/index.ts b/src/app/components/nav/index.ts index 12267446..2ed29601 100644 --- a/src/app/components/nav/index.ts +++ b/src/app/components/nav/index.ts @@ -4,3 +4,4 @@ export * from './NavEmptyLayout'; export * from './NavItem'; export * from './NavItemContent'; export * from './NavItemOptions'; +export * from './NavGutter'; diff --git a/src/app/components/nav/styles.css.ts b/src/app/components/nav/styles.css.ts index 48b2a4d3..5156ed41 100644 --- a/src/app/components/nav/styles.css.ts +++ b/src/app/components/nav/styles.css.ts @@ -1,6 +1,42 @@ -import { ComplexStyleRule, createVar, style } from '@vanilla-extract/css'; +import { ComplexStyleRule, StyleRule, createVar, globalStyle, style } from '@vanilla-extract/css'; import { RecipeVariants, recipe } from '@vanilla-extract/recipes'; import { ContainerColor, DefaultReset, Disabled, RadiiVariant, color, config, toRem } from 'folds'; +import { surfaceInk } from '../../features/scene/sceneShell.css'; +import { skyLabelInk } from '../../features/scene/skyLabelInk'; + +// Wash strengths over the scene: rows and labels are faint, and hover, selected and pressed rows +// are three distinct steps above them. +// Meta, 2026-11-09: "this UI needs no chrome". Rows and labels are bare ink on the scene; hover and selection keep a faint wash. +const SCRIM_LABEL = '0%'; +const SCRIM_ROW = '0%'; +const SCRIM_HOVER = '14%'; +const SCRIM_SELECTED = '22%'; +const SCRIM_PRESSED = '48%'; + +// The navigator's one column grid. Every row, section header and join row begins with a gutter of +// the same width, with its chevron, room icon or avatar centred in it, so every label starts at +// the same x for its depth; each nesting level (NavLevel) indents by one constant step. +const GUTTER = toRem(32); +const LEVEL_STEP = toRem(16); + +export const NavGutter = style({ + width: GUTTER, + minWidth: GUTTER, + flexShrink: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', +}); + +export const NavLevel = style({ + paddingLeft: LEVEL_STEP, +}); + +// Chevrons and room icons are matched in measured ink (a room glyph is about 1.7x a chevron's; unread rooms stay far above), so a foldout is not outweighed by its rooms. +export const NAV_ICON_REST_OPACITY = 0.9; +// A room's glyph is bigger and denser than the chevron, so it rests lighter to read as the same weight. +export const NAV_ROOM_ICON_REST_OPACITY = 0.26; +export const NAV_ICON_UNREAD_OPACITY = 0.6; export const NavCategory = style([ DefaultReset, @@ -11,6 +47,27 @@ export const NavCategory = style([ export const NavCategoryHeader = style({ gap: config.space.S100, + selectors: { + // The header's own side padding would shift its gutter off the rows': the section button + // carries the same left inset as NavItemContent instead. + '&&': { paddingLeft: 0 }, + }, + '@media': { + // A phone's section header is as tall as its 44 px button, so it does not overlap the next row. + 'screen and (max-width: 750px)': { + selectors: { '&&': { minHeight: toRem(44), height: toRem(44) } }, + }, + // Category labels sit on the scene like the rows do, and need a wash to be read (wide screens; + // a phone's list is plain text). + 'screen and (min-width: 751px)': { + selectors: { + ':root[data-scene] &': { + backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_LABEL}, transparent)`, + borderRadius: config.radii.R400, + }, + }, + }, + }, }); export const NavLink = style({ @@ -44,6 +101,12 @@ const getVariant = (variant: ContainerColor): ComplexStyleRule => ({ }, }); +// The room list is plain text on a phone: no pill, slab or border behind any row, and hover, +// selected and pressed show in the label (NavLabel), never in a box. Wider screens keep the rows +// and the wash. +const WIDE = 'screen and (min-width: 751px)'; +const PHONE = 'screen and (max-width: 750px)'; + const NavItemBase = style({ width: '100%', display: 'flex', @@ -54,19 +117,47 @@ const NavItemBase = style({ outline: 'none', minHeight: toRem(36), + '@media': { + [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: { + '&:hover, &:focus-visible': { + backgroundColor: ContainerHover, + }, + '&[data-hover=true]': { + backgroundColor: ContainerHover, + }, + // While the background scene runs the shell is clear (sceneShell.css.ts); a row is a faint + // wash of the theme's own surface: the terrain stays visible, the text stays readable. + // Hover, the selected row and a press each strengthen it a step. + ':root[data-scene] &': { + backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_ROW}, transparent)`, + }, + ':root[data-scene] &:hover, :root[data-scene] &:focus-visible, :root[data-scene] &[data-hover=true]': + { + backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_HOVER}, transparent)`, + }, + ':root[data-scene] &[aria-selected=true]': { + backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_SELECTED}, transparent)`, + }, + [`:root[data-scene] &:has(.${NavLink}:active)`]: { + backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_PRESSED}, transparent)`, + }, + [`&:has(.${NavLink}:active)`]: { + backgroundColor: ContainerActive, + }, + '&[aria-selected=true]': { + backgroundColor: ContainerActive, + }, + }, + }, + }, selectors: { - '&:hover, &:focus-visible': { - backgroundColor: ContainerHover, - }, - '&[data-hover=true]': { - backgroundColor: ContainerHover, - }, - [`&:has(.${NavLink}:active)`]: { - backgroundColor: ContainerActive, - }, - '&[aria-selected=true]': { - backgroundColor: ContainerActive, - }, [`&:has(.${NavLink}:focus-visible)`]: { outline: `${config.borderWidth.B600} solid ${ContainerLine}`, outlineOffset: `calc(-1 * ${config.borderWidth.B600})`, @@ -126,3 +217,73 @@ export const NavItemContent = style({ export const NavItemOptions = style({ paddingRight: config.space.S200, }); + +// A room's name on a phone wears the section headings' type (small caps-height, uppercase, 600, +// at heading strength) and states itself in the text: hover and focus underline it, the selected +// row is bolder and underlined, a press dims it, an unread room is at full strength. Over the scene +// a halo of the theme's own surface keeps it readable without a box. +export const NavLabel = style({}); +const label = `.${NavLabel}.${NavLabel}`; +const phoneLabel = (selector: string, rule: StyleRule) => + globalStyle(selector, { '@media': { [PHONE]: rule } }); + +phoneLabel(label, { + fontSize: toRem(12), + lineHeight: toRem(16), + fontWeight: config.fontWeight.W600, + textTransform: 'uppercase', + opacity: 0.75, + textDecorationThickness: '1px', + textUnderlineOffset: toRem(4), +}); +phoneLabel( + [ + `.${NavItemBase}:hover ${label}`, + `.${NavItemBase}:focus-visible ${label}`, + `.${NavItemBase}[data-hover=true] ${label}`, + `.${NavItemBase}[data-highlight=true] ${label}`, + ].join(', '), + { opacity: 1 } +); +phoneLabel(`.${NavItemBase}:hover ${label}, .${NavItemBase}:focus-visible ${label}`, { + textDecorationLine: 'underline', +}); +phoneLabel(`.${NavItemBase}[aria-selected=true] ${label}`, { + opacity: 1, + fontWeight: config.fontWeight.W700, + textDecorationLine: 'underline', + textDecorationThickness: '2px', +}); +phoneLabel(`.${NavItemBase}:has(.${NavLink}:active) ${label}`, { + opacity: 0.6, + textDecorationLine: 'underline', +}); +phoneLabel(`:root[data-scene] ${label}`, { + color: skyLabelInk('inherit'), + textShadow: `0 0 3px ${surfaceInk}, 0 0 8px ${surfaceInk}`, +}); + +// Icons, dots and the Join action share the label's halo over the scene. +globalStyle(`:root[data-scene] ${NavItemBase} svg, :root[data-scene] ${NavItemBase} button`, { + '@media': { + [PHONE]: { + filter: `drop-shadow(0 0 3px ${surfaceInk})`, + }, + }, +}); + +// The Join action is a plain underlined word on every screen, not a pill. +export const NavAction = style({ + selectors: { + '&&': { + backgroundColor: 'transparent', + boxShadow: 'none', + border: 'none', + borderRadius: 0, + minWidth: 0, + textDecoration: 'underline', + textUnderlineOffset: toRem(4), + flexShrink: 0, + }, + }, +}); diff --git a/src/app/components/password-input/PasswordInput.tsx b/src/app/components/password-input/PasswordInput.tsx index 4ffe0d83..6aa0d4f3 100644 --- a/src/app/components/password-input/PasswordInput.tsx +++ b/src/app/components/password-input/PasswordInput.tsx @@ -1,6 +1,7 @@ import React, { ComponentProps, forwardRef } from 'react'; import { Icon, IconButton, Input, config, Icons } from 'folds'; import { UseStateProvider } from '../UseStateProvider'; +import { PhoneHit } from '../../styles/PhoneHit.css'; type PasswordInputProps = Omit, 'type' | 'size'> & { size: '400' | '500'; @@ -23,6 +24,7 @@ export const PasswordInput = forwardRef( <> {after} setVisible(!visible)} type="button" variant={visible ? 'Warning' : variant} diff --git a/src/app/components/room-avatar/RoomAvatar.tsx b/src/app/components/room-avatar/RoomAvatar.tsx index cbcd626a..9015796e 100644 --- a/src/app/components/room-avatar/RoomAvatar.tsx +++ b/src/app/components/room-avatar/RoomAvatar.tsx @@ -1,9 +1,10 @@ import { JoinRule } from 'matrix-js-sdk'; -import { AvatarFallback, AvatarImage, Icon, Icons, color } from 'folds'; +import { AvatarFallback, AvatarImage, Icon, Icons } from 'folds'; import React, { ComponentProps, ReactEventHandler, ReactNode, forwardRef, useState } from 'react'; import * as css from './RoomAvatar.css'; import { getRoomIconSrc } from '../../utils/room'; import colorMXID from '../../../util/colorMXID'; +import { surfaceInk } from '../../features/scene/sceneShell.css'; type RoomAvatarProps = { roomId: string; @@ -21,7 +22,7 @@ export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps if (!src || error) { return ( {renderFallback()} diff --git a/src/app/components/room-card/RoomCard.tsx b/src/app/components/room-card/RoomCard.tsx index 34a7e24b..64aaaacb 100644 --- a/src/app/components/room-card/RoomCard.tsx +++ b/src/app/components/room-card/RoomCard.tsx @@ -1,5 +1,5 @@ import React, { ReactNode, useCallback, useRef, useState } from 'react'; -import { MatrixError, Room } from 'matrix-js-sdk'; +import { MatrixError } from 'matrix-js-sdk'; import { Avatar, Badge, @@ -31,6 +31,7 @@ import { RoomType, StateEvent } from '../../../types/matrix/room'; import { useJoinedRoomId } from '../../hooks/useJoinedRoomId'; import { useElementSizeObserver } from '../../hooks/useElementSizeObserver'; import { getRoomAvatarUrl, getStateEvent } from '../../utils/room'; +import { joinEstateRoomOrThrow } from '../../features/estate/joinEstateRoom'; import { useStateEventCallback } from '../../hooks/useStateEventCallback'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; @@ -197,8 +198,10 @@ export const RoomCard = as<'div', RoomCardProps>( ) ); - const [joinState, join] = useAsyncCallback( - useCallback(() => mx.joinRoom(roomIdOrAlias, { viaServers }), [mx, roomIdOrAlias, viaServers]) + const [joinState, join] = useAsyncCallback( + useCallback(async () => { + await joinEstateRoomOrThrow(mx, roomIdOrAlias, viaServers); + }, [mx, roomIdOrAlias, viaServers]) ); const joining = joinState.status === AsyncStatus.Loading || joinState.status === AsyncStatus.Success; diff --git a/src/app/components/room-intro/RoomIntro.tsx b/src/app/components/room-intro/RoomIntro.tsx index ce550992..0c6b10df 100644 --- a/src/app/components/room-intro/RoomIntro.tsx +++ b/src/app/components/room-intro/RoomIntro.tsx @@ -1,7 +1,9 @@ +import classNames from 'classnames'; import React, { useCallback, useState } from 'react'; import { Avatar, Box, Button, Spinner, Text, as } from 'folds'; import { Room } from 'matrix-js-sdk'; import { useAtomValue } from 'jotai'; +import { SkyPlate } from '../../features/scene/skyPlate.css'; import { IRoomCreateContent, Membership, StateEvent } from '../../../types/matrix/room'; import { getMemberDisplayName, getStateEvent } from '../../utils/room'; import { useMatrixClient } from '../../hooks/useMatrixClient'; @@ -17,12 +19,13 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; import { InviteUserPrompt } from '../invite-user-prompt'; +import { joinEstateRoomOrThrow } from '../../features/estate/joinEstateRoom'; export type RoomIntroProps = { room: Room; }; -export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) => { +export const RoomIntro = as<'div', RoomIntroProps>(({ room, className, ...props }, ref) => { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const { navigateRoom } = useRoomNavigate(); @@ -43,13 +46,13 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) => const prevRoomId = createContent?.predecessor?.room_id; const [prevRoomState, joinPrevRoom] = useAsyncCallback( - useCallback(async (roomId: string) => mx.joinRoom(roomId), [mx]) + useCallback((roomId: string) => joinEstateRoomOrThrow(mx, roomId), [mx]) ); const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); return ( - + - + diff --git a/src/app/components/splash-screen/SplashScreen.tsx b/src/app/components/splash-screen/SplashScreen.tsx index 27adadba..8e8f05b6 100644 --- a/src/app/components/splash-screen/SplashScreen.tsx +++ b/src/app/components/splash-screen/SplashScreen.tsx @@ -21,7 +21,7 @@ export function SplashScreen({ children }: SplashScreenProps) { justifyContent="Center" > - Cinny + Ratatoskr diff --git a/src/app/components/text-viewer/TextViewer.tsx b/src/app/components/text-viewer/TextViewer.tsx index ec4ed0a5..e9e150b5 100644 --- a/src/app/components/text-viewer/TextViewer.tsx +++ b/src/app/components/text-viewer/TextViewer.tsx @@ -5,6 +5,7 @@ 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')); @@ -53,7 +54,7 @@ export const TextViewer = as<'div', TextViewerProps>( >
- + diff --git a/src/app/components/uia-stages/PasswordStage.tsx b/src/app/components/uia-stages/PasswordStage.tsx index 0164cae6..a7ca2d06 100644 --- a/src/app/components/uia-stages/PasswordStage.tsx +++ b/src/app/components/uia-stages/PasswordStage.tsx @@ -4,6 +4,7 @@ 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, @@ -45,7 +46,7 @@ export function PasswordStage({ Account Password - +
diff --git a/src/app/components/uia-stages/SSOStage.tsx b/src/app/components/uia-stages/SSOStage.tsx index e3d6126a..20decb6c 100644 --- a/src/app/components/uia-stages/SSOStage.tsx +++ b/src/app/components/uia-stages/SSOStage.tsx @@ -1,6 +1,7 @@ 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, @@ -56,7 +57,7 @@ export function SSOStage({ SSO Login - + diff --git a/src/app/components/unread-badge/UnreadBadge.tsx b/src/app/components/unread-badge/UnreadBadge.tsx index f8bfefa3..00c7fa2f 100644 --- a/src/app/components/unread-badge/UnreadBadge.tsx +++ b/src/app/components/unread-badge/UnreadBadge.tsx @@ -22,7 +22,9 @@ export function UnreadBadge({ highlight, count }: UnreadBadgeProps) { 0 ? '400' : '200'} - fill="Solid" + // A mention and the bare unread dot are solid (a soft dot measured 1.6:1 against the ground); a plain unread + // count is a soft tint, so a row's count is never its loudest part. + fill={highlight || count === 0 ? 'Solid' : 'Soft'} radii="Pill" outlined={false} > diff --git a/src/app/components/upload-card/CompactUploadCardRenderer.tsx b/src/app/components/upload-card/CompactUploadCardRenderer.tsx index b9bada71..5fe73a10 100644 --- a/src/app/components/upload-card/CompactUploadCardRenderer.tsx +++ b/src/app/components/upload-card/CompactUploadCardRenderer.tsx @@ -6,6 +6,8 @@ import { useMatrixClient } from '../../hooks/useMatrixClient'; import { TUploadContent } from '../../utils/matrix'; import { bytesToSize, getFileTypeIcon } from '../../utils/common'; import { useMediaConfig } from '../../hooks/useMediaConfig'; +import { UploadFailureNote } from './UploadFailureNote'; +import { PhoneHitSpread28 } from '../../styles/PhoneHit.css'; type CompactUploadCardRendererProps = { isEncrypted?: boolean; @@ -63,6 +65,7 @@ export function CompactUploadCardRenderer({ )} - {upload.error.message} + )} {upload.status === UploadStatus.Idle && fileSizeExceeded && ( diff --git a/src/app/components/upload-card/UploadCardRenderer.tsx b/src/app/components/upload-card/UploadCardRenderer.tsx index f5bc68e0..e1d12843 100644 --- a/src/app/components/upload-card/UploadCardRenderer.tsx +++ b/src/app/components/upload-card/UploadCardRenderer.tsx @@ -12,6 +12,8 @@ import { } from '../../state/room/roomInputDrafts'; import { useObjectURL } from '../../hooks/useObjectURL'; import { useMediaConfig } from '../../hooks/useMediaConfig'; +import { UploadFailureNote } from './UploadFailureNote'; +import { PhoneHitSpread28 } from '../../styles/PhoneHit.css'; type PreviewImageProps = { fileItem: TUploadItem; @@ -160,6 +162,7 @@ export function UploadCardRenderer({ )} - {upload.error.message} + )} {upload.status === UploadStatus.Idle && fileSizeExceeded && ( diff --git a/src/app/components/upload-card/UploadFailureNote.test.tsx b/src/app/components/upload-card/UploadFailureNote.test.tsx new file mode 100644 index 00000000..c2f917ec --- /dev/null +++ b/src/app/components/upload-card/UploadFailureNote.test.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; +import { uploadFailure } from '../../utils/matrix'; +import { UploadFailureNote } from './UploadFailureNote'; + +const text = (html: string) => + html + .replace(/<[^>]*>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + +describe('the failed upload card', () => { + it('leads with the reason and the next step, and keeps the diagnostics behind Details', async () => { + const error = await uploadFailure(new DOMException('', 'AbortError'), new Blob(['abcd']), { + startedAt: Date.now(), + }); + const html = renderToStaticMarkup(); + const before = html.slice(0, html.indexOf(']*>.*Details.*<\/details>/s); + expect(text(html.slice(html.indexOf(' { + const html = renderToStaticMarkup(); + expect(text(html)).toContain('Upload failed. Send again'); + expect(text(html)).toContain('boom'); + }); +}); diff --git a/src/app/components/upload-card/UploadFailureNote.tsx b/src/app/components/upload-card/UploadFailureNote.tsx new file mode 100644 index 00000000..df4498ba --- /dev/null +++ b/src/app/components/upload-card/UploadFailureNote.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { Text } from 'folds'; +import { uploadFailureText } from '../../utils/uploadReason'; + +/** A failed upload as the card shows it: the reason and what to do, the diagnostic text one tap away. */ +export function UploadFailureNote({ error }: { error: Error }) { + return ( + <> + {uploadFailureText(error)} +
+ + + Details + + + + {error.message} + +
+ + ); +} diff --git a/src/app/components/url-preview/UrlPreviewCard.tsx b/src/app/components/url-preview/UrlPreviewCard.tsx index ee8967af..5d40e10c 100644 --- a/src/app/components/url-preview/UrlPreviewCard.tsx +++ b/src/app/components/url-preview/UrlPreviewCard.tsx @@ -1,3 +1,4 @@ +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'; @@ -10,6 +11,7 @@ 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'; @@ -181,7 +183,10 @@ export const UrlPreviewHolder = as<'div'>(({ children, ...props }, ref) => { <>
(({ children, ...props }, ref) => { <>
{renderFallback()} diff --git a/src/app/components/user-profile/PowerChip.tsx b/src/app/components/user-profile/PowerChip.tsx index a6758300..d3e9cf28 100644 --- a/src/app/components/user-profile/PowerChip.tsx +++ b/src/app/components/user-profile/PowerChip.tsx @@ -43,6 +43,7 @@ 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; @@ -70,7 +71,7 @@ function SelfDemoteAlert({ power, onCancel, onChange }: SelfDemoteAlertProps) { Self Demotion - + @@ -120,7 +121,7 @@ function SharedPowerAlert({ power, onCancel, onChange }: SharedPowerAlertProps) Shared Power - + diff --git a/src/app/features/add-existing/AddExisting.tsx b/src/app/features/add-existing/AddExisting.tsx index d100096b..a57a6b10 100644 --- a/src/app/features/add-existing/AddExisting.tsx +++ b/src/app/features/add-existing/AddExisting.tsx @@ -54,6 +54,7 @@ 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, @@ -201,7 +202,7 @@ export function AddExistingModal({ parentId, space, requestClose }: AddExistingM Add Existing - + diff --git a/src/app/features/albums/AlbumView.css.ts b/src/app/features/albums/AlbumView.css.ts new file mode 100644 index 00000000..d60dba43 --- /dev/null +++ b/src/app/features/albums/AlbumView.css.ts @@ -0,0 +1,71 @@ +import { style } from '@vanilla-extract/css'; +import { color, config, toRem } from 'folds'; +import { MOBILE_BREAKPOINT } from '../../hooks/useScreenSize'; + +const WIDE = `screen and (min-width: ${MOBILE_BREAKPOINT + 1}px)`; + +/** One column, bare ink on the scene. */ +export const Column = style({ + maxWidth: toRem(720), + width: '100%', + margin: '0 auto', + padding: config.space.S400, + display: 'flex', + flexDirection: 'column', + gap: config.space.S200, +}); + +/** A tappable text row: no border, no fill, at least 44 px tall. */ +export const Row = style({ + all: 'unset', + boxSizing: 'border-box', + cursor: 'pointer', + minHeight: 44, + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + gap: toRem(2), + color: color.Background.OnContainer, + ':focus-visible': { textDecoration: 'underline' }, +}); + +export const Back = style([Row, { minHeight: 44 }]); + +export const Files = style({ + display: 'flex', + flexDirection: 'column', + gap: config.space.S400, +}); + +/** Images may tile into a grid on wide screens only. */ +export const FilesWide = style({ + '@media': { + [WIDE]: { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', + alignItems: 'start', + }, + }, +}); + +export const Dim = style({ opacity: config.opacity.P300 }); + +/** The share panel: idle it is one row; the title, names and Send mount only once files are picked. */ +export const SharePanel = style({ + display: 'flex', + flexDirection: 'column', + gap: config.space.S200, +}); + +export const ShareTitle = style({ + all: 'unset', + boxSizing: 'border-box', + minHeight: 44, + width: '100%', + borderBottom: `1px solid ${color.Background.ContainerLine}`, + color: color.Background.OnContainer, + ':focus-visible': { borderBottomColor: color.Background.OnContainer }, +}); + +/** Picked names and send notes wrap, even a long unbroken name. */ +export const ShareNames = style({ overflowWrap: 'anywhere' }); diff --git a/src/app/features/albums/AlbumView.tsx b/src/app/features/albums/AlbumView.tsx new file mode 100644 index 00000000..f1a7f01a --- /dev/null +++ b/src/app/features/albums/AlbumView.tsx @@ -0,0 +1,267 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Box, Scroll, Text } from 'folds'; +import { Room, RoomEvent } from 'matrix-js-sdk'; +import { MediaControl } from '../../components/media'; +import { AudioContent } from '../../components/message'; +import { RenderMessageContent } from '../../components/RenderMessageContent'; +import { IAudioContent } from '../../../types/matrix/common'; +import { LINKIFY_OPTS, getReactCustomHtmlParser } from '../../plugins/react-custom-html-parser'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { useSetting } from '../../state/hooks/settings'; +import { settingsAtom } from '../../state/settings'; +import { markAsRead } from '../../utils/notifications'; +import { getBlobSafeMimeType } from '../../utils/mimeTypes'; +import { timeDayMonYear } from '../../utils/time'; +import { getMemberDisplayName } from '../../utils/room'; +import { + Album, + AlbumFile, + TimelineEvent, + lastEvent, + looseFiles, + looseNoteFor, +} from './buildAlbums'; +import { folderOf, listFolder, pathLabel } from './albumFolders'; +import { FileReadBy } from './FileReadBy'; +import { ShareSet, useShareEnabled } from './ShareSet'; +import { useAlbums } from './useAlbums'; +import * as css from './AlbumView.css'; + +/** + * Audio goes straight to AudioContent, skipping the chat Attachment frame and file-type badge: the + * title above it is the only label and the transport controls sit on the scene. Anything that is + * not playable audio falls through to the stock renderer (a file download). + */ +const playableAudio = (content: Record) => { + const c = content as IAudioContent; + const url = c.file?.url ?? c.url; + const mimeType = getBlobSafeMimeType(c.info?.mimetype ?? ''); + if (!c.info || typeof url !== 'string' || !mimeType.startsWith('audio')) return undefined; + return { info: c.info, url, mimeType, encInfo: c.file }; +}; + +type FileRowProps = { room: Room; file: AlbumFile; author: string }; +function FileRow({ room, file, author }: FileRowProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); + const htmlReactParserOptions = useMemo( + () => + getReactCustomHtmlParser(mx, room.roomId, { linkifyOpts: LINKIFY_OPTS, useAuthentication }), + [mx, room.roomId, useAuthentication] + ); + const audio = file.kind === 'audio' ? playableAudio(file.content) : undefined; + return ( + + + {file.name} + + + {audio ? ( + } /> + ) : ( + () => file.content as T} + mediaAutoLoad={mediaAutoLoad} + htmlReactParserOptions={htmlReactParserOptions} + linkifyOpts={LINKIFY_OPTS} + /> + )} + + ); +} + +/** A file, and under it a row that unfolds the versions it supersedes. */ +function VersionedFileRow({ room, file, author }: FileRowProps) { + const [shown, setShown] = useState(false); + const n = file.earlier.length; + return ( + <> + + {n > 0 && ( + + )} + {shown && + file.earlier.map((e) => )} + + ); +} + +const countLabel = (n: number) => `${n} ${n === 1 ? 'file' : 'files'}`; + +/** The zone never mounts RoomTimeline, so it sends the read receipt the timeline would. */ +function useMarkZoneRead(room: Room) { + const mx = useMatrixClient(); + const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); + useEffect(() => { + const mark = () => { + markAsRead(mx, room.roomId, hideActivity).catch(() => undefined); + }; + mark(); + room.on(RoomEvent.Timeline, mark); + return () => { + room.off(RoomEvent.Timeline, mark); + }; + }, [mx, room, hideActivity]); +} + +/** The file events in the room's live timeline that no album thread holds, kept current as it syncs. */ +function useLooseFiles(room: Room): AlbumFile[] { + const [, bump] = useState(0); + useEffect(() => { + const on = () => bump((n) => n + 1); + room.on(RoomEvent.Timeline, on); + room.on(RoomEvent.Redaction, on); + return () => { + room.off(RoomEvent.Timeline, on); + room.off(RoomEvent.Redaction, on); + }; + }, [room]); + const events: TimelineEvent[] = room + .getLiveTimeline() + .getEvents() + .map((e) => ({ + event_id: e.getId(), + type: e.getType(), + sender: e.getSender(), + origin_server_ts: e.getTs(), + content: e.getContent(), + unsigned: e.getUnsigned() as Record, + threadRoot: e.threadRootId, + })); + return looseFiles(events); +} + +type AlbumViewProps = { room: Room; onTimeline: () => void }; +export function AlbumView({ room, onTimeline }: AlbumViewProps) { + useMarkZoneRead(room); + const { loading, error, albums, truncated } = useAlbums(room); + const [dateFormat] = useSetting(settingsAtom, 'dateFormatString'); + const [openId, setOpenId] = useState(); + const [path, setPath] = useState([]); + const open: Album | undefined = albums.find((a) => a.id === openId); + const authorOf = (a: Album) => getMemberDisplayName(room, a.author) ?? a.author; + const encrypted = room.hasEncryptionStateEvent(); + const canShare = useShareEnabled(room); + const loose = useLooseFiles(room); + const looseNote = looseNoteFor(loose, { + open: !!open, + encrypted, + loading, + error: !!error, + }); + + let body: React.ReactNode; + if (loading) body = Loading albums…; + else if (error) body = Could not load albums: {error.message}; + else if (open) { + const images = open.files.length > 0 && open.files.every((f) => f.kind === 'image'); + body = ( + <> + + {open.title} + + {authorOf(open)} · {countLabel(open.count)} · {timeDayMonYear(open.ts, dateFormat)} + + {open.count === 0 && This album has no files.} + {canShare && ( + + )} +
+ {open.files.map((f) => ( + + ))} +
+ + ); + } else if (encrypted) + body = This zone is encrypted, so its albums cannot be listed here.; + else if (albums.length === 0 && path.length === 0) body = No albums yet.; + else { + const here = listFolder(albums, path); + body = ( + <> + {path.length > 0 && ( + + )} + {path.length > 0 && {pathLabel(path)}} + {here.folders.map((f) => ( + + ))} + {here.albums.map(({ album: a, leaf }) => ( + + ))} + {here.folders.length === 0 && here.albums.length === 0 && Nothing here.} + {truncated && ( + + The list is cut short: some albums or files are not loaded. + + )} + + ); + } + + return ( + +
+ {body} + {looseNote && ( + + {looseNote} + + )} + {/* Stays mounted while an album is open, so a running send keeps its progress. */} + {canShare && ( + + )} + +
+
+ ); +} diff --git a/src/app/features/albums/FileReadBy.tsx b/src/app/features/albums/FileReadBy.tsx new file mode 100644 index 00000000..28f9205c --- /dev/null +++ b/src/app/features/albums/FileReadBy.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { MatrixEvent, Room } from 'matrix-js-sdk'; +import { Text } from 'folds'; +import { getMemberDisplayName } from '../../utils/room'; +import { FileReader, readLine } from './fileReceipt'; +import { useFileReaders } from './useFileReaders'; +import * as css from './AlbumView.css'; + +type FileReadByProps = { + room: Room; + fileId: string; + content: Record; + /** Receipts already read elsewhere (an album's thread replies): merged with the timeline's, a Mind once. */ + also?: FileReader[]; +}; +/** "fetched for " under a file: the Minds whose verified receipts are in the room's timeline. Nothing when none. */ +export function FileReadBy({ room, fileId, content, also = [] }: FileReadByProps) { + const seen = useFileReaders(room, fileId, content); + const readers = [...new Map([...also, ...seen].map((r) => [r.sender, r])).values()].sort( + (a, b) => a.ts - b.ts + ); + if (readers.length === 0) return null; + return ( + + {readLine(readers, (id) => getMemberDisplayName(room, id) ?? id)} + + ); +} + +/** A timeline message that is a file: audio, image, video or file. */ +export const isFileMessage = (mEvent: MatrixEvent): boolean => + ['m.audio', 'm.image', 'm.video', 'm.file'].includes(mEvent.getContent().msgtype); diff --git a/src/app/features/albums/ShareSet.folder.test.tsx b/src/app/features/albums/ShareSet.folder.test.tsx new file mode 100644 index 00000000..abb8f2bb --- /dev/null +++ b/src/app/features/albums/ShareSet.folder.test.tsx @@ -0,0 +1,140 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MatrixClientProvider } from '../../hooks/useMatrixClient'; +import { MediaConfig, MediaConfigProvider } from '../../hooks/useMediaConfig'; + +// ShareSet hands its folder to the title the send lands under. The controller is held still and its +// `run` captured, and sendShareSet records the title it was given, so the path from the `folder` +// prop to the album root's title is read without a homeserver or a DOM. +const captured = vi.hoisted(() => ({ + run: undefined as undefined | ((files: unknown[], title: string, onFile: () => void) => unknown), + maxBytes: undefined as undefined | (() => number | undefined), + titles: [] as string[], +})); + +vi.mock('./shareController', () => ({ + createShareController: (deps: { + run: NonNullable; + maxBytes: NonNullable; + }) => { + captured.run = deps.run; + captured.maxBytes = deps.maxBytes; + return { + subscribe: () => () => undefined, + getState: () => ({ files: [], title: '', phase: { kind: 'idle' }, epoch: 0 }), + pick: () => undefined, + setTitle: () => undefined, + send: () => undefined, + cancel: () => undefined, + }; + }, +})); +vi.mock('./shareSet', async (importOriginal) => ({ + ...(await importOriginal()), + sendShareSet: async (_port: unknown, into: string | { albumId: string; lastId: string }) => { + captured.titles.push(typeof into === 'string' ? into : `into:${into.albumId}@${into.lastId}`); + return { albumId: '$a', sent: 0, failed: [], failedAt: [] }; + }, +})); +vi.mock('react', async (importOriginal) => { + const actual = await importOriginal(); + // The server renderer has no snapshot for an external store; read the client one. + const useSyncExternalStore = (_sub: unknown, get: () => unknown) => get(); + return { ...actual, default: { ...actual, useSyncExternalStore }, useSyncExternalStore }; +}); + +// eslint-disable-next-line import/first +import { ShareSet } from './ShareSet'; + +const timeline: unknown[] = []; +const ev = (id: string, ts: number, threadRootId?: string, status: string | null = null) => ({ + getId: () => id, + getTs: () => ts, + threadRootId, + status, +}); +const mount = ( + folder?: string[], + into?: { albumId: string; lastId: string; lastTs: number }, + mediaConfig: MediaConfig = {} +) => { + const room = { + roomId: '!r:x', + hasEncryptionStateEvent: () => false, + getLiveTimeline: () => ({ getEvents: () => timeline }), + } as never; + renderToStaticMarkup( + + ({ event_id: '$e' }) } as never}> + + + + + + ); +}; +const send = async (typed: string) => { + await captured.run?.([], typed, () => undefined); + return captured.titles.at(-1); +}; + +describe('ShareSet: the folder reaches the album title', () => { + beforeEach(() => { + captured.run = undefined; + captured.titles.length = 0; + timeline.length = 0; + }); + + it('at the top, the typed title goes as typed', async () => { + mount(); + expect(await send('Poses')).toBe('Poses'); + }); + + it('inside a folder, the path prefixes the typed title', async () => { + mount(['cast-art', 'poses']); + expect(await send('Set 2')).toBe('cast-art/poses/Set 2'); + }); + + it('an empty typed title falls back to the default, still under the folder', async () => { + mount(['handoff']); + expect(await send(' ')).toBe('handoff/Shared files'); + }); + + it('a typed title containing a slash goes deeper', async () => { + mount(['a']); + expect(await send('b/c')).toBe('a/b/c'); + }); + + it('an existing album takes the files instead of a title, whatever the folder', async () => { + mount(['a'], { albumId: '$alb', lastId: '$last', lastTs: 5 }); + expect(await send('ignored')).toBe('into:$alb@$last'); + }); + + it('the freshest sent event of that thread in the room timeline is where the files thread after', async () => { + timeline.push( + ev('$other', 99, '$else'), + ev('$local', 98, '$alb', 'sending'), + ev('$new', 50, '$alb'), + ev('$sentNotEchoed', 60, '$alb', 'sent'), + ev('$failed', 70, '$alb', 'not_sent') + ); + mount(['a'], { albumId: '$alb', lastId: '$last', lastTs: 5 }); + expect(await send('x')).toBe('into:$alb@$sentNotEchoed'); + }); +}); + +describe('ShareSet: the homeserver upload cap reaches the controller', () => { + it('is the server-stated m.upload.size, and none when the server states none', () => { + mount([], undefined, { 'm.upload.size': 52428800 }); + expect(captured.maxBytes?.()).toBe(52428800); + mount([], undefined, {}); + expect(captured.maxBytes?.()).toBeUndefined(); + // A cap that is not a positive number is no cap, never "refuse everything". + [0, -1, Number.NaN, '50' as unknown as number].forEach((bad) => { + mount([], undefined, { 'm.upload.size': bad }); + expect(captured.maxBytes?.()).toBeUndefined(); + }); + }); +}); diff --git a/src/app/features/albums/ShareSet.test.tsx b/src/app/features/albums/ShareSet.test.tsx new file mode 100644 index 00000000..b3da7a3d --- /dev/null +++ b/src/app/features/albums/ShareSet.test.tsx @@ -0,0 +1,123 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; +import { ClientConfigProvider } from '../../hooks/useClientConfig'; +import type { ClientConfig } from '../../hooks/useClientConfig'; +import { ShareState } from './shareController'; +import { ShareSetView, shareEnabled, useShareEnabled } from './ShareSet'; + +const text = (html: string) => + html + .replace(/<[^>]*>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +const noop = () => undefined; +const file = (name: string) => ({ name, type: 'audio/ogg' } as File); + +const view = (over: Partial> = {}, adding = false) => + renderToStaticMarkup( + + ); + +describe('the share panel, drawn', () => { + it('idle: one "Share files" row and no title, names, Send or Cancel', () => { + const html = view(); + expect(text(html)).toBe('Share files ›'); + expect(html).not.toContain('aria-label="Album title"'); + expect(html).toContain('type="file"'); + expect(html).toContain('multiple'); + expect(html).toContain('hidden'); + }); + + it('picked: the title field, the names on one line, Send N files and Cancel; no pick row', () => { + const html = view({ files: [file('a.ogg'), file('b.ogg')] }); + expect(html).toContain('aria-label="Album title"'); + expect(html).toContain('placeholder="Shared files"'); + expect(text(html)).toBe('a.ogg · b.ogg Send 2 files Cancel'); + expect(text(html)).not.toContain('Share files'); + }); + + it('one picked file reads "Send 1 file"', () => { + expect(text(view({ files: [file('a.ogg')] }))).toContain('Send 1 file Cancel'); + }); + + it('sending: progress replaces Send and Cancel, and the title is locked', () => { + const html = view({ + files: [file('a'), file('b'), file('c')], + phase: { kind: 'sending', done: 2 }, + }); + expect(text(html)).toContain('Sending 2 of 3…'); + expect(text(html)).toContain('Stop'); + expect(text(html)).not.toContain('Send 3 files'); + expect(text(html)).not.toContain('Cancel'); + expect(html).toMatch( + /aria-label="Album title"[^>]*disabled|disabled[^>]*aria-label="Album title"/ + ); + }); + + it('the encrypted note shows above the kept pick', () => { + const html = view({ + files: [file('a.ogg')], + phase: { kind: 'done', text: 'Not shared: this room is encrypted.' }, + }); + expect(text(html)).toBe('Not shared: this room is encrypted. a.ogg Send 1 file Cancel'); + }); + + it('after a full success the idle row and the note show together', () => { + expect(text(view({ phase: { kind: 'done', text: 'Shared 2 files.' } }))).toBe( + 'Share files › Shared 2 files.' + ); + }); +}); + +describe('adding to an existing album', () => { + it('the idle row says Add files', () => { + expect(text(view({}, true))).toBe('Add files ›'); + }); + + it('picked: no title field, the names, Send and Cancel', () => { + const html = view({ files: [file('a.ogg')] }, true); + expect(html).not.toContain('aria-label="Album title"'); + expect(text(html)).toBe('a.ogg Send 1 file Cancel'); + }); +}); + +describe('the flag', () => { + it('off, missing or empty offers no share', () => { + expect(shareEnabled({}, false)).toBe(false); + expect(shareEnabled({ fileZoneShare: {} }, false)).toBe(false); + expect(shareEnabled({ fileZoneShare: { enabled: false } }, false)).toBe(false); + }); + + it('on offers it, except in an encrypted room', () => { + expect(shareEnabled({ fileZoneShare: { enabled: true } }, false)).toBe(true); + expect(shareEnabled({ fileZoneShare: { enabled: true } }, true)).toBe(false); + }); + + it('the hook reads the client config and the room, as AlbumView mounts it', () => { + const room = (encrypted: boolean) => ({ hasEncryptionStateEvent: () => encrypted } as never); + function Probe({ encrypted }: { encrypted: boolean }) { + return {String(useShareEnabled(room(encrypted)))}; + } + const probe = (config: ClientConfig, encrypted: boolean) => + text( + renderToStaticMarkup( + + + + ) + ); + expect(probe({}, false)).toBe('false'); + expect(probe({ fileZoneShare: { enabled: true } }, false)).toBe('true'); + expect(probe({ fileZoneShare: { enabled: true } }, true)).toBe('false'); + }); +}); diff --git a/src/app/features/albums/ShareSet.tsx b/src/app/features/albums/ShareSet.tsx new file mode 100644 index 00000000..b7f26e7e --- /dev/null +++ b/src/app/features/albums/ShareSet.tsx @@ -0,0 +1,210 @@ +import React, { useRef, useState, useSyncExternalStore } from 'react'; +import { Text } from 'folds'; +import { useQueryClient } from '@tanstack/react-query'; +import { EventStatus, Room } from 'matrix-js-sdk'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useClientConfig } from '../../hooks/useClientConfig'; +import { useMediaConfig } from '../../hooks/useMediaConfig'; +import { uploadContent } from '../../utils/matrix'; +import { safeFile } from '../../utils/mimeTypes'; +import { getMsgContent } from '../room/msgContent'; +import { FILE_HASH_KEY, hashForSend } from './fileReceipt'; +import { ShareController, ShareState, createShareController } from './shareController'; +import { titleIn } from './albumFolders'; +import { DEFAULT_TITLE, ExistingAlbum, freshest, sendShareSet, ThreadEvent } from './shareSet'; +import * as css from './AlbumView.css'; + +/** Off unless `fileZoneShare.enabled` is true. An encrypted zone is never offered the share. */ +export const shareEnabled = ( + config: { fileZoneShare?: { enabled?: boolean } }, + encrypted: boolean +) => !!config.fileZoneShare?.enabled && !encrypted; + +export const useShareEnabled = (room: Room): boolean => + shareEnabled(useClientConfig(), room.hasEncryptionStateEvent()); + +type ShareViewProps = { + state: ShareState; + onPickClick: () => void; + onPicked: (files: File[]) => void; + onTitle: (title: string) => void; + onSend: () => void; + onCancel: () => void; + onStop: () => void; + /** Adding to an existing album: the files join it, so there is no title to ask for. */ + adding?: boolean; + inputRef?: React.Ref; +}; + +/** + * One row when idle. Picking files (any number, one picker) opens the panel: a title, the picked + * names, Send. Nothing but the row is visible until files are picked. + */ +export function ShareSetView({ + state, + onPickClick, + onPicked, + onTitle, + onSend, + onCancel, + onStop, + adding, + inputRef, +}: ShareViewProps) { + const { files, title, phase, epoch } = state; + return ( +
+ onPicked(Array.from(e.target.files ?? []))} + /> + {files.length === 0 && phase.kind !== 'sending' && ( + + )} + {phase.kind === 'done' && ( + + {phase.text} + + )} + {files.length > 0 && ( + <> + {!adding && ( + onTitle(e.target.value)} + /> + )} + + {files.map((f) => f.name).join(' · ')} + + {phase.kind === 'sending' ? ( + <> + + Sending {phase.done} of {files.length}… + + + + ) : ( + <> + + + + )} + + )} +
+ ); +} + +/** The sent events in the room's live timeline, as the thread view of them `freshest` reads. */ +const threadEvents = (room: Room): ThreadEvent[] => + room + .getLiveTimeline() + .getEvents() + .filter((e) => (!e.status || e.status === EventStatus.SENT) && !!e.getId()) + .map((e) => ({ id: e.getId() as string, ts: e.getTs(), threadRoot: e.threadRootId })); + +type ShareSetProps = { + room: Room; + /** The path a new album lands in: its title is prefixed by it (docs/commons-files-desk.md). */ + folder?: string[]; + /** Add the files to this album instead of making one. */ + into?: ExistingAlbum; +}; +export function ShareSet({ room, folder = [], into }: ShareSetProps) { + const mx = useMatrixClient(); + const queryClient = useQueryClient(); + const mediaConfig = useMediaConfig(); + const input = useRef(null); + // Built once per mount (AlbumView keys the panel by room): the pick lives in the controller, so a + // rerender with a new client or Room object must not build another. Live values go through `live`. + const live = useRef({ mx, room, queryClient, folder, into, mediaConfig }); + live.current = { mx, room, queryClient, folder, into, mediaConfig }; + const [controller]: [ShareController, unknown] = useState(() => + createShareController({ + roomId: room.roomId, + isEncrypted: () => live.current.room.hasEncryptionStateEvent(), + maxBytes: () => { + const cap = live.current.mediaConfig['m.upload.size']; + return typeof cap === 'number' && Number.isFinite(cap) && cap > 0 ? cap : undefined; + }, + onSent: () => + live.current.queryClient.invalidateQueries({ queryKey: ['albums', room.roomId] }), + run: (files, title, onFile, signal) => + sendShareSet( + { + signal, + send: async (content) => + (await live.current.mx.sendMessage(room.roomId, content as any)).event_id, + contentOf: async (f) => { + const file = safeFile(f.file); + const mxc = await new Promise((resolve, reject) => { + const { mx: client } = live.current; + let upload: Promise | undefined; + const abort = () => upload && client.cancelUpload(upload as never); + signal.addEventListener('abort', abort, { once: true }); + uploadContent(client, file, { + onPromise: (p) => { + upload = p; + if (signal.aborted) abort(); + }, + onSuccess: resolve, + onError: reject, + }); + }); + const content = await getMsgContent( + live.current.mx, + { + file, + originalFile: f.file, + metadata: { markedAsSpoiler: false }, + encInfo: undefined, + }, + mxc + ); + const sha256 = await hashForSend(f.file); + if (sha256) content.info = { ...content.info, [FILE_HASH_KEY]: sha256 }; + return content; + }, + onFile, + }, + live.current.into + ? freshest(live.current.into, threadEvents(live.current.room)) + : titleIn(live.current.folder, title, DEFAULT_TITLE), + files.map((file) => ({ name: file.name, mimeType: file.type, file })) + ), + }) + ); + const state = useSyncExternalStore(controller.subscribe, controller.getState); + return ( + input.current?.click()} + onPicked={controller.pick} + onTitle={controller.setTitle} + onSend={controller.send} + onCancel={controller.cancel} + onStop={controller.stop} + adding={!!into} + /> + ); +} diff --git a/src/app/features/albums/albumFolders.test.ts b/src/app/features/albums/albumFolders.test.ts new file mode 100644 index 00000000..9a9be53e --- /dev/null +++ b/src/app/features/albums/albumFolders.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from 'vitest'; +import { buildAlbums } from './buildAlbums'; +import type { Album, ZoneEvent } from './buildAlbums'; +import { folderOf, listFolder, splitPath, titleIn } from './albumFolders'; +import { DEFAULT_TITLE, sendShareSet } from './shareSet'; + +const album = (id: string, title: string, ts = 1): Album => ({ + id, + title, + author: '@a:x', + ts, + files: [], + count: 0, +}); +const names = (l: ReturnType) => ({ + folders: l.folders.map((f) => `${f.name}:${f.albums}`), + albums: l.albums.map((a) => `${a.album.id}=${a.leaf}`), +}); + +describe('splitPath', () => { + it('drops empty segments and padding; dots are names, not navigation', () => { + expect(splitPath(' a // b /')).toEqual(['a', 'b']); + expect(splitPath('../x')).toEqual(['..', 'x']); + expect(splitPath('///')).toEqual([]); + }); +}); + +describe('listFolder', () => { + const all = [ + album('$1', 'handoff/2026-11/notes', 10), + album('$2', 'handoff/2026-11/plan', 30), + album('$3', 'handoff/readme', 20), + album('$4', 'cast-art', 5), + album('$5', 'Plain album', 40), + ]; + + it('root: first segments become folders (counted below, newest first); single-segment titles are albums', () => { + expect(names(listFolder(all, []))).toEqual({ + folders: ['handoff:3'], + albums: ['$5=Plain album', '$4=cast-art'], + }); + }); + + it('nested: a folder shows its sub-folders and its own albums under their leaf names', () => { + expect(names(listFolder(all, ['handoff']))).toEqual({ + folders: ['2026-11:2'], + albums: ['$3=readme'], + }); + expect(names(listFolder(all, ['handoff', '2026-11']))).toEqual({ + folders: [], + albums: ['$2=plan', '$1=notes'], + }); + }); + + it('an album titled exactly like a folder is not hidden by it', () => { + const l = listFolder([album('$a', 'x'), album('$b', 'x/y')], []); + expect(names(l)).toEqual({ folders: ['x:1'], albums: ['$a=x'] }); + expect(names(listFolder([album('$a', 'x'), album('$b', 'x/y')], ['x']))).toEqual({ + folders: [], + albums: ['$b=y'], + }); + }); + + it('a path that exists nowhere lists nothing; the prefix match is by whole segment', () => { + expect(names(listFolder(all, ['hand']))).toEqual({ folders: [], albums: [] }); + }); + + it('an untitled or all-slash album stays at the root, nowhere else', () => { + const odd = [album('$u', '///')]; + expect(names(listFolder(odd, []))).toEqual({ folders: [], albums: ['$u=///'] }); + expect(names(listFolder(odd, ['x']))).toEqual({ folders: [], albums: [] }); + expect(names(listFolder([album('$w', ' ')], []))).toEqual({ + folders: [], + albums: ['$w=Untitled album'], + }); + }); + + it('server-supplied names like "constructor" are folders, not prototype hits', () => { + expect(names(listFolder([album('$c', 'constructor/a')], []))).toEqual({ + folders: ['constructor:1'], + albums: [], + }); + }); +}); + +describe('titleIn and folderOf', () => { + it('prefixes the folder, keeps a typed sub-path, falls back to the default title', () => { + expect(titleIn([], '', DEFAULT_TITLE)).toBe(DEFAULT_TITLE); + expect(titleIn([], ' Mine ', DEFAULT_TITLE)).toBe('Mine'); + expect(titleIn(['uploads', 'me'], '', DEFAULT_TITLE)).toBe(`uploads/me/${DEFAULT_TITLE}`); + expect(titleIn(['uploads'], 'a/b', DEFAULT_TITLE)).toBe('uploads/a/b'); + }); + + it('folderOf is the title path without its leaf', () => { + expect(folderOf(album('$', 'a/b/c'))).toEqual(['a', 'b']); + expect(folderOf(album('$', 'c'))).toEqual([]); + }); +}); + +describe('a share into a folder, read back as the viewer reads it', () => { + it('posts a root titled under the folder, and buildAlbums + listFolder put it in that folder', async () => { + const sent: Record[] = []; + const res = await sendShareSet( + { + send: async (c) => { + sent.push(c); + return `$e${sent.length}`; + }, + contentOf: async (f) => ({ msgtype: 'm.file', body: f.name, filename: f.name }), + }, + titleIn(['uploads', 'meta'], 'Photos', DEFAULT_TITLE), + [{ name: 'a.png', mimeType: 'image/png' }] + ); + expect(res.sent).toBe(1); + expect(sent[0]).toEqual({ msgtype: 'm.text', body: 'uploads/meta/Photos' }); + + const asEvent = (c: Record, id: string): ZoneEvent => ({ + event_id: id, + type: 'm.room.message', + sender: '@a:x', + origin_server_ts: 1, + content: c, + }); + const albums = buildAlbums([ + { root: asEvent(sent[0], '$e1'), replies: [asEvent(sent[1], '$e2')] }, + ]); + expect(names(listFolder(albums, []))).toEqual({ folders: ['uploads:1'], albums: [] }); + expect(names(listFolder(albums, ['uploads', 'meta']))).toEqual({ + folders: [], + albums: ['$e1=Photos'], + }); + }); +}); diff --git a/src/app/features/albums/albumFolders.ts b/src/app/features/albums/albumFolders.ts new file mode 100644 index 00000000..a74d363f --- /dev/null +++ b/src/app/features/albums/albumFolders.ts @@ -0,0 +1,73 @@ +// Pure: folders are albums whose title is a `/`-separated path (docs/commons-files-desk.md, "Folders"). +// No wire change: the album is still one thread root; the folder tree is only how the list is grouped. +import { Album, UNTITLED } from './buildAlbums'; + +/** A path as segments. Empty segments and surrounding space drop out; `.` and `..` are ordinary names, never navigation. */ +export const splitPath = (title: string): string[] => + title + .split('/') + .map((s) => s.trim()) + .filter(Boolean); + +export type FolderEntry = { + name: string; + /** Segments from the root to this folder. */ + path: string[]; + /** Albums at any depth below. */ + albums: number; + /** The newest album ts below, for ordering. */ + ts: number; +}; + +export type AlbumEntry = { album: Album; leaf: string }; + +export type FolderListing = { folders: FolderEntry[]; albums: AlbumEntry[] }; + +const startsWith = (path: string[], prefix: string[]) => prefix.every((seg, i) => path[i] === seg); + +/** + * What one folder shows: its direct sub-folders and the albums titled exactly at its path plus a leaf. + * An album titled `x` and a folder `x/` coexist at the same level; neither hides the other. Newest first, both lists. + */ +export const listFolder = (albums: readonly Album[], at: string[]): FolderListing => { + const folders = new Map(); + const direct: AlbumEntry[] = []; + albums.forEach((album) => { + const segs = splitPath(album.title); + if (segs.length === 0) { + // An untitled or all-slash title lives at the root under the title as written. + if (at.length === 0) direct.push({ album, leaf: album.title.trim() || UNTITLED }); + return; + } + if (!startsWith(segs, at)) return; + // An album titled exactly at this path sits in the parent, not inside the folder it shares a name with. + if (segs.length <= at.length) return; + if (segs.length === at.length + 1) { + direct.push({ album, leaf: segs[segs.length - 1] }); + return; + } + const name = segs[at.length]; + const f = folders.get(name) ?? { name, path: [...at, name], albums: 0, ts: 0 }; + f.albums += 1; + f.ts = Math.max(f.ts, album.ts); + folders.set(name, f); + }); + return { + folders: [...folders.values()].sort((a, b) => b.ts - a.ts), + albums: direct.sort((a, b) => b.album.ts - a.album.ts), + }; +}; + +/** The folder an album sits in: its title path without the leaf. */ +export const folderOf = (album: Album): string[] => splitPath(album.title).slice(0, -1); + +/** + * The title a share sends into `folder`: the folder path then what the sender typed. The typed title + * may itself contain `/`, which just goes deeper. An empty typed title falls back to `fallback`. + */ +export const titleIn = (folder: string[], typed: string, fallback: string): string => { + const leaf = splitPath(typed).join('/') || fallback; + return [...folder, leaf].join('/'); +}; + +export const pathLabel = (path: string[]): string => path.join(' / '); diff --git a/src/app/features/albums/buildAlbums.test.ts b/src/app/features/albums/buildAlbums.test.ts new file mode 100644 index 00000000..e1e7964b --- /dev/null +++ b/src/app/features/albums/buildAlbums.test.ts @@ -0,0 +1,336 @@ +import { describe, expect, it } from 'vitest'; +import { AlbumInput, ZoneEvent, buildAlbums, lastEvent } from './buildAlbums'; + +const root = (id: string, ts: number, over: Partial = {}): ZoneEvent => ({ + event_id: id, + type: 'm.room.message', + sender: '@void:x', + origin_server_ts: ts, + content: { msgtype: 'm.text', body: `album ${id}` }, + ...over, +}); +const reply = (id: string, ts: number, msgtype: string, body = id, extra = {}): ZoneEvent => ({ + event_id: id, + type: 'm.room.message', + sender: '@void:x', + origin_server_ts: ts, + content: { msgtype, body, ...extra }, +}); +const one = (r: ZoneEvent, replies: ZoneEvent[]) => buildAlbums([{ root: r, replies }])[0]; + +describe('buildAlbums', () => { + it('lists the newest album first', () => { + const inputs: AlbumInput[] = [ + { root: root('$old', 100), replies: [] }, + { root: root('$new', 300), replies: [] }, + { root: root('$mid', 200), replies: [] }, + ]; + expect(buildAlbums(inputs).map((a) => a.id)).toEqual(['$new', '$mid', '$old']); + }); + + it('takes title, author and date from the root', () => { + const a = one(root('$r', 555, { sender: '@eureka:x' }), []); + expect([a.title, a.author, a.ts]).toEqual(['album $r', '@eureka:x', 555]); + }); + + it('lists files oldest first whatever order the server returned them in', () => { + const a = one(root('$r', 1), [ + reply('$c', 30, 'm.audio'), + reply('$a', 10, 'm.audio'), + reply('$b', 20, 'm.audio'), + ]); + expect(a.files.map((f) => f.id)).toEqual(['$a', '$b', '$c']); + }); + + it('counts media replies only; a text reply is not a file', () => { + const a = one(root('$r', 1), [ + reply('$t', 5, 'm.text', 'nice'), + reply('$n', 6, 'm.notice', 'bot'), + reply('$a', 10, 'm.audio'), + reply('$f', 11, 'm.file'), + ]); + expect(a.count).toBe(2); + expect(a.files.map((f) => f.id)).toEqual(['$a', '$f']); + }); + + it('keeps a root with no media as an album of zero files', () => { + const a = one(root('$r', 1), [reply('$t', 5, 'm.text')]); + expect(a).toMatchObject({ id: '$r', count: 0, files: [], cover: undefined }); + }); + + it('uses the first image as the cover', () => { + const a = one(root('$r', 1), [ + reply('$a', 5, 'm.audio'), + reply('$i2', 20, 'm.image'), + reply('$i1', 10, 'm.image'), + ]); + expect(a.cover?.id).toBe('$i1'); + expect(one(root('$q', 1), [reply('$a', 5, 'm.audio')]).cover).toBeUndefined(); + }); + + it('derives the kind from the msgtype', () => { + const a = one(root('$r', 1), [ + reply('$1', 1, 'm.audio'), + reply('$2', 2, 'm.image'), + reply('$3', 3, 'm.video'), + reply('$4', 4, 'm.file'), + ]); + expect(a.files.map((f) => f.kind)).toEqual(['audio', 'image', 'video', 'file']); + }); + + it('names a file by filename, else body', () => { + const a = one(root('$r', 1), [ + reply('$1', 1, 'm.audio', 'caption', { filename: 'clip.wav' }), + reply('$2', 2, 'm.audio', 'plain.wav'), + ]); + expect(a.files.map((f) => f.name)).toEqual(['clip.wav', 'plain.wav']); + }); + + it('does not duplicate a file the relations endpoint returned twice', () => { + const a = one(root('$r', 1), [reply('$a', 5, 'm.audio'), reply('$a', 5, 'm.audio')]); + expect(a.count).toBe(1); + }); + + it('does not duplicate an album listed twice', () => { + expect( + buildAlbums([ + { root: root('$r', 1), replies: [] }, + { root: root('$r', 1), replies: [] }, + ]) + ).toHaveLength(1); + }); + + it('skips redacted and empty events', () => { + const redacted = { + ...reply('$x', 5, 'm.audio'), + content: {}, + unsigned: { redacted_because: {} }, + }; + const stillTyped = { ...reply('$y', 6, 'm.audio'), unsigned: { redacted_because: {} } }; + const empty: ZoneEvent = { event_id: '$z' }; + const a = one(root('$r', 1), [redacted, stillTyped, empty, reply('$ok', 7, 'm.audio')]); + expect(a.files.map((f) => f.id)).toEqual(['$ok']); + expect(buildAlbums([{ root: root('$g', 1, { content: {} }), replies: [] }])).toEqual([]); + expect( + buildAlbums([{ root: root('$h', 1, { unsigned: { redacted_because: {} } }), replies: [] }]) + ).toEqual([]); + }); + + it('tolerates events with missing fields', () => { + const a = one({ event_id: '$r', content: { msgtype: 'm.text' } }, [ + { event_id: '$a', content: { msgtype: 'm.audio' } }, + ]); + expect(a).toMatchObject({ title: 'Untitled album', author: '', ts: 0, count: 1 }); + }); + + it('does not read inherited object keys as file kinds', () => { + const a = one(root('$r', 1), [ + reply('$c', 1, 'constructor'), + reply('$p', 2, '__proto__'), + reply('$t', 3, 'toString'), + ]); + expect(a.count).toBe(0); + }); + + it('skips events that are not room messages even if their content looks like media', () => { + const reaction = { ...reply('$x', 5, 'm.audio'), type: 'm.reaction' }; + const a = one(root('$r', 1), [reaction, reply('$ok', 6, 'm.audio')]); + expect(a.files.map((f) => f.id)).toEqual(['$ok']); + expect(buildAlbums([{ root: root('$g', 1, { type: 'm.reaction' }), replies: [] }])).toEqual([]); + }); + + it('skips events without an event id', () => { + const anon: ZoneEvent = { type: 'm.room.message', content: { msgtype: 'm.audio', body: 'a' } }; + expect(one(root('$r', 1), [anon]).count).toBe(0); + expect( + buildAlbums([{ root: { content: { msgtype: 'm.text', body: 'x' } }, replies: [] }]) + ).toEqual([]); + }); + + it('titles an edited album by its latest edit', () => { + const edited = root('$r', 1, { + unsigned: { + 'm.relations': { + 'm.replace': { content: { 'm.new_content': { msgtype: 'm.text', body: 'renamed' } } }, + }, + }, + }); + expect(one(edited, []).title).toBe('renamed'); + const emptyEdit = root('$q', 1, { unsigned: { 'm.relations': { 'm.replace': {} } } }); + expect(one(emptyEdit, []).title).toBe('album $q'); + }); + + it('survives null or non-object entries in inputs and replies', () => { + const bad = [null, undefined, 'x', 7] as unknown as ZoneEvent[]; + const a = buildAlbums([ + null as unknown as AlbumInput, + { root: null as unknown as ZoneEvent, replies: [] }, + { root: root('$r', 1), replies: [...bad, reply('$ok', 2, 'm.audio')] }, + { root: root('$s', 2), replies: null as unknown as ZoneEvent[] }, + ]); + expect(a.map((x) => x.id)).toEqual(['$s', '$r']); + expect(a[1].files.map((f) => f.id)).toEqual(['$ok']); + }); + + it('treats a non-numeric timestamp as 0 so it sorts first, not by string order', () => { + const a = one(root('$r', 1), [ + reply('$b', 20, 'm.audio'), + { ...reply('$bad', 0, 'm.audio'), origin_server_ts: '5' as unknown as number }, + { ...reply('$nan', 0, 'm.audio'), origin_server_ts: NaN }, + reply('$a', 10, 'm.audio'), + ]); + expect(a.files.map((f) => f.id)).toEqual(['$bad', '$nan', '$a', '$b']); + const albums = buildAlbums([ + { root: root('$x', 100), replies: [] }, + { root: root('$y', 0, { origin_server_ts: '999' as unknown as number }), replies: [] }, + ]); + expect(albums.map((x) => [x.id, x.ts])).toEqual([ + ['$x', 100], + ['$y', 0], + ]); + }); + + it('gives an album with an empty body a fallback title', () => { + expect(one(root('$r', 1, { content: { msgtype: 'm.text', body: '' } }), []).title).toBe( + 'Untitled album' + ); + expect(one(root('$q', 1, { content: { msgtype: 'm.text' } }), []).title).toBe('Untitled album'); + }); + + it('never counts an edit event as a file or an album', () => { + const edit = reply('$e', 9, 'm.audio', '* x', { + 'm.relates_to': { rel_type: 'm.replace', event_id: '$a' }, + 'm.new_content': { msgtype: 'm.audio', body: 'x' }, + }); + const a = one(root('$r', 1), [reply('$a', 5, 'm.audio'), edit]); + expect(a.files.map((f) => f.id)).toEqual(['$a']); + const rootEdit = root('$re', 1, { + content: { msgtype: 'm.text', body: '* t', 'm.relates_to': { rel_type: 'm.replace' } }, + }); + expect(buildAlbums([{ root: rootEdit, replies: [] }])).toEqual([]); + const threaded = reply('$t', 6, 'm.audio', 't', { + 'm.relates_to': { rel_type: 'm.thread', event_id: '$r' }, + }); + expect(one(root('$q', 1), [threaded]).count).toBe(1); + }); +}); + +describe('versions', () => { + const v = (id: string, ts: number, replaces?: string) => + reply(id, ts, 'm.file', id, replaces ? { 'org.gamecult.publish': { replaces } } : {}); + + it('lists the latest of a chain and folds the earlier versions under it', () => { + const a = one(root('$r', 1), [v('$v1', 10), v('$v2', 20, '$v1'), reply('$x', 30, 'm.file')]); + expect(a.files.map((f) => [f.id, f.earlier.map((e) => e.id)])).toEqual([ + ['$v2', ['$v1']], + ['$x', []], + ]); + expect(a.count).toBe(2); + }); +}); + +describe('versions: cover and sender', () => { + it('the cover comes from the shown version, not a folded image', () => { + const img = (id: string, ts: number, type: string, replaces?: string) => + reply(id, ts, type, id, replaces ? { 'org.gamecult.publish': { replaces } } : {}); + const a = one(root('$r', 1), [img('$v1', 10, 'm.image'), img('$v2', 20, 'm.file', '$v1')]); + expect(a.files.map((f) => f.id)).toEqual(['$v2']); + expect(a.cover).toBeUndefined(); + }); +}); + +describe('lastEvent', () => { + it('is the newest event of the thread, a folded version included; the root when empty', () => { + const v = (id: string, ts: number, replaces?: string) => + reply(id, ts, 'm.file', id, replaces ? { 'org.gamecult.publish': { replaces } } : {}); + expect(lastEvent(one(root('$r', 1), [v('$a', 10), v('$b', 20, '$a'), v('$c', 15)]))).toEqual({ + id: '$b', + ts: 20, + }); + expect(lastEvent(one(root('$r', 1), []))).toEqual({ id: '$r', ts: 1 }); + }); +}); + +describe('Mind receipts', () => { + const HASH = 'a'.repeat(64); + const file = (id: string, sha?: string) => + reply(id, 10, 'm.audio', id, { info: sha ? { sha256: sha } : {} }); + const receipt = ( + id: string, + ts: number, + sender: string, + fileId: unknown, + sha?: unknown, + extra: Record = {} + ) => + ({ + event_id: id, + type: 'org.gamecult.file_received', + sender, + origin_server_ts: ts, + content: { 'org.gamecult.file_received': { event_id: fileId, sha256: sha, ...extra } }, + } as ZoneEvent); + + const oneX = (r: ZoneEvent, replies: ZoneEvent[]) => buildAlbums([{ root: r, replies }], 'x')[0]; + + it('shows who read a file when the receipt carries the file event hash', () => { + const a = oneX(root('$r', 1), [file('$f', HASH), receipt('$x', 20, '@void:x', '$f', HASH)]); + expect(a.files[0].readBy).toEqual([{ sender: '@void:x', ts: 20 }]); + expect(a.count).toBe(1); + }); + it('counts nothing for a hash that differs, a missing hash on either side, or a bad hash', () => { + const a = oneX(root('$r', 1), [ + file('$f', HASH), + file('$g'), + receipt('$x', 20, '@void:x', '$f', 'b'.repeat(64)), + receipt('$y', 21, '@void:x', '$g', HASH), + receipt('$z', 22, '@void:x', '$f', undefined), + receipt('$w', 23, '@void:x', '$f', 7), + ]); + expect(a.files.every((f) => f.readBy.length === 0)).toBe(true); + }); + it('trusts the event sender, never a Mind field inside the content', () => { + const a = oneX(root('$r', 1), [ + file('$f', HASH), + receipt('$x', 20, '@mallory:x', '$f', HASH, { mind: 'void', sender: '@void:x' }), + ]); + expect(a.files[0].readBy).toEqual([]); + }); + it('counts only a Mind of this homeserver: not another server, a lookalike, or a bare name', () => { + const a = oneX(root('$r', 1), [ + file('$f', HASH), + receipt('$x', 20, '@void-fan:x', '$f', HASH), + receipt('$y', 21, '@x:void', '$f', HASH), + receipt('$z', 22, '@aqua:x', '$f', HASH), + receipt('$e', 23, '@void:evil.example', '$f', HASH), + receipt('$b', 24, 'void:x', '$f', HASH), + ]); + expect(a.files[0].readBy.map((r) => r.sender)).toEqual(['@aqua:x']); + }); + it('keeps one receipt per Mind, the latest, and a second Mind beside it', () => { + const a = oneX(root('$r', 1), [ + file('$f', HASH), + receipt('$1', 20, '@void:x', '$f', HASH), + receipt('$2', 30, '@void:x', '$f', HASH), + receipt('$3', 25, '@eureka:x', '$f', HASH), + ]); + expect(a.files[0].readBy).toEqual([ + { sender: '@eureka:x', ts: 25 }, + { sender: '@void:x', ts: 30 }, + ]); + }); + it('ignores a receipt for a file not in the album, a malformed one, and a redacted one', () => { + const redacted = { + ...receipt('$d', 20, '@void:x', '$f', HASH), + unsigned: { redacted_because: {} }, + }; + const a = oneX(root('$r', 1), [ + file('$f', HASH), + receipt('$a', 20, '@void:x', '$elsewhere', HASH), + receipt('$b', 21, '@void:x', 42, HASH), + redacted, + ]); + expect(a.files[0].readBy).toEqual([]); + expect(a.count).toBe(1); + }); +}); diff --git a/src/app/features/albums/buildAlbums.ts b/src/app/features/albums/buildAlbums.ts new file mode 100644 index 00000000..5d3662e1 --- /dev/null +++ b/src/app/features/albums/buildAlbums.ts @@ -0,0 +1,184 @@ +// Pure: turns the thread roots of a file-zone room and each root's thread replies, in the JSON +// Synapse returns from GET /rooms/{roomId}/threads and GET /rooms/{roomId}/relations/{root}/m.thread, +// into albums. See docs/file-zone.md for the rulings this encodes. +import { readersOf } from './fileReceipt'; +import type { FileReader } from './fileReceipt'; +import { foldVersions } from './versions'; + +/** The slice of a Matrix event this module reads. Every field may be missing. */ +export type ZoneEvent = { + event_id?: string; + type?: string; + sender?: string; + origin_server_ts?: number; + content?: Record; + unsigned?: Record; +}; + +export type AlbumInput = { root: ZoneEvent; replies: ZoneEvent[] }; + +export type FileKind = 'audio' | 'image' | 'video' | 'file'; + +export type AlbumFile = { + id: string; + kind: FileKind; + name: string; + sender: string; + ts: number; + /** The event content, as RenderMessageContent reads it. */ + content: Record; + /** Receipts in this album that name this file, one per sender (the latest), oldest first. */ + readBy: FileReader[]; + /** The versions this file supersedes, nearest first (`org.gamecult.publish.replaces`). */ + earlier: AlbumFile[]; +}; + +export type Album = { + id: string; + title: string; + author: string; + ts: number; + cover?: AlbumFile; + files: AlbumFile[]; + count: number; +}; + +// A Map, not an object: msgtype is server-supplied and "constructor" must not name a kind. +const KIND_OF_MSGTYPE = new Map([ + ['m.audio', 'audio'], + ['m.image', 'image'], + ['m.video', 'video'], + ['m.file', 'file'], +]); + +// A redacted event keeps its id but loses its content and gains unsigned.redacted_because. +const live = ( + e: ZoneEvent +): e is ZoneEvent & { event_id: string; content: Record } => + !!e && + typeof e === 'object' && + !!e.event_id && + // An edit is an m.room.message too, but it revises an event; it is never a file or an album. + (e.content?.['m.relates_to'] as { rel_type?: unknown } | undefined)?.rel_type !== 'm.replace' && + !e.unsigned?.redacted_because && + (e.type === undefined || e.type === 'm.room.message') && + typeof e.content?.msgtype === 'string'; + +const tsOf = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0); + +const asString = (v: unknown): string => (typeof v === 'string' ? v : ''); + +export const toFile = (e: ZoneEvent): AlbumFile | undefined => { + if (!live(e)) return undefined; + const kind = KIND_OF_MSGTYPE.get(e.content.msgtype as string); + if (!kind) return undefined; + return { + id: e.event_id, + kind, + name: asString(e.content.filename) || asString(e.content.body), + sender: e.sender ?? '', + ts: tsOf(e.origin_server_ts), + content: e.content, + readBy: [], + earlier: [], + }; +}; + +export const UNTITLED = 'Untitled album'; + +// An edited root carries its latest edit under unsigned["m.relations"]["m.replace"]; the title is +// that edit's m.new_content.body, else the original body. +const titleOf = (root: ZoneEvent & { content: Record }): string => { + const relations = root.unsigned?.['m.relations'] as Record | undefined; + const edit = relations?.['m.replace'] as ZoneEvent | undefined; + const edited = (edit?.content?.['m.new_content'] as Record | undefined)?.body; + return asString(edited) || asString(root.content.body) || UNTITLED; +}; + +const toAlbum = (input: AlbumInput, server: string): Album | undefined => { + const { root, replies } = input ?? {}; + if (!live(root)) return undefined; + const seen = new Set(); + const list = Array.isArray(replies) ? replies : []; + const files = list + .map(toFile) + .filter((f): f is AlbumFile => { + if (!f || seen.has(f.id)) return false; + seen.add(f.id); + return true; + }) + .sort((a, b) => a.ts - b.ts) + .map((f) => ({ ...f, readBy: readersOf(list, f.id, f.content, server) })); + const heads = foldVersions(files); + return { + id: root.event_id, + title: titleOf(root), + author: root.sender ?? '', + ts: tsOf(root.origin_server_ts), + cover: heads.find((f) => f.kind === 'image'), + files: heads, + count: heads.length, + }; +}; + +/** Newest album first; files oldest first. Loose room messages never reach this function. */ +/** `server` is the homeserver's own domain: only its Minds' receipts count. */ +export const buildAlbums = (inputs: AlbumInput[], server = ''): Album[] => { + const seen = new Set(); + return inputs + .map((i) => toAlbum(i, server)) + .filter((a): a is Album => { + if (!a || seen.has(a.id)) return false; + seen.add(a.id); + return true; + }) + .sort((a, b) => b.ts - a.ts); +}; + +/** The newest event in an album's thread (shown or folded), else its root: where a new file threads after. */ +export const lastEvent = (album: Album): { id: string; ts: number } => + album.files + .flatMap((f) => [f, ...f.earlier]) + .reduce<{ id: string; ts: number }>((a, f) => (f.ts >= a.ts ? { id: f.id, ts: f.ts } : a), { + id: album.id, + ts: album.ts, + }); + +/** A room event with the thread root the SDK resolved for it, when it has one. */ +export type TimelineEvent = ZoneEvent & { threadRoot?: string }; + +/** + * File events that sit in the room outside any album thread: what a poster that does not thread + * its files leaves behind. The album list never reads them (it lists threads), so they are named + * to the sender instead of left out silently. + */ +export const looseFiles = (events: TimelineEvent[]): AlbumFile[] => + events + .filter((e) => { + const rel = e?.content?.['m.relates_to'] as { rel_type?: unknown } | undefined; + return !e?.threadRoot && rel?.rel_type !== 'm.thread'; + }) + .map(toFile) + .filter((f): f is AlbumFile => !!f); + +const NAMED = 3; + +/** The line that says loose files exist and are not listed; empty when there are none. */ +export const looseNote = (files: AlbumFile[]): string => { + if (files.length === 0) return ''; + const names = files.slice(0, NAMED).map((f) => f.name || 'unnamed'); + const more = files.length > NAMED ? ` and ${files.length - NAMED} more` : ''; + const what = files.length === 1 ? '1 file was' : `${files.length} files were`; + return `${what} posted outside an album and ${ + files.length === 1 ? 'is' : 'are' + } not listed here: ${names.join(', ')}${more}. ${ + files.length === 1 ? 'It is' : 'They are' + } in the Timeline.`; +}; + +/** Where the album view stands when it decides whether to show the loose-files note. */ +export type ViewState = { open: boolean; encrypted: boolean; loading: boolean; error: boolean }; + +/** The note for the list view only: never inside an open album, an encrypted zone, or a load that is not finished. */ +export const looseNoteFor = (files: AlbumFile[], view: ViewState): string => + view.open || view.encrypted || view.loading || view.error ? '' : looseNote(files); diff --git a/src/app/features/albums/fileReceipt.test.ts b/src/app/features/albums/fileReceipt.test.ts new file mode 100644 index 00000000..c3c85f5e --- /dev/null +++ b/src/app/features/albums/fileReceipt.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from 'vitest'; +import { ReceiptEvent, hashForSend, readLine, readersOf, sha256Hex } from './fileReceipt'; + +describe('sha256Hex', () => { + it('hashes the bytes: the FIPS 180 vector for "abc"', async () => { + expect(await sha256Hex(new Blob(['abc']))).toBe( + 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad' + ); + }); + it('hashes empty input', async () => { + expect(await sha256Hex(new Blob([]))).toBe( + 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' + ); + }); +}); + +describe('hashForSend', () => { + it('gives no hash, and does not throw, when the blob cannot be read', async () => { + const broken = { arrayBuffer: () => Promise.reject(new Error('gone')) } as unknown as Blob; + expect(await hashForSend(broken)).toBeUndefined(); + }); +}); + +describe('readLine', () => { + it('names each reader', () => { + const name = (s: string) => s.slice(1, s.indexOf(':')); + expect( + readLine( + [ + { sender: '@void:x', ts: 1 }, + { sender: '@eureka:x', ts: 2 }, + ], + name + ) + ).toBe('fetched for void, eureka'); + }); +}); + +describe('readersOf: receipts anywhere in the room, thread reply or plain event', () => { + const HASH = 'a'.repeat(64); + const file = { info: { sha256: HASH } }; + const receipt = ( + id: string, + ts: number, + sender: string, + sha: unknown | undefined, + fileId: string | undefined, + relates?: object + ): ReceiptEvent => ({ + event_id: id, + type: 'org.gamecult.file_received', + sender, + origin_server_ts: ts, + content: { + ...(relates ? { 'm.relates_to': relates } : {}), + 'org.gamecult.file_received': { + event_id: fileId ?? '$f', + sha256: sha === undefined ? HASH : sha, + }, + }, + }); + const readers = (events: ReceiptEvent[], content: Record | undefined) => + readersOf(events, '$f', content, 'gamecult.org').map((r) => r.sender); + + it('counts a threadless receipt, as a composer file with no album root gets', () => { + expect(readers([receipt('$1', 5, '@void:gamecult.org', undefined, undefined)], file)).toEqual([ + '@void:gamecult.org', + ]); + }); + it('counts a thread reply the same way', () => { + const t = { rel_type: 'm.thread', event_id: '$root' }; + expect(readers([receipt('$1', 5, '@eureka:gamecult.org', HASH, '$f', t)], file)).toEqual([ + '@eureka:gamecult.org', + ]); + }); + it('refuses lookalike localparts and foreign servers', () => { + expect( + readers( + [ + receipt('$1', 1, '@void2:gamecult.org', undefined, undefined), + receipt('$2', 2, '@voidd:gamecult.org', undefined, undefined), + receipt('$3', 3, '@Void:gamecult.org', undefined, undefined), + receipt('$4', 4, '@void:evil.example', undefined, undefined), + receipt('$5', 5, '@void:gamecult.org.evil.example', undefined, undefined), + receipt('$6', 6, '@mallory:gamecult.org', undefined, undefined), + ], + file + ) + ).toEqual([]); + }); + it('shows nothing for a mismatched hash, a file with no hash, or another file', () => { + expect( + readers([receipt('$1', 1, '@void:gamecult.org', 'b'.repeat(64), undefined)], file) + ).toEqual([]); + expect( + readers([receipt('$1', 1, '@void:gamecult.org', undefined, undefined)], { info: {} }) + ).toEqual([]); + expect( + readers([receipt('$1', 1, '@void:gamecult.org', undefined, undefined)], undefined) + ).toEqual([]); + expect(readers([receipt('$1', 1, '@void:gamecult.org', HASH, '$other')], file)).toEqual([]); + }); + it('keeps one entry per Mind, the latest, oldest first', () => { + expect( + readersOf( + [ + receipt('$1', 30, '@void:gamecult.org', undefined, undefined), + receipt('$2', 10, '@void:gamecult.org', undefined, undefined), + receipt('$3', 20, '@aqua:gamecult.org', undefined, undefined), + ], + '$f', + file, + 'gamecult.org' + ) + ).toEqual([ + { sender: '@aqua:gamecult.org', ts: 20 }, + { sender: '@void:gamecult.org', ts: 30 }, + ]); + }); + it('ignores a redacted receipt and a malformed one', () => { + const redacted = { + ...receipt('$1', 1, '@void:gamecult.org', undefined, undefined), + unsigned: { redacted_because: {} }, + }; + expect( + readers( + [ + redacted, + receipt('$2', 2, '@void:gamecult.org', 7, undefined), + { + ...receipt('$3', 3, '@void:gamecult.org', undefined, undefined), + type: 'm.room.message', + }, + ], + file + ) + ).toEqual([]); + }); + + describe('an m.reference to the file', () => { + const ref = (id: string) => ({ rel_type: 'm.reference', event_id: id }); + it('counts when it names the same event as the content', () => { + expect( + readers([receipt('$1', 1, '@void:gamecult.org', HASH, '$f', ref('$f'))], file) + ).toEqual(['@void:gamecult.org']); + }); + it('counts for nothing when it names another event', () => { + expect( + readers([receipt('$1', 1, '@void:gamecult.org', HASH, '$f', ref('$other'))], file) + ).toEqual([]); + }); + it('draws one entry per Mind however many receipts exist', () => { + expect( + readers( + [ + receipt('$1', 1, '@void:gamecult.org', HASH, '$f', ref('$f')), + receipt('$2', 2, '@void:gamecult.org', HASH, '$f'), + receipt('$3', 3, '@void:gamecult.org', HASH, '$f', ref('$f')), + ], + file + ) + ).toEqual(['@void:gamecult.org']); + }); + }); +}); diff --git a/src/app/features/albums/fileReceipt.ts b/src/app/features/albums/fileReceipt.ts new file mode 100644 index 00000000..93fa73f0 --- /dev/null +++ b/src/app/features/albums/fileReceipt.ts @@ -0,0 +1,89 @@ +// The file-zone Mind contract (docs/file-zone-mind-contract.md): the plaintext hash a sender writes +// into a file event, and the receipt a Mind answers with. + +import { MINDS } from '../../components/message/pose/poses'; + +/** A Mind's verified receipt for one file: its sender, who the Matrix server says posted it. */ +export type FileReader = { sender: string; ts: number }; + +/** The slice of a Matrix event a receipt is read from. Every field may be missing. */ +export type ReceiptEvent = { + event_id?: string; + type?: string; + sender?: string; + origin_server_ts?: number; + content?: Record; + unsigned?: Record; +}; + +/** Hex sha256 of the plaintext bytes, written at `content.info[FILE_HASH_KEY]` in unencrypted rooms. */ +export const FILE_HASH_KEY = 'sha256'; + +/** A Mind's receipt: event type, and the content key holding { event_id, sha256, size }. Inert: nothing in it is fetched or trusted but the sender and the hash. */ +export const FILE_RECEIVED = 'org.gamecult.file_received'; + +export const sha256Hex = async (data: Blob): Promise => { + const digest = await crypto.subtle.digest('SHA-256', await data.arrayBuffer()); + return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join(''); +}; + +/** The hash to write for a file about to be sent in an unencrypted room; none when it cannot be computed. */ +export const hashForSend = async (data: Blob): Promise => { + try { + return await sha256Hex(data); + } catch { + return undefined; + } +}; + +export const readLine = (readBy: FileReader[], name: (sender: string) => string): string => + `fetched for ${readBy.map((r) => name(r.sender)).join(', ')}`; + +const receiptOf = (e: ReceiptEvent) => { + if (!e || typeof e !== 'object' || e.type !== FILE_RECEIVED || !e.event_id) return undefined; + if (e.unsigned?.redacted_because) return undefined; + const body = e.content?.[FILE_RECEIVED] as { event_id?: unknown; sha256?: unknown } | undefined; + if (typeof body?.event_id !== 'string' || typeof body.sha256 !== 'string') return undefined; + if (typeof e.sender !== 'string') return undefined; + // A reference to the file (Eureka's dedup, via /relations) must name the same event as the content, or the + // receipt counts for nothing. Matching is by content.event_id; other relations (a thread) are not read. + const rel = e.content?.['m.relates_to'] as { rel_type?: unknown; event_id?: unknown } | undefined; + if (rel?.rel_type === 'm.reference' && rel.event_id !== body.event_id) return undefined; + const ts = e.origin_server_ts; + return { + file: body.event_id, + sender: e.sender, + ts: typeof ts === 'number' && Number.isFinite(ts) ? ts : 0, + sha256: body.sha256, + }; +}; + +// Only the event's sender counts, never a name inside the content, and only a Mind's own Matrix +// identity on this homeserver (`@:`, a mind named in the Persona catalogue). +const isMind = (sender: string, server: string) => MINDS.some((m) => sender === `@${m}:${server}`); + +/** + * Who has read the file `fileId`, from receipts found anywhere in `events` (an album's thread replies, or the room's + * timeline: a receipt is a thread reply when the file has an album root, otherwise a plain room event; both count). + * "Read" means verified: the receipt's sha256 must equal the file event's `info.sha256`, so a file with no hash, or a + * mismatch, shows nothing. One entry per Mind (its latest), oldest first. + */ +export const readersOf = ( + events: ReceiptEvent[], + fileId: string, + fileContent: Record | undefined, + server: string +): FileReader[] => { + const sent = (fileContent?.info as Record | undefined)?.[FILE_HASH_KEY]; + if (typeof sent !== 'string') return []; + const latest = new Map(); + events + .map(receiptOf) + .filter( + (r): r is NonNullable => + !!r && r.file === fileId && r.sha256 === sent && isMind(r.sender, server) + ) + .sort((a, b) => a.ts - b.ts) + .forEach((r) => latest.set(r.sender, { sender: r.sender, ts: r.ts })); + return [...latest.values()].sort((a, b) => a.ts - b.ts); +}; diff --git a/src/app/features/albums/isFileZone.test.ts b/src/app/features/albums/isFileZone.test.ts new file mode 100644 index 00000000..0e55269f --- /dev/null +++ b/src/app/features/albums/isFileZone.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { isFileZone } from './isFileZone'; + +const room = { roomId: '!f:x', aliases: ['#files:x'] }; + +describe('isFileZone', () => { + it('is true for the file-zone create type', () => { + expect(isFileZone({ ...room, createType: 'org.gamecult.file_zone' }, {})).toBe(true); + }); + it('is false for other create types and for plain rooms', () => { + expect(isFileZone({ ...room, createType: 'm.space' }, {})).toBe(false); + expect(isFileZone({ ...room, createType: 'org.gamecult.other' }, {})).toBe(false); + expect(isFileZone({ ...room, createType: 'org.gamecult.file_zone.x' }, {})).toBe(false); + expect(isFileZone(room, {})).toBe(false); + expect(isFileZone(room, { fileZoneRooms: [] })).toBe(false); + }); + it('is true when the room id or an alias is configured', () => { + expect(isFileZone(room, { fileZoneRooms: ['!f:x'] })).toBe(true); + expect(isFileZone(room, { fileZoneRooms: ['#other:x', '#files:x'] })).toBe(true); + }); + it('is false when only another room is configured', () => { + expect(isFileZone(room, { fileZoneRooms: ['!g:x', '#g:x'] })).toBe(false); + }); +}); diff --git a/src/app/features/albums/isFileZone.ts b/src/app/features/albums/isFileZone.ts new file mode 100644 index 00000000..071ef17b --- /dev/null +++ b/src/app/features/albums/isFileZone.ts @@ -0,0 +1,14 @@ +export const FILE_ZONE_TYPE = 'org.gamecult.file_zone'; + +export type FileZoneRoom = { + roomId: string; + /** Canonical and alternative aliases. */ + aliases?: string[]; + /** The `type` of the room's m.room.create content. */ + createType?: string; +}; + +/** A room is the file zone when its create type says so or the client config lists it. */ +export const isFileZone = (room: FileZoneRoom, config: { fileZoneRooms?: string[] }): boolean => + room.createType === FILE_ZONE_TYPE || + !!config.fileZoneRooms?.some((r) => r === room.roomId || room.aliases?.includes(r)); diff --git a/src/app/features/albums/mapLimit.test.ts b/src/app/features/albums/mapLimit.test.ts new file mode 100644 index 00000000..24898cb0 --- /dev/null +++ b/src/app/features/albums/mapLimit.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { mapLimit } from './mapLimit'; + +describe('mapLimit', () => { + it('keeps result order and never exceeds the limit', async () => { + let inFlight = 0; + let peak = 0; + const out = await mapLimit([5, 1, 4, 2, 3, 0], 2, async (n) => { + inFlight += 1; + peak = Math.max(peak, inFlight); + await new Promise((r) => { + setTimeout(r, n); + }); + inFlight -= 1; + return n * 10; + }); + expect(out).toEqual([50, 10, 40, 20, 30, 0]); + expect(peak).toBe(2); + }); + it('handles an empty list and rejects when a call rejects', async () => { + expect(await mapLimit([], 3, async (n) => n)).toEqual([]); + await expect( + mapLimit([1, 2], 2, async (n) => { + if (n === 2) throw new Error('boom'); + return n; + }) + ).rejects.toThrow('boom'); + }); +}); diff --git a/src/app/features/albums/mapLimit.ts b/src/app/features/albums/mapLimit.ts new file mode 100644 index 00000000..e850add0 --- /dev/null +++ b/src/app/features/albums/mapLimit.ts @@ -0,0 +1,19 @@ +/** Like Promise.all(items.map(fn)) but with at most `limit` calls in flight; result order kept. */ +export const mapLimit = async ( + items: T[], + limit: number, + fn: (item: T) => Promise +): Promise => { + const out = new Array(items.length); + let next = 0; + const worker = async () => { + while (next < items.length) { + const i = next; + next += 1; + // eslint-disable-next-line no-await-in-loop + out[i] = await fn(items[i]); + } + }; + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)); + return out; +}; diff --git a/src/app/features/albums/publisherWire.test.ts b/src/app/features/albums/publisherWire.test.ts new file mode 100644 index 00000000..83bddf3a --- /dev/null +++ b/src/app/features/albums/publisherWire.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from 'vitest'; +import { buildAlbums, looseFiles, looseNote, looseNoteFor, TimelineEvent } from './buildAlbums'; + +// The Commons publisher as it posted before gamecult-ops #121: loose files (no album thread), a +// flat `org.gamecult.publish.replaces` key, no info.sha256. The sender must see that, not a +// silently empty or silently unfolded zone. +const MIND = '@eureka:gamecult.org'; +const file = ( + id: string, + name: string, + extra: Record = {}, + ts = 1 +): TimelineEvent => ({ + event_id: id, + type: 'm.room.message', + sender: MIND, + origin_server_ts: ts, + content: { msgtype: 'm.file', body: name, filename: name, url: `mxc://x/${id}`, ...extra }, +}); +const text = (id: string, body: string): TimelineEvent => ({ + event_id: id, + type: 'm.room.message', + sender: MIND, + origin_server_ts: 1, + content: { msgtype: 'm.text', body }, +}); + +describe('loose files from a poster that does not thread', () => { + it('are found, with the names the sender sees', () => { + const loose = looseFiles([file('$a', 'notes.md'), file('$b', 'plan.pdf')]); + expect(loose.map((f) => f.name)).toEqual(['notes.md', 'plan.pdf']); + }); + + it('are not the album roots, thread replies (by SDK root or by relation), edits or redactions', () => { + const events: TimelineEvent[] = [ + text('$root', 'handoff/eureka'), + { ...file('$r1', 'a.md'), threadRoot: '$root' }, + file('$r2', 'b.md', { + 'm.relates_to': { rel_type: 'm.thread', event_id: '$root' }, + }), + file('$edit', 'c.md', { 'm.relates_to': { rel_type: 'm.replace', event_id: '$r1' } }), + { ...file('$gone', 'd.md'), unsigned: { redacted_because: {} } }, + file('$loose', 'e.md'), + ]; + expect(looseFiles(events).map((f) => f.id)).toEqual(['$loose']); + }); + + it('a zone holding only loose files still has no albums, and the note says why', () => { + const events = [file('$a', 'notes.md')]; + expect(buildAlbums([])).toEqual([]); + expect(looseNote(looseFiles(events))).toBe( + '1 file was posted outside an album and is not listed here: notes.md. It is in the Timeline.' + ); + }); + + it('the note names three and counts the rest', () => { + const many = looseFiles(['a', 'b', 'c', 'd', 'e'].map((n) => file(`$${n}`, `${n}.md`))); + expect(looseNote(many)).toBe( + '5 files were posted outside an album and are not listed here: a.md, b.md, c.md and 2 more. They are in the Timeline.' + ); + expect(looseNote([])).toBe(''); + }); +}); + +describe('where the loose-files note shows', () => { + const loose = looseFiles([file('$a', 'notes.md')]); + const list = { open: false, encrypted: false, loading: false, error: false }; + + it('on the album list, and only there', () => { + expect(looseNoteFor(loose, list)).toContain('notes.md'); + expect(looseNoteFor(loose, { ...list, open: true })).toBe(''); + expect(looseNoteFor(loose, { ...list, encrypted: true })).toBe(''); + expect(looseNoteFor(loose, { ...list, loading: true })).toBe(''); + expect(looseNoteFor(loose, { ...list, error: true })).toBe(''); + }); + + it('says nothing when there are no loose files', () => { + expect(looseNoteFor([], list)).toBe(''); + }); + + it('names m.notice and m.sticker posts as not files', () => { + const notice = { ...text('$n', 'hi'), content: { msgtype: 'm.notice', body: 'hi' } }; + expect(looseFiles([notice])).toEqual([]); + }); + + it('a file with no name reads as unnamed', () => { + const bare: TimelineEvent = { ...file('$b', ''), content: { msgtype: 'm.file', body: '' } }; + expect(looseNote(looseFiles([bare]))).toContain('unnamed'); + }); +}); + +describe('the old wire shape inside an album', () => { + const root = text('$root', 'handoff/eureka'); + const albumOf = (replies: TimelineEvent[]) => buildAlbums([{ root, replies }], 'gamecult.org')[0]; + + it('a flat replaces key folds nothing: both versions show, none is hidden', () => { + const album = albumOf([ + file('$v1', 'plan.md', { 'org.gamecult.publish.sha256': 'aa' }, 1), + file('$v2', 'plan.md', { 'org.gamecult.publish.replaces': '$v1' }, 2), + ]); + expect(album.files.map((f) => f.id)).toEqual(['$v1', '$v2']); + expect(album.files.every((f) => f.earlier.length === 0)).toBe(true); + }); + + it('the nested shape from the fixed publisher folds as before', () => { + const album = albumOf([ + file('$v1', 'plan.md', {}, 1), + file('$v2', 'plan.md', { 'org.gamecult.publish': { replaces: '$v1' } }, 2), + ]); + expect(album.files.map((f) => f.id)).toEqual(['$v2']); + expect(album.files[0].earlier.map((f) => f.id)).toEqual(['$v1']); + }); + + it('a file with no info.sha256 lists fine, with no readers even when a receipt names it', () => { + const receipt: TimelineEvent = { + event_id: '$rc', + type: 'org.gamecult.file_received', + sender: '@void:gamecult.org', + origin_server_ts: 5, + content: { 'org.gamecult.file_received': { event_id: '$v1', sha256: 'aa', size: 1 } }, + }; + const album = albumOf([file('$v1', 'plan.md'), receipt]); + expect(album.count).toBe(1); + expect(album.files[0].readBy).toEqual([]); + }); +}); diff --git a/src/app/features/albums/readPages.test.ts b/src/app/features/albums/readPages.test.ts new file mode 100644 index 00000000..124a32a6 --- /dev/null +++ b/src/app/features/albums/readPages.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { Page, readPages } from './readPages'; + +const pager = (pages: Page[]) => { + const froms: (string | undefined)[] = []; + return { + froms, + fetchPage: async (from: string | undefined) => { + froms.push(from); + return pages[froms.length - 1] ?? {}; + }, + }; +}; + +describe('readPages', () => { + it('one page without a token is the whole read', async () => { + const p = pager([{ chunk: [1, 2] }]); + expect(await readPages(p.fetchPage)).toEqual({ items: [1, 2], truncated: false }); + expect(p.froms).toEqual([undefined]); + }); + + it('follows next_batch in order, passing each token back', async () => { + const p = pager([ + { chunk: [1], next_batch: 'a' }, + { chunk: [2], next_batch: 'b' }, + { chunk: [3] }, + ]); + expect(await readPages(p.fetchPage)).toEqual({ items: [1, 2, 3], truncated: false }); + expect(p.froms).toEqual([undefined, 'a', 'b']); + }); + + it('a missing chunk reads as empty', async () => { + expect(await readPages(async () => ({}))).toEqual({ items: [], truncated: false }); + }); + + it('an empty page ends the read even when it carries a token', async () => { + const p = pager([ + { chunk: [1], next_batch: 'a' }, + { chunk: [], next_batch: 'b' }, + ]); + expect(await readPages(p.fetchPage)).toEqual({ items: [1], truncated: false }); + expect(p.froms).toHaveLength(2); + }); + + it('the cap stops the read and says it was cut short', async () => { + const p = pager(Array.from({ length: 10 }, (_, i) => ({ chunk: [i], next_batch: `t${i}` }))); + expect(await readPages(p.fetchPage, 3)).toEqual({ items: [0, 1, 2], truncated: true }); + expect(p.froms).toHaveLength(3); + }); + + it('a last page exactly at the cap is not truncated', async () => { + const p = pager([{ chunk: [1], next_batch: 'a' }, { chunk: [2] }]); + expect((await readPages(p.fetchPage, 2)).truncated).toBe(false); + }); +}); diff --git a/src/app/features/albums/readPages.ts b/src/app/features/albums/readPages.ts new file mode 100644 index 00000000..a2b888e5 --- /dev/null +++ b/src/app/features/albums/readPages.ts @@ -0,0 +1,27 @@ +/** One page of a Matrix paginated endpoint: its events and the token for the next page, if any. */ +export type Page = { chunk?: T[]; next_batch?: string }; + +/** Pages read per endpoint at most, so a runaway zone cannot hold the phone in a loop. */ +export const MAX_PAGES = 20; + +/** + * Reads every page of an endpoint by following `next_batch`, up to `maxPages`. `truncated` is true + * when the cap stopped the read with more still to come, so a caller can say the list is partial. + */ +export const readPages = async ( + fetchPage: (from: string | undefined) => Promise>, + maxPages = MAX_PAGES +): Promise<{ items: T[]; truncated: boolean }> => { + const items: T[] = []; + let from: string | undefined; + for (let n = 0; n < maxPages; n += 1) { + // eslint-disable-next-line no-await-in-loop + const page = await fetchPage(from); + items.push(...(page.chunk ?? [])); + // An empty page ends the read even if a token is returned: a server that keeps handing out + // tokens with nothing behind them would otherwise spend every page of the cap. + if (!page.next_batch || !page.chunk?.length) return { items, truncated: false }; + from = page.next_batch; + } + return { items, truncated: true }; +}; diff --git a/src/app/features/albums/receiptStore.test.ts b/src/app/features/albums/receiptStore.test.ts new file mode 100644 index 00000000..c97202e5 --- /dev/null +++ b/src/app/features/albums/receiptStore.test.ts @@ -0,0 +1,116 @@ +import { EventEmitter } from 'events'; +import { describe, expect, it, vi } from 'vitest'; +import { MatrixEvent, Room, RoomEvent } from 'matrix-js-sdk'; +import { receiptStoreOf } from './receiptStore'; +import { readersOf } from './fileReceipt'; + +const HASH = 'a'.repeat(64); + +type Fake = { + id: string; + type: string; + sender: string; + ts: number; + content: object; + redacted?: boolean; +}; +const event = (f: Fake) => + ({ + getId: () => f.id, + getType: () => f.type, + getSender: () => f.sender, + getTs: () => f.ts, + getContent: () => f.content, + isRedacted: () => !!f.redacted, + } as unknown as MatrixEvent); +const receipt = (id: string, sender: string, fileId = '$f', over: Partial = {}): Fake => ({ + id, + type: 'org.gamecult.file_received', + sender, + ts: 1, + content: { 'org.gamecult.file_received': { event_id: fileId, sha256: HASH } }, + ...over, +}); + +const fakeRoom = (initial: Fake[] = []) => { + const emitter = new EventEmitter(); + emitter.setMaxListeners(0); + let events = initial; + const room = Object.assign(emitter, { + getUnfilteredTimelineSet: () => ({ + getLiveTimeline: () => ({ getEvents: () => events.map(event) }), + }), + }) as unknown as Room; + return { + room, + emitter, + set: (next: Fake[]) => { + events = next; + }, + }; +}; +const listeners = (e: EventEmitter) => + [RoomEvent.Timeline, RoomEvent.Redaction, RoomEvent.TimelineReset].map((n) => e.listenerCount(n)); + +describe('receiptStoreOf: one subscription per room', () => { + it('registers one listener per event for 200 file rows, and none once they are gone', () => { + const { room, emitter } = fakeRoom(); + const store = receiptStoreOf(room); + const off = Array.from({ length: 200 }, () => store.subscribe(() => undefined)); + expect(listeners(emitter)).toEqual([1, 1, 1]); + off.forEach((o) => o()); + expect(listeners(emitter)).toEqual([0, 0, 0]); + }); + + it('is one store per room', () => { + const a = fakeRoom(); + const b = fakeRoom(); + expect(receiptStoreOf(a.room)).toBe(receiptStoreOf(a.room)); + expect(receiptStoreOf(a.room)).not.toBe(receiptStoreOf(b.room)); + }); + + it('notifies rows when a receipt arrives, not for unrelated timeline events, and gives readersOf what it needs', () => { + const { room, emitter, set } = fakeRoom(); + const store = receiptStoreOf(room); + const cb = vi.fn(); + store.subscribe(cb); + expect(store.getSnapshot()).toEqual([]); + set([{ id: '$m', type: 'm.room.message', sender: '@x:gamecult.org', ts: 1, content: {} }]); + emitter.emit(RoomEvent.Timeline); + expect(cb).not.toHaveBeenCalled(); + set([receipt('$r', '@void:gamecult.org')]); + emitter.emit(RoomEvent.Timeline); + expect(cb).toHaveBeenCalledTimes(1); + const snap = store.getSnapshot(); + expect( + readersOf(snap, '$f', { info: { sha256: HASH } }, 'gamecult.org').map((r) => r.sender) + ).toEqual(['@void:gamecult.org']); + emitter.emit(RoomEvent.Timeline); + expect(store.getSnapshot()).toBe(snap); + }); + + it('rescans on a redaction and on a timeline reset', () => { + const { room, emitter, set } = fakeRoom([receipt('$r', '@void:gamecult.org')]); + const store = receiptStoreOf(room); + const cb = vi.fn(); + store.subscribe(cb); + expect(store.getSnapshot()).toHaveLength(1); + set([receipt('$r', '@void:gamecult.org', '$f', { redacted: true })]); + emitter.emit(RoomEvent.Redaction); + expect(cb).toHaveBeenCalledTimes(1); + expect( + readersOf(store.getSnapshot(), '$f', { info: { sha256: HASH } }, 'gamecult.org') + ).toEqual([]); + set([]); + emitter.emit(RoomEvent.TimelineReset); + expect(cb).toHaveBeenCalledTimes(2); + expect(store.getSnapshot()).toEqual([]); + }); + + it('picks up what is already loaded when the first row subscribes', () => { + const { room } = fakeRoom([receipt('$r', '@aqua:gamecult.org')]); + const store = receiptStoreOf(room); + store.subscribe(() => undefined); + expect(store.getSnapshot()).toHaveLength(1); + }); +}); diff --git a/src/app/features/albums/receiptStore.ts b/src/app/features/albums/receiptStore.ts new file mode 100644 index 00000000..1f69aba9 --- /dev/null +++ b/src/app/features/albums/receiptStore.ts @@ -0,0 +1,79 @@ +import type { MatrixEvent, Room } from 'matrix-js-sdk'; +import { RoomEvent } from 'matrix-js-sdk'; +import { FILE_RECEIVED, ReceiptEvent } from './fileReceipt'; + +/** + * One subscription per room to the file receipts in its loaded timeline, shared by every file row: a row costs a + * callback in a Set, not a Room listener (matrix-js-sdk warns past 100 listeners). The snapshot is the receipt events + * themselves; each row derives its own readers from it with `readersOf`, so the rules (sender, hash, reference, one per + * Mind) live in one place and the store holds no verdicts. The snapshot changes identity only when the set of receipts + * or one's redaction state does, so rows re-render for a receipt, not for every timeline event. + */ +export type ReceiptStore = { + subscribe: (onChange: () => void) => () => void; + getSnapshot: () => ReceiptEvent[]; +}; + +const asReceiptEvent = (e: MatrixEvent): ReceiptEvent => ({ + event_id: e.getId(), + type: e.getType(), + sender: e.getSender(), + origin_server_ts: e.getTs(), + content: e.getContent(), + unsigned: e.isRedacted() ? { redacted_because: true } : undefined, +}); + +const same = (a: ReceiptEvent[], b: ReceiptEvent[]): boolean => + a.length === b.length && + a.every( + (r, i) => + r.event_id === b[i].event_id && + !r.unsigned?.redacted_because === !b[i].unsigned?.redacted_because + ); + +// A redaction removes a tick and a reset drops the window: both rescan, not only a new event. +const EVENTS = [RoomEvent.Timeline, RoomEvent.Redaction, RoomEvent.TimelineReset] as const; + +const make = (room: Room): ReceiptStore => { + const subscribers = new Set<() => void>(); + let snapshot: ReceiptEvent[] = []; + const read = (): boolean => { + const next = room + .getUnfilteredTimelineSet() + .getLiveTimeline() + .getEvents() + .filter((e) => e.getType() === FILE_RECEIVED) + .map(asReceiptEvent); + if (same(snapshot, next)) return false; + snapshot = next; + return true; + }; + const scan = () => { + if (read()) subscribers.forEach((cb) => cb()); + }; + return { + subscribe: (onChange) => { + subscribers.add(onChange); + if (subscribers.size === 1) { + EVENTS.forEach((ev) => room.on(ev, scan)); + read(); // the first row sees what is already loaded; it reads the snapshot after subscribing + } + return () => { + subscribers.delete(onChange); + if (subscribers.size === 0) EVENTS.forEach((ev) => room.off(ev, scan)); + }; + }, + getSnapshot: () => snapshot, + }; +}; + +const stores = new WeakMap(); + +export const receiptStoreOf = (room: Room): ReceiptStore => { + let store = stores.get(room); + if (!store) { + store = make(room); + stores.set(room, store); + } + return store; +}; diff --git a/src/app/features/albums/shareController.test.ts b/src/app/features/albums/shareController.test.ts new file mode 100644 index 00000000..b163b736 --- /dev/null +++ b/src/app/features/albums/shareController.test.ts @@ -0,0 +1,380 @@ +import { describe, expect, it } from 'vitest'; +import { Picked, ShareDeps, createShareController } from './shareController'; +import { ShareResult } from './shareSet'; + +const file = (name: string, size?: number): Picked => ({ name, type: 'audio/ogg', size }); +const MB = 1024 * 1024; +const ok = (n: number): ShareResult => ({ albumId: '$r', sent: n, failed: [], failedAt: [] }); + +/** A run the test settles by hand, so "in flight" is a state the test can stand in. */ +const hand = () => { + const calls: { files: Picked[]; title: string; onFile: () => void; signal: AbortSignal }[] = []; + let release: (r: ShareResult) => void = () => undefined; + let fail: (e: Error) => void = () => undefined; + const run: ShareDeps['run'] = (files, title, onFile, signal) => { + calls.push({ files, title, onFile, signal }); + return new Promise((resolve, reject) => { + release = resolve; + fail = reject; + }); + }; + return { calls, run, release: (r: ShareResult) => release(r), fail: (e: Error) => fail(e) }; +}; + +const make = (over: Partial> = {}, roomId = '!zone:x') => { + const h = hand(); + const sentTo: string[] = []; + const c = createShareController({ + roomId, + isEncrypted: () => false, + run: h.run, + onSent: () => sentTo.push(roomId), + ...over, + }); + return { c, h, sentTo }; +}; + +describe('picking', () => { + it('starts idle with nothing picked, and a pick holds the files in order', () => { + const { c } = make(); + expect(c.getState().files).toEqual([]); + expect(c.getState().phase).toEqual({ kind: 'idle' }); + c.pick([file('a'), file('b')]); + expect(c.getState().files.map((f) => f.name)).toEqual(['a', 'b']); + }); + + it('Cancel empties the pick and the title and bumps the input epoch', () => { + const { c } = make(); + c.pick([file('a')]); + c.setTitle('Take 4'); + const before = c.getState().epoch; + c.cancel(); + expect(c.getState()).toMatchObject({ files: [], title: '', epoch: before + 1 }); + }); + + it('subscribers hear every change and stop hearing after unsubscribe', () => { + const { c } = make(); + let heard = 0; + const off = c.subscribe(() => { + heard += 1; + }); + c.pick([file('a')]); + off(); + c.pick([]); + expect(heard).toBe(1); + }); +}); + +describe('the Send guard', () => { + it('a double tap starts one send', async () => { + const { c, h } = make(); + c.pick([file('a'), file('b')]); + const first = c.send(); + const second = c.send(); + expect(h.calls).toHaveLength(1); + h.release(ok(2)); + await Promise.all([first, second]); + expect(h.calls).toHaveLength(1); + }); + + it('a re-mounted panel for the same room cannot start a second send while one is in flight', async () => { + const { c, h } = make(); + c.pick([file('a')]); + const first = c.send(); + // The page was left and returned to: a new controller, nothing picked in it but a new pick. + const again = make({}, '!zone:x'); + again.c.pick([file('b')]); + await again.c.send(); + expect(again.h.calls).toHaveLength(0); + h.release(ok(1)); + await first; + // Once the first has settled the room is free again. + const next = again.c.send(); + expect(again.h.calls).toHaveLength(1); + again.h.release(ok(1)); + await next; + }); + + it('another room is not blocked by a send in flight', async () => { + const a = make({}, '!a:x'); + const b = make({}, '!b:x'); + a.c.pick([file('a')]); + b.c.pick([file('b')]); + const sendA = a.c.send(); + const sendB = b.c.send(); + expect(b.h.calls).toHaveLength(1); + a.h.release(ok(1)); + b.h.release(ok(1)); + await Promise.all([sendA, sendB]); + }); + + it('sends nothing when nothing is picked', async () => { + const { c, h } = make(); + await c.send(); + expect(h.calls).toHaveLength(0); + }); + + it('a send that throws frees the room and says so', async () => { + const { c, h } = make(); + c.pick([file('a')]); + const p = c.send(); + h.fail(new Error('M_FORBIDDEN')); + await p; + expect(c.getState().phase).toEqual({ kind: 'done', text: 'Could not share: M_FORBIDDEN' }); + expect(c.getState().files).toHaveLength(1); + const retry = c.send(); + expect(h.calls).toHaveLength(2); + h.release(ok(1)); + await retry; + }); +}); + +describe('progress and results', () => { + it('counts files as they settle, passing the title through', async () => { + const { c, h } = make(); + c.pick([file('a'), file('b')]); + c.setTitle('Take 4'); + const p = c.send(); + expect(c.getState().phase).toEqual({ kind: 'sending', done: 0 }); + expect(h.calls[0].title).toBe('Take 4'); + h.calls[0].onFile(); + expect(c.getState().phase).toEqual({ kind: 'sending', done: 1 }); + h.release(ok(2)); + await p; + expect(c.getState().phase).toEqual({ kind: 'done', text: 'Shared 2 files.' }); + }); + + it('full success clears the pick, tells the room list, and remounts the input', async () => { + const { c, h, sentTo } = make(); + c.pick([file('a')]); + const { epoch } = c.getState(); + const p = c.send(); + h.release(ok(1)); + await p; + expect(c.getState()).toMatchObject({ files: [], title: '', epoch: epoch + 1 }); + expect(sentTo).toEqual(['!zone:x']); + }); + + it('failed files stay picked by position, so a same-named twin that went is not re-sent', async () => { + const { c, h } = make(); + const twins = [file('a.ogg'), file('a.ogg'), file('b.ogg')]; + c.pick(twins); + const p = c.send(); + h.release({ albumId: '$r', sent: 2, failed: ['a.ogg'], failedAt: [1] }); + await p; + expect(c.getState().files).toEqual([twins[1]]); + expect(c.getState().phase).toEqual({ kind: 'done', text: 'Shared 2. Not sent: a.ogg' }); + }); + + it('when every upload failed everything stays picked and the note says nothing was shared', async () => { + const { c, h } = make(); + const picked = [file('a'), file('b')]; + c.pick(picked); + const p = c.send(); + h.release({ albumId: '$r', sent: 0, failed: ['a', 'b'], failedAt: [0, 1] }); + await p; + expect(c.getState().files).toEqual(picked); + expect(c.getState().phase).toEqual({ + kind: 'done', + text: 'Nothing was shared. Not sent: a, b', + }); + }); + + it('a new pick after a result drops the result note', async () => { + const { c, h } = make(); + c.pick([file('a')]); + const p = c.send(); + h.release(ok(1)); + await p; + c.pick([file('b')]); + expect(c.getState().phase).toEqual({ kind: 'idle' }); + }); +}); + +describe('what a send leaves behind', () => { + it('keeps the title when some files failed, so the retry has the same name', async () => { + const { c, h } = make(); + c.pick([file('a'), file('b')]); + c.setTitle('Take 4'); + const p = c.send(); + h.release({ albumId: '$r', sent: 1, failed: ['b'], failedAt: [1] }); + await p; + expect(c.getState().title).toBe('Take 4'); + }); + + it('tells the room list only after the send settled, and not when it threw', async () => { + const { c, h, sentTo } = make(); + c.pick([file('a')]); + const p = c.send(); + expect(sentTo).toEqual([]); + h.release(ok(1)); + await p; + expect(sentTo).toEqual(['!zone:x']); + + const bad = make(); + bad.c.pick([file('a')]); + const q = bad.c.send(); + bad.h.fail(new Error('boom')); + await q; + expect(bad.sentTo).toEqual([]); + }); + + it('a success lands in one notification: the pick is never empty while still sending', async () => { + const { c, h } = make(); + c.pick([file('a')]); + const p = c.send(); + const seen: string[] = []; + c.subscribe(() => { + const st = c.getState(); + seen.push(`${st.files.length}:${st.phase.kind}`); + }); + h.release(ok(1)); + await p; + expect(seen).toEqual(['0:done']); + }); +}); + +describe('an encrypted room', () => { + it('says so, sends nothing and keeps the pick', async () => { + const { c, h } = make({ isEncrypted: () => true }); + c.pick([file('a')]); + await c.send(); + expect(c.getState().phase).toEqual({ + kind: 'done', + text: 'Not shared: this room is encrypted.', + }); + expect(c.getState().files).toHaveLength(1); + expect(h.calls).toHaveLength(0); + }); + + it('is checked at Send, not at mount: a room that turns encrypted after the pick is refused', async () => { + let encrypted = false; + const { c, h } = make({ isEncrypted: () => encrypted }); + c.pick([file('a')]); + encrypted = true; + await c.send(); + expect(h.calls).toHaveLength(0); + expect(c.getState().phase).toMatchObject({ + kind: 'done', + text: 'Not shared: this room is encrypted.', + }); + }); +}); + +describe('Stop', () => { + it('aborts the signal of the send in flight, and only that send', async () => { + const { c, h } = make(); + c.pick([file('a'), file('b')]); + c.stop(); // nothing running: nothing to abort + const sending = c.send(); + expect(h.calls[0].signal.aborted).toBe(false); + c.stop(); + expect(h.calls[0].signal.aborted).toBe(true); + h.release({ albumId: '$r', sent: 0, failed: ['a', 'b'], failedAt: [0, 1] }); + await sending; + expect(c.getState().files.map((f) => f.name)).toEqual(['a', 'b']); + // the next send gets a fresh signal + const again = c.send(); + expect(h.calls[1].signal.aborted).toBe(false); + h.release(ok(2)); + await again; + }); +}); + +describe('files over the homeserver cap', () => { + const cap = 50 * MB; + const capped = () => make({ maxBytes: () => cap }); + + it('are named in the note and never sent; the rest go', async () => { + const { c, h } = capped(); + c.pick([file('small.ogg', 3 * MB), file('film.mp4', 80 * MB), file('also.ogg', 1)]); + const sent = c.send(); + expect(h.calls[0].files.map((f) => f.name)).toEqual(['small.ogg', 'also.ogg']); + expect(c.getState().files.map((f) => f.name)).toEqual(['small.ogg', 'also.ogg']); + h.release(ok(2)); + await sent; + expect(c.getState().phase).toEqual({ + kind: 'done', + text: 'Shared 2 files. Too large for this server (limit 50 MB): film.mp4', + }); + expect(c.getState().files).toEqual([]); + }); + + it('are not kept for a retry that cannot help them, while a failed small file is', async () => { + const { c, h } = capped(); + c.pick([file('a.ogg', 1), file('big.wav', 60 * MB)]); + const sent = c.send(); + h.release({ albumId: '$r', sent: 0, failed: ['a.ogg'], failedAt: [0] }); + await sent; + expect(c.getState().files.map((f) => f.name)).toEqual(['a.ogg']); + }); + + it('a pick that is all over the cap sends nothing: no run, so no album root, and the pick clears', async () => { + const { c, h, sentTo } = capped(); + c.pick([file('a.mp4', 51 * MB), file('b.mp4', 99 * MB)]); + const { epoch } = c.getState(); + await c.send(); + expect(h.calls).toHaveLength(0); + expect(sentTo).toEqual([]); + expect(c.getState()).toMatchObject({ files: [], epoch: epoch + 1 }); + expect(c.getState().phase).toEqual({ + kind: 'done', + text: 'Nothing was shared. Too large for this server (limit 50 MB): a.mp4, b.mp4', + }); + }); + + it('a file of exactly the cap goes: the server refuses only what is larger', async () => { + const { c, h } = capped(); + c.pick([file('edge.bin', cap)]); + const sent = c.send(); + expect(h.calls[0].files).toHaveLength(1); + h.release(ok(1)); + await sent; + }); + + it('the room is not held busy by an all-refused pick, so the next pick sends', async () => { + const { c, h } = capped(); + c.pick([file('a.mp4', 99 * MB)]); + await c.send(); + c.pick([file('b.ogg', 1)]); + const sent = c.send(); + expect(h.calls).toHaveLength(1); + h.release(ok(1)); + await sent; + }); + + it('a server that states no cap, or a file with no size, refuses nothing', async () => { + const none = make(); + none.c.pick([file('a', 10 ** 12)]); + const s1 = none.c.send(); + expect(none.h.calls[0].files).toHaveLength(1); + none.h.release(ok(1)); + await s1; + const unsized = make({ maxBytes: () => cap }, '!other:x'); + unsized.c.pick([file('b')]); + const s2 = unsized.c.send(); + expect(unsized.h.calls[0].files).toHaveLength(1); + unsized.h.release(ok(1)); + await s2; + }); + + it('a fractional cap reads with one decimal', async () => { + const { c } = make({ maxBytes: () => 1.5 * MB }); + c.pick([file('a', 2 * MB)]); + await c.send(); + expect(c.getState().phase).toMatchObject({ text: expect.stringContaining('limit 1.5 MB') }); + }); + + it('a run that throws still names the refused files, which are not kept', async () => { + const { c, h } = capped(); + c.pick([file('a.ogg', 1), file('big.wav', 60 * MB)]); + const sent = c.send(); + h.fail(new Error('network down')); + await sent; + expect(c.getState().phase).toEqual({ + kind: 'done', + text: 'Could not share: network down. Too large for this server (limit 50 MB): big.wav', + }); + expect(c.getState().files.map((f) => f.name)).toEqual(['a.ogg']); + }); +}); diff --git a/src/app/features/albums/shareController.ts b/src/app/features/albums/shareController.ts new file mode 100644 index 00000000..72f786c9 --- /dev/null +++ b/src/app/features/albums/shareController.ts @@ -0,0 +1,135 @@ +// The share panel's state machine, apart from React and Matrix: what is picked, what phase the +// send is in, and the rules that keep a room to one send at a time. ShareSet.tsx draws it. +import { ShareResult, remainingAfter, resultNote } from './shareSet'; + +export type Picked = { name: string; type: string; size?: number }; + +export type SharePhase = + | { kind: 'idle' } + | { kind: 'sending'; done: number } + | { kind: 'done'; text: string }; + +export type ShareState = { + files: F[]; + title: string; + phase: SharePhase; + /** Bumps whenever the pick is emptied, so the file input can be remounted and re-pick the same files. */ + epoch: number; +}; + +export const ENCRYPTED_NOTE = 'Not shared: this room is encrypted.'; + +const MIB = 1024 * 1024; + +/** + * Files the homeserver would refuse for size (its `m.upload.size`, which Synapse enforces as a + * limit, so a file of exactly that size goes), split from the rest before anything is sent. A + * missing cap or size refuses nothing. + */ +export const splitBySize = (files: F[], cap: number | undefined) => { + const over = (f: F) => cap !== undefined && f.size !== undefined && f.size > cap; + return { sendable: files.filter((f) => !over(f)), tooLarge: files.filter(over) }; +}; + +const tooLargeNote = (names: string[], cap: number): string => + `Too large for this server (limit ${Math.round((cap / MIB) * 10) / 10} MB): ${names.join(', ')}`; + +export type ShareDeps = { + roomId: string; + isEncrypted: () => boolean; + /** Sends the files as one album; reports each settled file through onFile. */ + run: (files: F[], title: string, onFile: () => void, signal: AbortSignal) => Promise; + /** The homeserver's upload cap in bytes (`m.upload.size`), when it states one. */ + maxBytes?: () => number | undefined; + /** Called after a send that reached the room, whatever it left unsent. */ + onSent?: () => void; +}; + +/** Rooms with a share in flight. Module-level: leaving the page unmounts the panel, not the send. */ +const sending = new Set(); + +export const createShareController = (deps: ShareDeps) => { + let state: ShareState = { files: [], title: '', phase: { kind: 'idle' }, epoch: 0 }; + const listeners = new Set<() => void>(); + let running: AbortController | undefined; + const set = (patch: Partial>) => { + state = { ...state, ...patch }; + listeners.forEach((l) => l()); + }; + + const clear = () => set({ files: [], title: '', epoch: state.epoch + 1 }); + + const send = async () => { + if (sending.has(deps.roomId) || state.files.length === 0) return; + if (deps.isEncrypted()) { + set({ phase: { kind: 'done', text: ENCRYPTED_NOTE } }); + return; + } + // Files over the cap are named and dropped from the pick before any upload or album root: a + // retry cannot help them, and a root with nothing under it is a lone message nobody lists. + const cap = deps.maxBytes?.(); + const { sendable, tooLarge } = splitBySize(state.files, cap); + const sizeNote = tooLarge.length + ? tooLargeNote( + tooLarge.map((f) => f.name), + cap as number + ) + : ''; + if (sendable.length === 0) { + const phase: SharePhase = { kind: 'done', text: `Nothing was shared. ${sizeNote}` }; + set({ files: [], title: '', epoch: state.epoch + 1, phase }); + return; + } + sending.add(deps.roomId); + const abort = new AbortController(); + running = abort; + const { title } = state; + const files = sendable; + set({ files, phase: { kind: 'sending', done: 0 } }); + let settled = 0; + try { + const res = await deps.run( + files, + title, + () => { + settled += 1; + set({ phase: { kind: 'sending', done: settled } }); + }, + abort.signal + ); + deps.onSent?.(); + const left = remainingAfter(files, res); + const phase: SharePhase = { + kind: 'done', + text: sizeNote ? `${resultNote(res)} ${sizeNote}` : resultNote(res), + }; + // One notification: no state is ever drawn with the pick emptied and the send still running. + if (left.length === 0) set({ files: [], title: '', epoch: state.epoch + 1, phase }); + else set({ files: left, phase }); + } catch (e: any) { + const failure = `Could not share: ${e?.message ?? 'send failed'}`; + set({ phase: { kind: 'done', text: sizeNote ? `${failure}. ${sizeNote}` : failure } }); + } finally { + running = undefined; + sending.delete(deps.roomId); + } + }; + + return { + getState: () => state, + subscribe: (l: () => void) => { + listeners.add(l); + return () => { + listeners.delete(l); + }; + }, + pick: (files: F[]) => set({ files, phase: { kind: 'idle' } }), + setTitle: (title: string) => set({ title }), + cancel: clear, + /** Stops a send in flight: the running upload is aborted and the rest are left picked. */ + stop: () => running?.abort(), + send, + }; +}; + +export type ShareController = ReturnType>; diff --git a/src/app/features/albums/shareSet.test.ts b/src/app/features/albums/shareSet.test.ts new file mode 100644 index 00000000..8fc4a8b8 --- /dev/null +++ b/src/app/features/albums/shareSet.test.ts @@ -0,0 +1,328 @@ +import { describe, expect, it } from 'vitest'; +import config from '../../../../config.json'; +import { ZoneEvent, buildAlbums } from './buildAlbums'; +import { + DEFAULT_TITLE, + ShareFile, + SharePort, + CANCELLED, + freshest, + refusalOf, + remainingAfter, + resultNote, + sendShareSet, + threadRelation, +} from './shareSet'; + +const kindOf = (f: ShareFile) => (f.mimeType.startsWith('audio') ? 'm.audio' : 'm.file'); + +/** A fake room: records what is sent, as the events Synapse would return from /relations. */ +const room = (failOn: string[] = []) => { + const events: ZoneEvent[] = []; + const port: SharePort = { + send: async (content) => { + const id = `$e${events.length + 1}`; + events.push({ + event_id: id, + type: 'm.room.message', + sender: '@aqua:chat.gamecult.org', + origin_server_ts: 1000 + events.length, + content, + }); + return id; + }, + contentOf: async (f) => { + if (failOn.includes(f.name)) throw new Error('upload failed'); + return { + msgtype: kindOf(f), + body: f.name, + filename: f.name, + url: `mxc://x/${f.name}`, + info: { mimetype: f.mimeType }, + }; + }, + }; + const album = () => { + const [root, ...replies] = events; + return buildAlbums([{ root, replies }], 'chat.gamecult.org')[0]; + }; + return { events, port, album }; +}; + +const clips: ShareFile[] = [ + { name: 'a.ogg', mimeType: 'audio/ogg' }, + { name: 'b.ogg', mimeType: 'audio/ogg' }, + { name: 'c.txt', mimeType: 'text/plain' }, +]; + +describe('sendShareSet', () => { + it('reads back as one album of the files in pick order', async () => { + const r = room(); + const res = await sendShareSet(r.port, 'Take 4', clips); + expect(res).toEqual({ albumId: '$e1', sent: 3, failed: [], failedAt: [], reasons: [] }); + const album = r.album(); + expect(album.title).toBe('Take 4'); + expect(album.files.map((f) => [f.name, f.kind])).toEqual([ + ['a.ogg', 'audio'], + ['b.ogg', 'audio'], + ['c.txt', 'file'], + ]); + }); + + it('threads every file under the root, each falling back to the previous event', async () => { + const r = room(); + await sendShareSet(r.port, 't', clips); + const rels = r.events.slice(1).map((e) => e.content?.['m.relates_to']); + expect(rels).toEqual([ + threadRelation('$e1', '$e1'), + threadRelation('$e1', '$e2'), + threadRelation('$e1', '$e3'), + ]); + expect(r.events[0].content?.['m.relates_to']).toBeUndefined(); + }); + + it('titles an untitled share', async () => { + const r = room(); + await sendShareSet(r.port, ' ', clips.slice(0, 1)); + expect(r.album().title).toBe(DEFAULT_TITLE); + }); + + it('names a failed file and still sends the rest', async () => { + const r = room(['b.ogg']); + const res = await sendShareSet(r.port, 't', clips); + expect(res.failed).toEqual(['b.ogg']); + expect(res.sent).toBe(2); + expect(r.album().files.map((f) => f.name)).toEqual(['a.ogg', 'c.txt']); + // the next file falls back to the last event that exists, not to the failed one + expect(r.events[2].content?.['m.relates_to']).toEqual(threadRelation('$e1', '$e2')); + }); + + it('throws when the root cannot be sent, uploading nothing', async () => { + let uploads = 0; + const port: SharePort = { + send: async () => { + throw new Error('M_FORBIDDEN'); + }, + contentOf: async () => { + uploads += 1; + return {}; + }, + }; + await expect(sendShareSet(port, 't', clips)).rejects.toThrow('M_FORBIDDEN'); + expect(uploads).toBe(0); + }); +}); + +describe('the share flag', () => { + it('ships off', () => { + expect(config.fileZoneShare).toEqual({ enabled: false }); + }); +}); + +describe('after a send', () => { + it('keeps the failed one of two same-named files, not both', async () => { + const twins: ShareFile[] = [ + { name: 'a.ogg', mimeType: 'audio/ogg' }, + { name: 'a.ogg', mimeType: 'audio/ogg' }, + ]; + let n = 0; + const r = room(); + const port: SharePort = { + ...r.port, + contentOf: async (f) => { + n += 1; + if (n === 2) throw new Error('second upload failed'); + return r.port.contentOf(f); + }, + }; + const res = await sendShareSet(port, 't', twins); + expect(res.failedAt).toEqual([1]); + expect(remainingAfter(twins, res)).toEqual([twins[1]]); + }); + + it('keeps only the failed files picked, so a retry is one tap', async () => { + const r = room(['b.ogg']); + const res = await sendShareSet(r.port, 't', clips); + expect(remainingAfter(clips, res).map((f) => f.name)).toEqual(['b.ogg']); + expect(resultNote(res)).toBe('Shared 2. Not sent: b.ogg'); + }); + + it('keeps everything and says nothing was shared when every upload failed', async () => { + const r = room(clips.map((c) => c.name)); + const res = await sendShareSet(r.port, 't', clips); + expect(remainingAfter(clips, res)).toEqual(clips); + expect(resultNote(res)).toBe('Nothing was shared. Not sent: a.ogg, b.ogg, c.txt'); + }); + + it('clears the pick on full success', async () => { + const r = room(); + const res = await sendShareSet(r.port, 't', clips); + expect(remainingAfter(clips, res)).toEqual([]); + expect(resultNote(res)).toBe('Shared 3 files.'); + }); +}); + +describe('adding to an existing album', () => { + it('sends no root; each file threads under the album, the first after `lastId`', async () => { + const r = room(); + const sent: Record[] = []; + const port = { + ...r.port, + send: async (c: Record) => { + sent.push(c); + return `$n${sent.length}`; + }, + }; + const res = await sendShareSet( + port, + { albumId: '$root', lastId: '$last', lastTs: 1 }, + clips.slice(0, 2) + ); + expect(res.albumId).toBe('$root'); + expect(res.sent).toBe(2); + expect(sent.map((c) => c.msgtype)).toEqual(['m.audio', 'm.audio']); + expect(sent.map((c) => c['m.relates_to'])).toEqual([ + threadRelation('$root', '$last'), + threadRelation('$root', '$n1'), + ]); + }); +}); + +describe('refusals', () => { + const refuse = (errcode: string): SharePort => ({ + send: async () => '$e', + contentOf: async () => { + throw Object.assign(new Error('raw server text'), { errcode }); + }, + }); + + it('names the three upload refusals and nothing else', () => { + expect(refusalOf({ errcode: 'M_TOO_LARGE' })).toBe('too large for this server'); + expect(refusalOf({ errcode: 'M_FORBIDDEN' })).toBe('not allowed to upload here'); + expect(refusalOf({ errcode: 'M_LIMIT_EXCEEDED' })).toBe( + 'uploading too fast, try again shortly' + ); + expect(refusalOf({ errcode: 'M_UNKNOWN' })).toBeUndefined(); + expect(refusalOf({ errcode: 'constructor' })).toBeUndefined(); + expect(refusalOf(new Error('x'))).toBeUndefined(); + expect(refusalOf(null)).toBeUndefined(); + }); + + it('the note carries the reason beside each refused file', async () => { + const res = await sendShareSet(refuse('M_TOO_LARGE'), 't', clips.slice(0, 2)); + expect(resultNote(res)).toBe( + 'Nothing was shared. Not sent: a.ogg (too large for this server), b.ogg (too large for this server)' + ); + }); + + it('a failure the client saw, not the server, is named too', async () => { + const dropped: SharePort = { + send: async () => '$e', + contentOf: async () => { + throw Object.assign(new Error('x'), { + errcode: 'AbortError', + info: { aborted: true, progressed: true }, + }); + }, + }; + const res = await sendShareSet(dropped, 't', clips.slice(0, 1)); + expect(resultNote(res)).toBe( + 'Nothing was shared. Not sent: a.ogg (the connection dropped partway through)' + ); + }); + + it('an unnamed failure lists the bare name', async () => { + const res = await sendShareSet(refuse('M_UNKNOWN'), 't', clips.slice(0, 1)); + expect(resultNote(res)).toBe('Nothing was shared. Not sent: a.ogg'); + }); +}); + +describe('stopping a send', () => { + /** A port whose upload of `stopAt` aborts the signal as it runs, as the Stop button does mid-upload. */ + const stopping = (stopAt: string, abortAfterUpload = false) => { + const ac = new AbortController(); + const r = room(); + const port: SharePort = { + ...r.port, + signal: ac.signal, + contentOf: async (f) => { + if (f.name !== stopAt) return r.port.contentOf(f); + ac.abort(); + if (abortAfterUpload) return r.port.contentOf(f); + throw Object.assign(new Error('aborted'), { name: 'AbortError' }); + }, + }; + return { port, r }; + }; + + it('an upload aborted mid-file leaves that file and the rest unsent, named as stopped', async () => { + const { port, r } = stopping('b.ogg'); + const res = await sendShareSet(port, 't', clips); + expect(res.sent).toBe(1); + expect(res.failed).toEqual(['b.ogg', 'c.txt']); + expect(res.failedAt).toEqual([1, 2]); + expect(res.reasons).toEqual([CANCELLED, CANCELLED]); + expect(r.album().files.map((f) => f.name)).toEqual(['a.ogg']); + expect(remainingAfter(clips, res).map((f) => f.name)).toEqual(['b.ogg', 'c.txt']); + expect(resultNote(res)).toBe('Shared 1. Not sent: b.ogg (stopped), c.txt (stopped)'); + }); + + it('a file whose upload finished after the stop is not sent', async () => { + const { port, r } = stopping('b.ogg', true); + const res = await sendShareSet(port, 't', clips); + expect(res.sent).toBe(1); + expect(res.failedAt).toEqual([1, 2]); + expect(r.album().files.map((f) => f.name)).toEqual(['a.ogg']); + }); + + it('a signal already aborted starts nothing and sends everything back as unsent', async () => { + const r = room(); + const ac = new AbortController(); + ac.abort(); + let uploads = 0; + const res = await sendShareSet( + { + ...r.port, + signal: ac.signal, + contentOf: async (f) => { + uploads += 1; + return r.port.contentOf(f); + }, + }, + { albumId: '$a', lastId: '$l', lastTs: 1 }, + clips + ); + expect(uploads).toBe(0); + expect(res.sent).toBe(0); + expect(res.failedAt).toEqual([0, 1, 2]); + expect(r.events).toEqual([]); + }); + + it('an abort that is not a stop is an ordinary failure', async () => { + const r = room(['b.ogg']); + const res = await sendShareSet({ ...r.port, signal: new AbortController().signal }, 't', clips); + expect(res.failedAt).toEqual([1]); + expect(res.reasons).toEqual([undefined]); + }); +}); + +describe('freshest', () => { + const album = { albumId: '$alb', lastId: '$old', lastTs: 100 }; + it('moves lastId to a newer event of the same thread', () => { + expect( + freshest(album, [ + { id: '$mid', ts: 150, threadRoot: '$alb' }, + { id: '$new', ts: 200, threadRoot: '$alb' }, + ]) + ).toEqual({ albumId: '$alb', lastId: '$new', lastTs: 200 }); + }); + it('ignores older events and events of other threads or none', () => { + expect( + freshest(album, [ + { id: '$older', ts: 50, threadRoot: '$alb' }, + { id: '$other', ts: 900, threadRoot: '$else' }, + { id: '$loose', ts: 900 }, + ]) + ).toEqual(album); + }); +}); diff --git a/src/app/features/albums/shareSet.ts b/src/app/features/albums/shareSet.ts new file mode 100644 index 00000000..007d7e91 --- /dev/null +++ b/src/app/features/albums/shareSet.ts @@ -0,0 +1,138 @@ +// Share a set of files at once: one album root (a text message) and one thread reply per file. +// That is exactly the shape buildAlbums reads, so a set shared here and a set a Mind's mouth posts +// are the same thing to every viewer. Ports are injected so the order and the failure rules are +// testable without a homeserver. See docs/file-zone.md, "Share a set". + +import { uploadReason } from '../../utils/uploadReason'; + +export type ShareFile = { name: string; mimeType: string }; + +/** What sendShareSet needs from the outside world. */ +export type SharePort = { + /** Send an event and return its id. */ + send: (content: Record) => Promise; + /** Upload one file and build its message content (msgtype, url, info incl. sha256). */ + contentOf: (file: F) => Promise>; + /** Called after each file settles, for progress. */ + onFile?: (index: number, result: 'sent' | 'failed') => void; + /** Aborted to stop the send: the running upload is the port's to cancel, the rest are not started. */ + signal?: AbortSignal; +}; + +export type ShareResult = { + /** The album root's event id; present as soon as the root is sent. */ + albumId: string; + sent: number; + failed: string[]; + /** Positions in the files array of those that failed; names can repeat, positions cannot. */ + failedAt: number[]; + /** Why each failed file failed, parallel to `failed`, when the homeserver's refusal is one we name. */ + reasons?: (string | undefined)[]; +}; + +/** An album that already exists: new files join its thread after `lastId` (sent at `lastTs`), with no new root. */ +export type ExistingAlbum = { albumId: string; lastId: string; lastTs: number }; + +/** An event as the room's live timeline holds it: its id, time, and the thread root it belongs to. */ +export type ThreadEvent = { id: string; ts: number; threadRoot?: string }; + +/** + * `album` with `lastId` moved to the newest event the room's timeline holds in that thread, when it + * is newer than the one the album list knew. The list refreshes every 30 s; the timeline does not. + */ +export const freshest = (album: ExistingAlbum, events: ThreadEvent[]): ExistingAlbum => + events + .filter((e) => e.threadRoot === album.albumId) + .reduce((a, e) => (e.ts > a.lastTs ? { ...a, lastId: e.id, lastTs: e.ts } : a), album); + +export const CANCELLED = 'stopped'; + +/** The short reason for a failure we can name (the homeserver's refusal or what the client saw), else undefined. */ +export const refusalOf = (e: unknown): string | undefined => uploadReason(e)?.cause; + +/** The reply relation Matrix clients expect for a thread message: root, with the last event as fallback reply. */ +export const threadRelation = (rootId: string, lastId: string) => ({ + rel_type: 'm.thread', + event_id: rootId, + is_falling_back: true, + 'm.in_reply_to': { event_id: lastId }, +}); + +export const DEFAULT_TITLE = 'Shared files'; + +export const titleOrDefault = (title: string): string => title.trim() || DEFAULT_TITLE; + +/** + * Files go one at a time, in the order given, so thread order is pick order. A file that fails + * to upload is reported by name and the rest still go; the root is already out, so stopping would + * leave an empty album and the sender nothing to retry from. A root that fails to send throws: + * nothing exists yet. + */ +export const sendShareSet = async ( + port: SharePort, + into: string | ExistingAlbum, + files: F[] +): Promise => { + const albumId = + typeof into === 'string' + ? await port.send({ msgtype: 'm.text', body: titleOrDefault(into) }) + : into.albumId; + const result: ShareResult = { albumId, sent: 0, failed: [], failedAt: [], reasons: [] }; + let lastId = typeof into === 'string' ? albumId : into.lastId; + // A stopped send leaves its file, and every file after it, unsent and picked, so one tap resumes. + const stopFrom = (from: number) => { + for (let j = from; j < files.length; j += 1) { + result.failed.push(files[j].name); + result.reasons?.push(CANCELLED); + result.failedAt.push(j); + } + }; + for (let i = 0; i < files.length; i += 1) { + if (port.signal?.aborted) { + stopFrom(i); + break; + } + try { + // eslint-disable-next-line no-await-in-loop + const content = await port.contentOf(files[i]); + if (port.signal?.aborted) { + stopFrom(i); + break; + } + // eslint-disable-next-line no-await-in-loop + lastId = await port.send({ ...content, 'm.relates_to': threadRelation(albumId, lastId) }); + result.sent += 1; + port.onFile?.(i, 'sent'); + } catch (e) { + if (port.signal?.aborted) { + stopFrom(i); + break; + } + result.failed.push(files[i].name); + result.reasons?.push(refusalOf(e)); + result.failedAt.push(i); + port.onFile?.(i, 'failed'); + } + } + return result; +}; + +/** + * What stays picked after a send: only the files that did not go, so a retry is one tap. A retry + * of a new album makes a second album, and a share where nothing went + * leaves its root as a lone message the album list does not show (it lists threads with replies). + */ +export const remainingAfter = (files: T[], result: ShareResult): T[] => + files.filter((_, i) => result.failedAt.includes(i)); + +const failedList = (result: ShareResult): string => + result.failed + .map((name, i) => (result.reasons?.[i] ? `${name} (${result.reasons[i]})` : name)) + .join(', '); + +export const resultNote = (result: ShareResult): string => { + if (result.failed.length === 0) + return `Shared ${result.sent} ${result.sent === 1 ? 'file' : 'files'}.`; + if (result.sent === 0) return `Nothing was shared. Not sent: ${failedList(result)}`; + return `Shared ${result.sent}. Not sent: ${failedList(result)}`; +}; diff --git a/src/app/features/albums/useAlbums.ts b/src/app/features/albums/useAlbums.ts new file mode 100644 index 00000000..13c199ec --- /dev/null +++ b/src/app/features/albums/useAlbums.ts @@ -0,0 +1,85 @@ +import { useQuery } from '@tanstack/react-query'; +import { ClientPrefix, Method, Room } from 'matrix-js-sdk'; +import { StateEvent } from '../../../types/matrix/room'; +import { useStateEvent } from '../../hooks/useStateEvent'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useClientConfig } from '../../hooks/useClientConfig'; +import { Album, ZoneEvent, buildAlbums } from './buildAlbums'; +import { isFileZone } from './isFileZone'; +import { mapLimit } from './mapLimit'; +import { Page, readPages } from './readPages'; + +const REPLY_FETCHES_IN_FLIGHT = 6; +const REFRESH_MS = 30_000; + +/** True when the room is the file zone: by its create type, or listed in the client config. */ +export const useIsFileZone = (room: Room): boolean => { + const config = useClientConfig(); + // Subscribes to alias changes (alt aliases live in the same event). + useStateEvent(room, StateEvent.RoomCanonicalAlias); + const createType = room.currentState.getStateEvents('m.room.create', '')?.getContent().type; + return isFileZone( + { + roomId: room.roomId, + aliases: [room.getCanonicalAlias(), ...room.getAltAliases()].filter((a): a is string => !!a), + createType, + }, + config + ); +}; + +export type AlbumsState = { + loading: boolean; + error: Error | null; + albums: Album[]; + /** A read hit the page cap (readPages), so older albums or files are not in the list. */ + truncated: boolean; +}; + +const PAGE_LIMIT = '100'; + +/** + * Reads every page of the zone's threads, then every page of each thread's replies, each capped at + * MAX_PAGES (readPages). Reads are made with the logged-in client, so the room must be + * unencrypted: encrypted events carry no msgtype and are skipped. + */ +export const useAlbums = (room: Room): AlbumsState => { + const mx = useMatrixClient(); + const { roomId } = room; + const query = useQuery({ + queryKey: ['albums', roomId], + queryFn: async (): Promise<{ albums: Album[]; truncated: boolean }> => { + const get = (path: string, params: Record) => + mx.http.authedRequest>(Method.Get, path, params, undefined, { + prefix: ClientPrefix.V1, + }); + const paged = (path: string, params: Record) => + readPages((from) => get(path, from ? { ...params, from } : params)); + const room_ = encodeURIComponent(roomId); + const roots = await paged(`/rooms/${room_}/threads`, { limit: PAGE_LIMIT }); + let { truncated } = roots; + const inputs = await mapLimit( + roots.items, + REPLY_FETCHES_IN_FLIGHT, + async (root: ZoneEvent) => { + const replies = await paged( + `/rooms/${room_}/relations/${encodeURIComponent(root.event_id ?? '')}/m.thread`, + { limit: PAGE_LIMIT, dir: 'f' } + ); + truncated = truncated || replies.truncated; + return { root, replies: replies.items }; + } + ); + return { albums: buildAlbums(inputs, mx.getDomain() ?? ''), truncated }; + }, + retry: 1, + refetchInterval: REFRESH_MS, + }); + return { + loading: query.isLoading, + // A failed refetch keeps the albums already loaded; the error shows only when there are none. + error: query.data ? null : (query.error as Error | null), + albums: query.data?.albums ?? [], + truncated: query.data?.truncated ?? false, + }; +}; diff --git a/src/app/features/albums/useFileReaders.ts b/src/app/features/albums/useFileReaders.ts new file mode 100644 index 00000000..5ade3827 --- /dev/null +++ b/src/app/features/albums/useFileReaders.ts @@ -0,0 +1,25 @@ +import { useMemo, useSyncExternalStore } from 'react'; +import { Room } from 'matrix-js-sdk'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { FileReader, readersOf } from './fileReceipt'; +import { receiptStoreOf } from './receiptStore'; + +/** + * Who has read a file, from the receipts in the room's loaded timeline: a plain room event or a thread reply, both + * count (docs/file-zone-mind-contract.md). `content` is the file event's content, which carries its `info.sha256`. + * The room's one shared subscription (receiptStore.ts) feeds every file row. + */ +export const useFileReaders = ( + room: Room, + fileId: string, + content: Record +): FileReader[] => { + const mx = useMatrixClient(); + const server = mx.getDomain() ?? ''; + const store = receiptStoreOf(room); + const receipts = useSyncExternalStore(store.subscribe, store.getSnapshot); + return useMemo( + () => readersOf(receipts, fileId, content, server), + [receipts, fileId, content, server] + ); +}; diff --git a/src/app/features/albums/versions.test.ts b/src/app/features/albums/versions.test.ts new file mode 100644 index 00000000..5179e945 --- /dev/null +++ b/src/app/features/albums/versions.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; +import { PUBLISH_KEY, foldVersions, replacesOf } from './versions'; + +type F = { id: string; sender: string; content: Record; earlier: F[] }; +const f = (id: string, replaces?: string, sender = '@m:x'): F => ({ + id, + sender, + content: replaces ? { [PUBLISH_KEY]: { replaces } } : {}, + earlier: [], +}); +const shape = (files: F[]): unknown => files.map((x) => [x.id, x.earlier.map((e) => e.id)]); + +describe('replacesOf', () => { + it('reads the id the publisher key names', () => { + expect(replacesOf({ [PUBLISH_KEY]: { replaces: '$a' } })).toBe('$a'); + }); + it('anything else says nothing', () => { + expect(replacesOf({})).toBeUndefined(); + expect(replacesOf({ [PUBLISH_KEY]: {} })).toBeUndefined(); + expect(replacesOf({ [PUBLISH_KEY]: { replaces: 3 } })).toBeUndefined(); + expect(replacesOf({ [PUBLISH_KEY]: { replaces: '' } })).toBeUndefined(); + expect(replacesOf({ [PUBLISH_KEY]: 'x' })).toBeUndefined(); + }); +}); + +describe('foldVersions', () => { + it('files that replace nothing pass through in order, with nothing folded', () => { + expect(shape(foldVersions([f('$a'), f('$b')]))).toEqual([ + ['$a', []], + ['$b', []], + ]); + }); + + it('shows the latest of a chain with earlier versions nearest first', () => { + expect(shape(foldVersions([f('$a'), f('$b', '$a'), f('$c', '$b')]))).toEqual([ + ['$c', ['$b', '$a']], + ]); + }); + + it('keeps unrelated files beside a chain, in order', () => { + expect(shape(foldVersions([f('$a'), f('$x'), f('$b', '$a')]))).toEqual([ + ['$x', []], + ['$b', ['$a']], + ]); + }); + + it('a replacement whose target is not in the list stands alone', () => { + expect(shape(foldVersions([f('$b', '$gone')]))).toEqual([['$b', []]]); + }); + + it('a file two others replace sits under both', () => { + expect(shape(foldVersions([f('$a'), f('$b', '$a'), f('$c', '$a')]))).toEqual([ + ['$b', ['$a']], + ['$c', ['$a']], + ]); + }); + + it('files in a loop with no head outside it are all shown, none hidden', () => { + const out = foldVersions([f('$a', '$b'), f('$b', '$a'), f('$s', '$s')]); + expect(shape(out)).toEqual([ + ['$a', []], + ['$b', []], + ['$s', []], + ]); + }); + + it('a loop reached from a head is walked once and ends', () => { + const out = foldVersions([f('$a', '$b'), f('$b', '$a'), f('$h', '$a')]); + expect(shape(out)).toEqual([['$h', ['$a', '$b']]]); + }); + + it('a replacement by another sender folds nothing', () => { + const out = foldVersions([f('$victim', undefined, '@m:x'), f('$evil', '$victim', '@e:x')]); + expect(shape(out)).toEqual([ + ['$victim', []], + ['$evil', []], + ]); + }); + + it('an empty list is empty', () => { + expect(foldVersions([])).toEqual([]); + }); +}); diff --git a/src/app/features/albums/versions.ts b/src/app/features/albums/versions.ts new file mode 100644 index 00000000..e27ff6d0 --- /dev/null +++ b/src/app/features/albums/versions.ts @@ -0,0 +1,56 @@ +// Pure: a file event may name the event it supersedes in `org.gamecult.publish.replaces` (the +// publisher writes it on a later final version; docs/commons-files-desk.md, "replaces, not +// m.replace"). Both events stay ordinary files for every other client; this view shows the latest +// of a chain with the earlier ones folded under it. A chain is read from the events alone. + +/** Key the publisher writes its provenance under. */ +export const PUBLISH_KEY = 'org.gamecult.publish'; + +/** The event id a file says it replaces, if it says one. */ +export const replacesOf = (content: Record): string | undefined => { + const id = (content[PUBLISH_KEY] as { replaces?: unknown } | undefined)?.replaces; + return typeof id === 'string' && id ? id : undefined; +}; + +type Versioned = F & { earlier: F[] }; + +/** + * Files not replaced by another file in the list, oldest first as given, each carrying the files it + * supersedes (nearest first) in `earlier`. A replacement whose target is missing from the list (a + * redacted event, another thread) replaces nothing and stands as a head. A replacement by another sender is ignored. A file two others claim to + * replace sits under both. The walk refuses to revisit a file, so a malformed chain cannot loop (a real event id hashes its content and cannot cycle); files caught in a loop with no head outside it are all shown, none folded. + */ +export const foldVersions = < + F extends { id: string; sender: string; content: Record } +>( + files: Versioned[] +): Versioned[] => { + const byId = new Map(files.map((f) => [f.id, f])); + const target = (f: F): F | undefined => { + const id = replacesOf(f.content); + const t = id && id !== f.id ? byId.get(id) : undefined; + // Only the sender of a file can supersede it, so no one else in the thread can fold it away. + return t && t.sender === f.sender ? t : undefined; + }; + const replaced = new Set(); + files.forEach((f) => { + const t = target(f); + if (t) replaced.add(t.id); + }); + const chainOf = (head: Versioned): Versioned[] => { + const out: Versioned[] = []; + const seen = new Set([head.id]); + for (let t = target(head); t && !seen.has(t.id); t = target(t)) { + seen.add(t.id); + out.push({ ...t, earlier: [] }); + } + return out; + }; + const heads = files.filter((f) => !replaced.has(f.id)); + const covered = new Set(heads.flatMap((h) => [h.id, ...chainOf(h).map((e) => e.id)])); + // Files that replace one another with no head outside the loop are never reached from a head; show + // each of them as it is rather than let the loop hide itself. + return files + .filter((f) => !replaced.has(f.id) || !covered.has(f.id)) + .map((f) => ({ ...f, earlier: replaced.has(f.id) ? [] : chainOf(f) })); +}; diff --git a/src/app/features/albums/zoneView.test.ts b/src/app/features/albums/zoneView.test.ts new file mode 100644 index 00000000..65beb9cb --- /dev/null +++ b/src/app/features/albums/zoneView.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { zoneView } from './zoneView'; + +describe('zoneView', () => { + it('opens albums by default and the timeline for an event deep link', () => { + expect(zoneView(undefined, undefined)).toBe('albums'); + expect(zoneView(undefined, '$e')).toBe('timeline'); + }); + it('lets an event link win over an earlier choice of albums', () => { + expect(zoneView('albums', '$e')).toBe('timeline'); + }); + it('honours the choice when there is no event link', () => { + expect(zoneView('albums', undefined)).toBe('albums'); + expect(zoneView('timeline', undefined)).toBe('timeline'); + }); +}); diff --git a/src/app/features/albums/zoneView.ts b/src/app/features/albums/zoneView.ts new file mode 100644 index 00000000..1af7f705 --- /dev/null +++ b/src/app/features/albums/zoneView.ts @@ -0,0 +1,5 @@ +export type ZoneView = 'albums' | 'timeline'; + +/** The view a file-zone room shows. A link to an event always wins; else the user's choice; else albums. */ +export const zoneView = (choice: ZoneView | undefined, eventId: string | undefined): ZoneView => + eventId ? 'timeline' : choice ?? 'albums'; diff --git a/src/app/features/call-status/CallControl.tsx b/src/app/features/call-status/CallControl.tsx index 1808be92..d63f8709 100644 --- a/src/app/features/call-status/CallControl.tsx +++ b/src/app/features/call-status/CallControl.tsx @@ -5,6 +5,7 @@ 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; @@ -14,7 +15,7 @@ type MicrophoneButtonProps = { function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps) { const [micState, toggleMic] = useAsyncCallback(onToggle); const loading = micState.status === AsyncStatus.Loading; - + return ( {(anchorRef) => ( {(anchorRef) => ( {(anchorRef) => ( {(anchorRef) => ( callEmbed.control.toggleMicrophone(), [callEmbed]); + const handleMicrophoneToggle = useCallback( + () => callEmbed.control.toggleMicrophone(), + [callEmbed] + ); const handleVideoToggle = useCallback(() => callEmbed.control.toggleVideo(), [callEmbed]); const [hangupState, hangup] = useAsyncCallback( @@ -195,11 +203,7 @@ export function CallControl({ disabled={!callJoined} /> {!compact && } - + {!compact && ( callEmbed.control.toggleMicrophone(), [callEmbed]); + const handleMicrophoneToggle = useCallback( + () => callEmbed.control.toggleMicrophone(), + [callEmbed] + ); const handleVideoToggle = useCallback(() => callEmbed.control.toggleVideo(), [callEmbed]); const [hangupState, hangup] = useAsyncCallback( @@ -97,10 +101,7 @@ export function CallControls({ callEmbed }: CallControlsProps) { > - + callEmbed.control.toggleSound()} /> {!compact && } @@ -169,6 +170,7 @@ export function CallControls({ callEmbed }: CallControlsProps) { } > diff --git a/src/app/features/call/Controls.tsx b/src/app/features/call/Controls.tsx index 6aa94ff5..560ed8e5 100644 --- a/src/app/features/call/Controls.tsx +++ b/src/app/features/call/Controls.tsx @@ -4,6 +4,7 @@ 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 ( @@ -18,7 +19,7 @@ type MicrophoneButtonProps = { export function MicrophoneButton({ enabled, onToggle }: MicrophoneButtonProps) { const [micState, toggleMic] = useAsyncCallback(onToggle); const loading = micState.status === AsyncStatus.Loading; - + return ( {(anchorRef) => ( {(anchorRef) => ( {(anchorRef) => ( {(anchorRef) => ( {(anchorRef) => ( toggleMicrophone(), [toggleMicrophone]); const handleVideoToggle = useCallback(async () => toggleVideo(), [toggleVideo]); diff --git a/src/app/features/common-settings/developer-tools/DevelopTools.tsx b/src/app/features/common-settings/developer-tools/DevelopTools.tsx index 29b6aa51..61e05a62 100644 --- a/src/app/features/common-settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/common-settings/developer-tools/DevelopTools.tsx @@ -30,6 +30,7 @@ import { AccountDataSubmitCallback, } from '../../../components/AccountDataEditor'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; type DeveloperToolsProps = { requestClose: () => void; @@ -92,7 +93,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) { - + diff --git a/src/app/features/common-settings/developer-tools/SendRoomEvent.tsx b/src/app/features/common-settings/developer-tools/SendRoomEvent.tsx index f25ba7c3..a6e60a7b 100644 --- a/src/app/features/common-settings/developer-tools/SendRoomEvent.tsx +++ b/src/app/features/common-settings/developer-tools/SendRoomEvent.tsx @@ -22,6 +22,7 @@ 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; @@ -118,7 +119,7 @@ export function SendRoomEvent({ type, stateKey, requestClose }: SendRoomEventPro - + diff --git a/src/app/features/common-settings/developer-tools/StateEventEditor.tsx b/src/app/features/common-settings/developer-tools/StateEventEditor.tsx index 0ea96901..0f9f4034 100644 --- a/src/app/features/common-settings/developer-tools/StateEventEditor.tsx +++ b/src/app/features/common-settings/developer-tools/StateEventEditor.tsx @@ -31,6 +31,7 @@ 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; @@ -275,7 +276,7 @@ export function StateEventEditor({ type, stateKey, requestClose }: StateEventEdi - + diff --git a/src/app/features/common-settings/emojis-stickers/EmojisStickers.tsx b/src/app/features/common-settings/emojis-stickers/EmojisStickers.tsx index ad8ffae6..f7e901d5 100644 --- a/src/app/features/common-settings/emojis-stickers/EmojisStickers.tsx +++ b/src/app/features/common-settings/emojis-stickers/EmojisStickers.tsx @@ -4,6 +4,7 @@ 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; @@ -29,7 +30,7 @@ export function EmojisStickers({ requestClose }: EmojisStickersProps) { - + diff --git a/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx b/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx index fdbe546e..7be8de35 100644 --- a/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx +++ b/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx @@ -40,6 +40,7 @@ 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[]; @@ -215,6 +216,7 @@ export function RoomPacks({ onViewPack }: RoomPacksProps) { {canEdit && (removed ? ( ) : ( Enable Encryption - setPrompt(false)} radii="300"> + setPrompt(false)} + radii="300" + > diff --git a/src/app/features/common-settings/general/RoomUpgrade.tsx b/src/app/features/common-settings/general/RoomUpgrade.tsx index 45a480aa..2971d565 100644 --- a/src/app/features/common-settings/general/RoomUpgrade.tsx +++ b/src/app/features/common-settings/general/RoomUpgrade.tsx @@ -39,6 +39,7 @@ 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(); @@ -105,7 +106,7 @@ function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) { {room.isSpaceRoom() ? 'Space Upgrade' : 'Room Upgrade'} - + diff --git a/src/app/features/common-settings/members/Members.tsx b/src/app/features/common-settings/members/Members.tsx index 15cd1d08..a1dddb54 100644 --- a/src/app/features/common-settings/members/Members.tsx +++ b/src/app/features/common-settings/members/Members.tsx @@ -56,6 +56,7 @@ 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, @@ -161,7 +162,7 @@ export function Members({ requestClose }: MembersProps) { - + @@ -277,6 +278,7 @@ export function Members({ requestClose }: MembersProps) { anchorRef={scrollTopAnchorRef} > virtualizer.scrollToOffset(0)} variant="Surface" radii="Pill" diff --git a/src/app/features/common-settings/permissions/PowersEditor.tsx b/src/app/features/common-settings/permissions/PowersEditor.tsx index 97305946..0684a48a 100644 --- a/src/app/features/common-settings/permissions/PowersEditor.tsx +++ b/src/app/features/common-settings/permissions/PowersEditor.tsx @@ -49,6 +49,7 @@ 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; @@ -369,7 +370,7 @@ export function PowersEditor({ powerLevels, requestClose }: PowersEditorProps) { - + diff --git a/src/app/features/create-room/CreateRoomModal.tsx b/src/app/features/create-room/CreateRoomModal.tsx index d5d8c92d..9332f35c 100644 --- a/src/app/features/create-room/CreateRoomModal.tsx +++ b/src/app/features/create-room/CreateRoomModal.tsx @@ -24,6 +24,7 @@ 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; @@ -63,7 +64,7 @@ function CreateRoomModal({ state }: CreateRoomModalProps) { - + diff --git a/src/app/features/create-space/CreateSpaceModal.tsx b/src/app/features/create-space/CreateSpaceModal.tsx index c1bc6898..faa53b25 100644 --- a/src/app/features/create-space/CreateSpaceModal.tsx +++ b/src/app/features/create-space/CreateSpaceModal.tsx @@ -23,6 +23,7 @@ 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; @@ -61,7 +62,7 @@ function CreateSpaceModal({ state }: CreateSpaceModalProps) { New Space - + diff --git a/src/app/features/desk/Desk.tsx b/src/app/features/desk/Desk.tsx new file mode 100644 index 00000000..d6795ac4 --- /dev/null +++ b/src/app/features/desk/Desk.tsx @@ -0,0 +1,54 @@ +import React, { useMemo } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds'; +import { PhoneHit } from '../../styles/PhoneHit.css'; +import { Page, PageContent, PageContentCenter, PageHeader } from '../../components/page'; +import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; +import { getHomePath } from '../../pages/pathUtils'; +import { DeskView } from './DeskView'; +import { useUnverified } from './useUnverified'; +import { deskFor } from './source'; + +const labelOf = (mind: string) => mind.charAt(0).toUpperCase() + mind.slice(1); + +/** The route's page: one Mind's desk under the navigator. */ +export function Desk() { + const { mind = '' } = useParams(); + const navigate = useNavigate(); + const phone = useScreenSizeContext() === ScreenSize.Mobile; + const label = labelOf(mind); + const list = useMemo(() => deskFor(mind), [mind]); + // Until the recompute finishes nothing is flagged; a card that fails is withdrawn when it lands. + const unverified = useUnverified(list); + + return ( + + + + + {phone && ( + navigate(getHomePath())} + > + + + )} + + {`${label}'s desk`} + + + + + + + + + + + + + + ); +} diff --git a/src/app/features/desk/DeskEntry.tsx b/src/app/features/desk/DeskEntry.tsx new file mode 100644 index 00000000..511b0783 --- /dev/null +++ b/src/app/features/desk/DeskEntry.tsx @@ -0,0 +1,46 @@ +import React, { useMemo } from 'react'; +import { Box, Icon, Icons, Text } from 'folds'; +import { NavGutter, NavItem, NavItemContent, NavLink } from '../../components/nav'; +import { NAV_ROOM_ICON_REST_OPACITY, NavLabel } from '../../components/nav/styles.css'; +import { getDeskPath } from '../../pages/pathUtils'; +import { buildDesk } from './buildDesk'; +import { ForkList } from './forkContract'; +import { deskFor } from './source'; +import { useUnverified } from './useUnverified'; + +export function DeskLink({ mind, open }: { mind: string; open: number }) { + return ( + + + + + + + + + {open > 0 ? `Desk · ${open} open` : 'Desk'} + + + + + + ); +} + +/** The navigator's count: the desk page's own, and none until the hash check has landed. */ +export const openCount = ( + list: ForkList | undefined, + unverified: ReadonlySet | undefined +): number => (unverified ? buildDesk(list, unverified).counts.open : 0); + +/** A Mind's desk, pinned at the top of its navigator group: always there, with the open count when there is one. */ +export function DeskEntry({ mind }: { mind: string }) { + const list = useMemo(() => deskFor(mind), [mind]); + const unverified = useUnverified(list); + // The same count the desk page shows; until the hash check lands there is no count to show. + return ; +} diff --git a/src/app/features/desk/DeskView.css.ts b/src/app/features/desk/DeskView.css.ts new file mode 100644 index 00000000..042f0644 --- /dev/null +++ b/src/app/features/desk/DeskView.css.ts @@ -0,0 +1,122 @@ +import { globalStyle, style } from '@vanilla-extract/css'; +import { color, config, toRem } from 'folds'; +import { MOBILE_BREAKPOINT } from '../../hooks/useScreenSize'; + +const PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`; + +/** Rows and booth side by side on wide screens. */ +export const Layout = style({ + display: 'flex', + gap: config.space.S500, + alignItems: 'flex-start', + '@media': { [PHONE]: { display: 'block', paddingBottom: toRem(120) } }, +}); + +export const Rows = style({ flex: 1, minWidth: 0, margin: 0, padding: 0, listStyle: 'none' }); + +/** A hairline splits rows; it is the only chrome. */ +export const Row = style({ + padding: `${config.space.S300} 0`, + borderTop: `1px solid ${color.Background.ContainerLine}`, +}); + +export const Title = style({ + margin: 0, + font: 'inherit', + fontWeight: 600, +}); + +/** The fold button reads as the title itself, not as a control. */ +export const Fold = style({ + all: 'unset', + cursor: 'pointer', + 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 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` }); + +export const Note = style({ + display: 'block', + width: '100%', + boxSizing: 'border-box', + minHeight: 44, + margin: `${config.space.S200} 0`, + 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', +}); + +/** Sticky beside the rows on wide screens; a small fixed corner image on phones. */ +export const Booth = style({ + position: 'sticky', + top: config.space.S400, + width: toRem(168), + flexShrink: 0, + '@media': { + [PHONE]: { + position: 'fixed', + top: 'auto', + right: config.space.S200, + bottom: config.space.S200, + width: toRem(88), + }, + }, +}); + +// PoseAvatar's button is laid out for message gutters; the booth takes it back into flow. +globalStyle(`${Booth} > [data-pose]`, { + position: 'static', + float: 'none', + display: 'block', + width: '100%', + height: 'auto', + margin: 0, + maskImage: 'none', + WebkitMaskImage: 'none', + cursor: 'default', +}); + +export const BoothLine = style({ + '@media': { + // Still read out by the live region; only the picture shows in the corner. + [PHONE]: { + position: 'absolute', + width: 1, + height: 1, + overflow: 'hidden', + clip: 'rect(0 0 0 0)', + whiteSpace: 'nowrap', + }, + }, +}); + +/** The recorded rulings sit on the dark ridge in every sky; they carry their own ground (scrimStyle). */ +export const Recorded = style({ + marginTop: config.space.S400, + padding: `${config.space.S200} ${config.space.S300}`, +}); diff --git a/src/app/features/desk/DeskView.test.tsx b/src/app/features/desk/DeskView.test.tsx new file mode 100644 index 00000000..9fde039b --- /dev/null +++ b/src/app/features/desk/DeskView.test.tsx @@ -0,0 +1,318 @@ +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 { ScreenSize, ScreenSizeProvider } from '../../hooks/useScreenSize'; +import { SkyInkContextProvider } from '../../hooks/useTheme'; +import { SCRIM_GROUND, SCRIM_INK } from '../scene/skyInk'; +import { DeskEntry, DeskLink, openCount } from './DeskEntry'; +import { DeskView, SIGN_IN_NOTE, rowOpen } from './DeskView'; +import { ForkList } from './forkContract'; +import { STATES, entry as forkEntry, list as forkList } from './fixtures'; +import { 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, + mind = 'void', + unverified = new Set() +) => + renderToStaticMarkup( + + + + ); + +describe('DeskView', () => { + it('folds a row behind its title on a phone, with the fold button saying so', () => { + const html = view('noAnswers', ScreenSize.Mobile); + expect(html).toContain('aria-expanded="false"'); + expect(html).toContain('Question plain'); + expect(html).not.toContain('Context for plain'); + expect(html).not.toContain('First way'); + }); + it('shows the row open, with no fold button, on a wide screen', () => { + const html = view('noAnswers', ScreenSize.Desktop); + expect(html).not.toContain('aria-expanded'); + expect(html).toContain('Context for plain'); + expect(html).toContain('First way'); + }); + it('states the recommendation in words, on that option only', () => { + const html = view('noAnswers', ScreenSize.Desktop); + expect(html.match(/\(recommended\)/g)).toHaveLength(1); + expect(html).toContain('B Second way (recommended)'); + }); + it('keeps the note disabled and says why in a sentence the field points at', () => { + const html = view('noAnswers', ScreenSize.Desktop); + expect(html).toMatch(/]* disabled=""/); + expect(html).toContain(SIGN_IN_NOTE); + expect(SIGN_IN_NOTE).toMatch(/sign-in/); + expect(html).toMatch(/aria-describedby="note-[^"]+-why"/); + }); + it('shows the depends line as the text it is', () => { + expect(view('blocked', ScreenSize.Desktop)).toContain( + 'Waits on: "Needs the first one settled"' + ); + }); + it('says No answers yet on a row without answers', () => { + expect(view('noAnswers', ScreenSize.Desktop)).toContain('No answers yet.'); + }); + it('lists settled questions as recorded rulings with the ruling id', () => { + const html = view('settled', ScreenSize.Desktop); + expect(html).toContain('Recorded as rulings'); + expect(html).toContain('Question done (ruling:done-1)'); + expect(html).not.toContain('Question shut'); + }); + it('gives the recorded rulings their own ground and ink while a scene runs, none without one', () => { + const withScene = renderToStaticMarkup( + + + + + + ); + // renderToStaticMarkup writes the inline style as the browser would read it back + expect(withScene).toContain(SCRIM_GROUND.replace(/, /g, ', ')); + expect(withScene).toContain(`color:${SCRIM_INK}`); + expect(view('settled', ScreenSize.Desktop)).not.toContain('background-color'); + }); + it('shows each outcome quietly, marks the counted answer, and renders a note only when sent', () => { + const html = view('answered', ScreenSize.Desktop); + expect(html).toContain( + 'Answer First way "Because it is first." (2026-11-09) (counted)' + ); + expect(html).toContain('Revision of n1 Third way (2026-11-09)'); + expect(html).toContain('Retraction of n2 (2026-11-09)'); + expect(html).toContain('Refused (not_a_decider) Second way "Let me in."'); + expect(html).toContain('Answer "Either, if measured first."'); + expect(html.match(/\(counted\)/g)).toHaveLength(1); + expect(html).not.toContain('"null"'); + }); + it('says nothing is waiting when empty, and that nothing is published when it never arrived', () => { + expect(view('empty', ScreenSize.Desktop)).toContain('Nothing is waiting on you from Void.'); + expect(view('unpublished', ScreenSize.Desktop)).toContain( + 'Void's questions have not been published yet.' + ); + }); + it('seats the Mind in the booth with the pose for the desk mood', () => { + const html = view('noAnswers', ScreenSize.Desktop); + expect(html).toContain('data-pose="thinking"'); + expect(html).toContain('Just one fork on the desk.'); + }); + it('makes the booth portrait a plain image: no button, no tab stop', () => { + const html = view('noAnswers', ScreenSize.Desktop); + const booth = html.slice(html.indexOf('data-pose')); + expect(booth).toContain(']*data-pose/); + expect(html).not.toMatch(/<[a-z]+[^>]*data-pose[^>]*tabindex/); + expect(html).toMatch(/]*data-pose/); + }); + it('withdraws a card whose hash does not match and does not count it open', () => { + const html = view('noAnswers', ScreenSize.Desktop, 'void', new Set(['c:question:plain'])); + expect(html).toContain('does not match the hash it was published with'); + expect(html).not.toContain('First way'); + expect(html).toContain('Nothing is waiting on you from Void.'); + expect(html).not.toContain('1 open.'); + }); + it('never claims an empty desk while the list says forks are open, whatever rows it could show', () => { + setDeskSource(() => undefined); + const html = renderToStaticMarkup( + + + + ); + expect(html).toContain('3 open.'); + expect(html).not.toContain('Nothing is waiting'); + }); + it('says every row is read-only for a signed-out viewer, in every state the fixtures hold', () => { + (['noAnswers', 'blocked', 'answered', 'settled'] as const).forEach((state) => { + const html = view(state, ScreenSize.Desktop); + const rows = html.match(/