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/docs/file-zone-mind-contract.md b/docs/file-zone-mind-contract.md new file mode 100644 index 00000000..8b350c6c --- /dev/null +++ b/docs/file-zone-mind-contract.md @@ -0,0 +1,98 @@ +# File zone: what a Mind can read from a file event + +Ruling `ratatoskr:ruling:persona-file-sharing-zone`. The Mind-side fetch is the mouth's +(`eureka-body:cut_spec:mouth-fetch-attachment.r1`); this page is the client side of the same contract. +A Mind cannot fetch an `mxc://` itself (the media endpoint answers 401), so it never holds one. + +## What the Mind passes + +Room id and event id of the file event. Never a bare `mxc://`, URL or hash. The mouth fetches the event, +reads the media reference from its content, and enforces the Mind's own membership in that room at fetch +time. A room the Mind is not in is refused by the mouth, whatever the client wrote. + +## What the client writes (this repo, `RoomInput.tsx` send path) + +A file event is an `m.room.message` whose `msgtype` is `m.audio`, `m.image`, `m.video` or `m.file`: + +| field | where | note | +| ------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `msgtype` | content | selects the kind from the file's mimetype prefix | +| `body`, `filename` | content | both the file's name (display only; never a path) | +| `url` | content | the `mxc://` of an unencrypted file; the mouth reads it | +| `file` | content | present instead of `url` in an encrypted room: `{url, key, iv, hashes.sha256 (ciphertext), v}`. A Mind has no key; the mouth refuses it | +| `info.mimetype` | content | the browser-reported type, normalised by `safeFile` (e.g. `application/ogg` becomes `audio/ogg`). A claim, not proof: the mouth sniffs magic bytes | +| `info.size` | content | bytes of the file as uploaded | +| `info.sha256` | content | lower-case hex sha256 of the plaintext bytes, computed by the client before send; written in unencrypted rooms only (a plaintext hash in an encrypted room would leak the file's identity) | + +The hash is computed over the same bytes that are uploaded, reading the whole file into memory before send (large videos pay for it); if hashing fails the file is sent without one. A file sent by something other than this +client may lack `info.sha256`; the mouth then logs the hash it computed, and the row can show no `fetched for` +for it. + +`info.mimetype` and `info.size` are written on audio and file events always; on image and video events `info` exists only if the browser could load the media, otherwise `info` holds `sha256` alone. Image and video events also carry `info.w`, `info.h`, `info.xyz.amorgan.blurhash` and a thumbnail +reference; audio carries no duration. A Mind should not rely on any of those. + +## Limits and refusals + +- Size: the mouth streams at most 5 MiB and refuses a larger file. The client does not enforce this cap; + its own limit is the homeserver's `m.upload.size`. A file over 5 MiB can be shared with people and + not fetched for a Mind. +- Refusals are the mouth's and are typed: not a member of the room, bridged or Discord room, encrypted + file, over the cap, content type not matching the magic bytes. The client does not re-check them and + nothing in the client depends on the mouth's log. +- The Mind cites files by room id and event id only; a file in a room the Mind is not in does not exist + for it. + +## Receipt: `org.gamecult.file_received` + +The Mind posts one event of type `org.gamecult.file_received` after a successful fetch, to the room of the +file. When the file has an album root the receipt is a reply in the album's thread, so it arrives with the album's +own listing; otherwise (a composer file sent with no thread) it is a plain room event with no `m.relates_to`. Both +count, and the client finds a receipt by its `event_id` anywhere in the room's loaded timeline or the album's thread: + +```json +{ + "m.relates_to": { "rel_type": "m.thread", "event_id": "" }, + "org.gamecult.file_received": { + "event_id": "", + "sha256": "", + "size": 123456 + } +} +``` + +A plain room-event receipt may also carry `m.relates_to: {rel_type: "m.reference", event_id: }`, which lets +the mouth find its earlier receipts through `/relations` for dedup. The client matches by `content.event_id` and reads no +relation but this one: if a `m.reference` is present its `event_id` must equal `content.event_id`, else the receipt counts +for nothing. A thread relation is not read. However many receipts a Mind has posted for a file, the row draws one +`fetched for` for that Mind. + +Two rules, both enforced on each side: + +1. **The sender is the Mind.** The row trusts the Matrix `sender` of the receipt event and nothing inside it: + no Mind field is read. `fetched for X` counts only when the sender is X's own Matrix identity (the + full id is `@:` for a Mind in the Persona catalogue; a display name, a lookalike or another server's `@void:evil.example` does not count). +2. **Fetched means verified.** The Mind posts only after the sha256 of the bytes it fetched equals the file + event's `info.sha256`, and the receipt carries that hash. On a mismatch it posts nothing (or a typed + refusal that the row does not draw). The row checks it again: the receipt's `sha256` must equal the + file event's `info.sha256`. A file event with no `info.sha256` can show no `fetched for`. + +The receipt is inert: no paths, URLs, filenames or anything a browser would fetch, and the client reads only +`event_id`, `sha256` and the sender. It has no `msgtype`, so the album's file listing ignores it. The mouth's +log is the fuller record for Minds; this event is the public tick. + +The row draws `fetched for `: one receipt per Mind per file (the latest), several Minds side by side. A +receipt for an event that is not a file of that album, a malformed one, and a redacted one are ignored. +The wording is deliberate: the receipt says the mouth fetched and stored the file for the Mind, not that it is in the Mind's sandbox and not that it was read. Client strings never say "saved" or "read". +The sender check keeps a member from ticking for a Mind; it does not prove the Mind read the file beyond +what the hash and the Mind's own account show. + +## Not built yet + +- Files sent from the composer carry no thread relation, so they reach an album only if written into a + thread by another tool; the album upload path (docs/file-zone.md, "Upload") is unwritten. Their receipts are plain + room events, and the timeline row for the file shows `fetched for` from them. +- A receipt that is still encrypted when it arrives is not seen until a later timeline event triggers a rescan; zones are + unencrypted and the hash is written only there, so this is the encrypted-room limit again. +- The timeline row reads the room's loaded timeline only: a receipt older than what the client has paged in is not + seen there (an album's own listing reads the thread, which is paged separately). +- Encrypted zones are not listed. diff --git a/docs/file-zone.md b/docs/file-zone.md new file mode 100644 index 00000000..7990485d --- /dev/null +++ b/docs/file-zone.md @@ -0,0 +1,123 @@ +# File zone: design sketch + +Status: design only. No code exists. Everything below is a proposal; items marked **UNKNOWN** are not yet verified against the Body. + +Source: Meta ruling 2026-10-31, eureka mind `ratatoskr:ruling:file-zone-looks-like-file-sharing`. + +## What it is + +A file-sharing space beside the room timeline, not a view of it. The zone is **one ordinary Matrix room with threaded discussions, one thread per Mind**. The UI presents each thread as an **album** (title, item count, date) and each post in it as a **file** with an inline player or preview. Matrix is the storage and the fallback: another client sees a normal room with threads and never needs the zone. No homeserver change. + +First use (Aqua): listen to audio in place, mark each clip keep / fix / drop with a one-line reason, export the marks as a file. See `/opt/commons/handoff/aqua-listen-kits-20261108/` (static `listen.html` kits that emit `verdict.json` + `verdict.md`); the zone replaces sending that page around. + +## Matrix event shapes (stock events only) + +- **Zone** = one room, chosen by the client (**UNKNOWN:** how the client knows which room is the zone: a room-account-data flag, a `m.room.create` type, or a configured room id in `config.json`; config is simplest). +- **Album** = a thread root: a text `m.room.message` posted by a Mind, `body` = the album title (the Mind's name, e.g. `void`). One thread per Mind, owned by authorship (the root's sender). **UNKNOWN:** whether a Mind may open more than one (e.g. per kit); the shape allows it with no change, the zone would then list several albums per author. Count and last-activity come from the SDK's thread summary (`unsigned.m.relations.m.thread`); album date = root `origin_server_ts`. Rename = `m.replace` on the root. +- **File** = a post in the thread: an `m.room.message` with `msgtype` `m.audio` / `m.image` / `m.video` / `m.file` and `m.relates_to: { rel_type: "m.thread", event_id: , is_falling_back: true, "m.in_reply_to": { event_id: } }`. The builders in `src/app/features/room/msgContent.ts` already make the content. Delete = redact. +- **Album vs chat thread**: with a single-purpose room, every thread is an album. No marker key is needed. A non-media reply in a thread (chat, a mark) is not a file and is not listed as one. +- **Cover** = first image file in the thread, if any. Loose room messages outside any thread are not shown in the zone; they remain in the timeline. + +## Marks (reactions, plus an optional reply) + +A mark is stock Matrix, so every client shows it. Two reaction sets on the **file post**, and one optional reply: + +- **Verdict** = an `m.annotation` reaction (`m.reaction` event, `m.relates_to: { rel_type: "m.annotation", event_id: , key }`) with key **👍 keep**, **🔧 fix**, **🗑 drop**. One per person per file: choosing another replaces the old one (the client redacts the sender's previous verdict reaction, then sends the new one); tapping the active one clears it. +- **Landed** = a second reaction set, keys **1️⃣ 2️⃣ 3️⃣ 4️⃣ 5️⃣** (keycap digits, 1-5). Also one per person per file, same replace rule. The two sets are independent: a file can have a verdict, a landed score, both or neither. +- **Reason** = an ordinary thread reply under the file: `m.room.message` `m.text`, `m.relates_to: { rel_type: "m.thread", event_id: , "m.in_reply_to": { event_id: } }`. Optional. **Keep needs none**; the UI prompts for one mainly after fix or drop. The latest such reply by the sender under that file is the reason (edits via `m.replace` count). No special key: the reply text is the reason, so nothing is lost if another client wrote it. +- **No private mode.** Marks are room events the moment they are tapped; there is no local-only draft and no "Share" step. Offline taps queue in the SDK's normal send queue. +- Who marked what is read from the reactions' senders, so each listener is distinguishable and the album can show tallies per file. +- Why reactions: one tap, readable in any client, no custom event type, and they can be changed with no history growth beyond a redaction. Reaction keys carry no text, hence the separate reply for the reason. +- **UNKNOWN:** emoji variation selectors. 🗑 and 🔧 and the keycaps may arrive with or without U+FE0F from different clients; match by normalizing (strip U+FE0F) before comparing keys. Also **UNKNOWN:** servers' per-event limits on distinct reaction keys; a handful per file is far below any default. + +## Export + +Client-side only. "Export" builds Aqua's verdict file from the verdict and landed reactions and the reply under each file in one album (one listener's marks per export; that sender is the `listener`) and either downloads it or uploads it into the album. Two files, as the kits write them: `verdict.json` and `verdict.md`. + +`verdict.json` is schema **`aqua.listen.verdict.v1`**, field for field what `buildVerdict` in the kit (`dings/listen.html`, section `buildVerdict`) emits, so Aqua's tooling reads it unchanged: + +``` +{ schema: "aqua.listen.verdict.v1", + kit: { id, title }, // album root: id = root event id (or the kit id if the upload carried one), title = root body + exportedAt, // ISO time of export + seed, consent, // kit-only: zone writes seed: 0 (or omits), consent: true for the sender's own export (see UNKNOWN) + listener, // sender display name + device, // headphones|speakers|null, the sender's most-used mark device, or null + notes, // free text; zone leaves "" in v1 + summary: { total, keep, fix, drop, unanswered }, + clips: [{ position, id, title, group, feel, file, pair, slot, verdict, reason, landed, device }], + pairs: [{ a, b, preferred }] } // empty array when the album has no pairs +``` + +- `clips` has one row per file post in the album, in thread order (`position` = 1-based). The join per file, for the exporting listener: `verdict` = their 👍/🔧/🗑 reaction (keep/fix/drop) or null; `landed` = their keycap reaction (1-5) or null; `reason` = text of their latest reply under the file, or "". A file with no verdict reaction counts as `unanswered` even if it has a reply. `device` per clip is null (reactions cannot carry it); the top-level `device` (headphones|speakers|null) is asked once in the export dialog. +- `pairs`, `seed`, `consent` are optional for the zone: `pairs: []`, no `slot`/`pair` (null) unless the clip metadata supplies them. +- **Where clip metadata comes from.** `id` is the clip id from `org.gamecult.clip`, else the filename stem. `title` = file post `body`; `file` = the post's `filename`/`body`. `group`, `feel`, `pair`, `slot` are kit data that Matrix has no slot for: carry them on the file post as one extra key `org.gamecult.clip: { id, group, feel, pair, slot }` written by whoever uploads the kit (a small uploader script, **UNKNOWN**, not in the first slice; without it those columns export as ""/null and the id falls back to the filename stem). +- `verdict.md` follows `verdictMarkdown` from the same file: header line, count line, the clip table (`# | clip | group | intended feel | verdict | landed (1-5) | device | reason`), pairs table when `pairs` is nonempty, notes. +- To avoid drift, the exporter should be a port of `buildVerdict`/`verdictMarkdown` with the schema string and field order pinned by a test that compares against a fixture produced by the kit. **UNKNOWN:** whether to share code (the kit's `logic.cjs`) rather than port; recommend port, ~80 lines, because the kit is a standalone page. +- **UNKNOWN:** `consent` is a kit-side checkbox before listening. The zone has no such step; recommend writing `true` only if the sender ticks a one-time "include my name in exports" toggle, else `false` and `listener: ""`. +- JSON here is a xenos-boundary export, not state; the room events are the state. + +## Mobile-first layout + +- Phone width is the design width; desktop adds columns. +- Level 1, album list: one column, each row = cover/icon, title, "N files", date. Tap opens. +- Level 2, album: list by default (title, duration/size), grid toggle for images. Audio rows play in place, one at a time (starting a clip stops the last), a persistent mini-player at the bottom with seek. +- Mark controls on each audio row: three large tap targets (👍 keep / 🔧 fix / 🗑 drop) with tallies, a row of five "landed" chips (1-5), and after fix or drop an inline one-line reason field (skippable). Thumb-reachable, no modal. +- Back is one level up; the room timeline stays one tap away (the zone is a sibling view, not a replacement). **UNKNOWN:** where the entry lives in the room header; check `RoomViewHeader.tsx`, `Room.tsx` for how the member drawer and pin menu attach. + +## Upload + +Reuse the composer's pipeline, pointed at an album instead of the main timeline: + +- `createUploadAtom` / `useBindUploadAtom` (`src/app/state/upload.ts`), `UploadCard`, `upload-board` for progress. +- On success, the msgContent builder produces the event; the zone adds the thread relation to the album root before sending (the composer already does this for thread replies, `RoomInput.tsx`). +- "New album" = send the root text event, then upload into its thread. Multi-file drop = N events, sent in order. +- Size limit from `MediaConfigLoader` (server `m.upload.size`). **UNKNOWN:** whether the 32 MB `shelf` kit's clips (42 x ~0.75 MB) should be uploaded individually or batched; individually is the only option without a server change. + +## Existing code to reuse (verified present in the repo) + +| need | where | +| --------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| audio player (play/seek/volume, encrypted download) | `src/app/components/message/content/AudioContent.tsx` (210 lines) | +| video, image, generic file | `.../content/VideoContent.tsx`, `ImageContent.tsx`, `FileContent.tsx` | +| full-screen image viewer | `src/app/components/image-viewer/ImageViewer.tsx` | +| media dispatch by msgtype | `src/app/components/RenderMessageContent.tsx`, `message/MsgTypeRenderers.tsx` | +| upload + progress | `state/upload.ts`, `components/upload-card`, `upload-board` | +| event content builders | `features/room/msgContent.ts` | +| thread plumbing | `features/room/message/Message.tsx`, `RoomTimeline.tsx`, `RoomInput.tsx` (thread relation on send) | +| room-scoped file/pin precedent | `features/room/room-pin-menu/RoomPinMenu.tsx` | + +Nothing named "gallery" exists as a feature; `AudioContent` takes its controls as render props (`renderMediaControl`), so it can be rehosted in a row without a fork. **UNKNOWN:** whether `AudioContent` can be rendered outside a timeline event context (it needs only `url`, `info`, `mimeType`, `encInfo`, from the event content, so probably yes). + +## First slice (what to build first) + +Rough lines of TypeScript, tests excluded. + +1. **Read-only albums + album view** (~250 lines): list the room's thread roots as albums (grouped or filterable by author = Mind), open one to list its file posts, rehost `AudioContent` and `ImageContent`. One route/panel plus a header entry. Depends on the thread list the SDK already keeps. +2. **Marks + export** (~240 lines, with the schema port): verdict and landed reaction sets with the replace-one rule, optional reason reply, per-file tallies, the join into `aqua.listen.verdict.v1` and `verdict.json`/`verdict.md` download. This is Aqua's workflow end to end. +3. **Upload into an album** (~150 lines): thread-related send through the existing upload atoms, "new album". + +Slices 1+2 serve the first use case. Each is its own PR. Because marks are ordinary thread replies, there is no separate "publish" slice. + +## Open questions for the Meta + +- How the client identifies the zone room (config id recommended). +- Whether exports are per listener (proposed) or a merged all-listeners file. +- Where `device` comes from, now that reactions cannot carry it (export dialog, proposed). + +## Share a set (built, behind `fileZoneShare.enabled`) + +The ruling `ratatoskr:ruling:persona-file-sharing-zone` asks that a set of files arrive together, not one attachment at a time. The zone already holds one thread per album, so a set is an album: **one root text message plus one `m.thread` reply per file**, the exact shape `buildAlbums` reads. Nothing new on the wire and no homeserver change; a Mind's mouth that posts into the zone room and a person using the client produce the same events. + +Matrix options checked: (a) a thread, chosen: stock, rendered by every client, already the zone's album; (b) `m.gallery` (MSC4274) puts several items in one event, but it is unstable, most clients and this one do not render it, and each item still needs its own upload; (c) a shared custom relation: no client groups by it. A thread costs one extra event per set (the root) and nothing else. + +Client, `src/app/features/albums`: + +- `shareSet.ts`: `sendShareSet(port, title, files)`. Sends the root, then each file in pick order, each reply falling back to the previous event (`threadRelation`). A file whose upload fails is named in the result and the rest still go; a root that fails to send throws before any upload. The root alone is not an album in the list (`/threads` returns only roots that have a reply), so a share where every upload failed leaves a lone text message in the timeline. After a send the panel keeps only the failed files picked; a retry makes a second album. Ports are injected, so the order and failure rules are tested without a homeserver (`shareSet.test.ts` reads the sent events back through `buildAlbums`). +- `shareController.ts`: the panel's state machine (picked files, phase, the one-send-per-room guard, the encrypted refusal, failed files kept by position), apart from React so tests drive it without a DOM; `shareController.test.ts` and `ShareSet.test.tsx` (static render of each state, the flag) pin it. No DOM test environment exists in this repo, so a real tap on a real input is covered by the phone check, not by tests. +- `ShareSet.tsx`: phone layout. Idle, it is one text row, "Share files". Picking files (any number from the native picker; a second pick replaces the first) mounts a title field, the picked names on one dim line, and Send / Cancel as full-width rows; progress is "Sending 2 of 5". It is shown only on the album list (kept mounted but hidden while an album is open, so a running send keeps its state), one send at a time per room (tracked outside the component; leaving the page does not stop a send, it only loses its progress and result), and never in an encrypted zone (re-checked when Send is tapped) (the plaintext `info.sha256` the Mind verifies against is written in unencrypted rooms only). +- Content per file comes from `getMsgContent` in `features/room/msgContent.ts`, now also used by the composer, so a shared file and an attached file carry the same `info` including `sha256`. + +Flag: `fileZoneShare.enabled` in `config.json`, default `false`. With it off nothing mounts. + +Not built: a Mind's own path into the zone (outbox, Persona decision, mouth post) belongs to gamecult-ops / Huginn / the mouth, not this client; sharing into an existing album ("add files"); upload cancel; pagination beyond 100 albums/files. diff --git a/docs/gamecult/chat-shell-compression.md b/docs/gamecult/chat-shell-compression.md new file mode 100644 index 00000000..c8ac66cd --- /dev/null +++ b/docs/gamecult/chat-shell-compression.md @@ -0,0 +1,108 @@ +# chat.gamecult.org: shell compression and caching + +Follow-up `ratatoskr:follow_up:chat-shell-compression`. Measured 2026-11-12 against the live +host, which serves release `ratatoskr-nemesis-9b9b495d` (the `index-CAataSJe.js` bundle; the offline table +below also uses the `f8115609` handoff build, `index-D0yL0Dgy.js`, and says which row is which). The host config is +`nginx/chat.gamecult.org.conf` in `gamecult-ops`, owned by Eureka; nothing here is applied +to the host. + +## What the host serves now + +The 2026-10-31 probe (no Content-Encoding, no Cache-Control on `/assets/`) is stale: the +`nginx.conf` has `gzip on`, the site file sets level 5, `gzip_vary` and `gzip_types` for js, css, json, svg and wasm, and carries an immutable +`Cache-Control` on `/assets/` and `/preview/assets/`. Probed with `curl -H 'Accept-Encoding: X'`. + +| Resource | raw | gzip (served now) | brotli (served now) | +|---|---|---|---| +| `/assets/index-CAataSJe.js` | 3,732,771 | 956,085 | none: `br` is ignored, identity returned | +| `/assets/index-BLdKqKqx.css` | 111,267 | 19,952 | none | +| `/assets/matrix_sdk_crypto_wasm_bg-DwIieW7K.wasm` | 5,573,870 | 1,878,147 | none | +| `/` (index.html, no-cache) | 3,066 | gzip | none | + +Headers: hashed `/assets/` answer `Cache-Control: public, max-age=31536000, immutable`; +`/`, `index.html`, `sw.js`, `manifest.json`, `config.json` answer `no-cache`; compressed +responses carry `Vary: Accept-Encoding` and a weak ETag. `config.json` (424 B) is below +`gzip_min_length 1024` and goes uncompressed, which is correct. + +So the original ask is done. What is left is brotli, which nginx 1.26.3 here cannot serve: +`/usr/sbin/nginx -V` shows `--with-http_gzip_static_module` and no brotli module. + +## What brotli buys + +Level-5 gzip is what the host does per request. Offline measurement on the same files +(node `zlib`, 2026-11-12): + +| File | raw | gzip 5 (node) | gzip 9 | brotli 11 | brotli vs live | +|---|---|---|---|---|---| +| live main chunk `index-CAataSJe.js` | 3,732,771 | 957,199 | 945,424 | 725,167 | -24% | +| handoff main chunk `index-D0yL0Dgy.js` | 3,868,313 | 1,015,680 | 1,001,446 | 773,992 | -24% | +| crypto wasm | 5,573,870 | 1,868,282 | 1,851,108 | 1,343,595 | -28% | +| `ReactPrism-Dm2uRF4v.js` | 577,517 | 209,339 | 208,376 | 163,525 | -22% | + +A cold load of the two big files falls from about 2.8 MB to about 2.1 MB on the wire; the +saving is ~0.7 MB once per hashed build per device while the HTTP cache keeps the file (immutable; the +service worker does no precaching, so eviction or a new device pays again). gzip 9 alone buys 1-2%, so +`gzip_static` without brotli is not worth a build step. Brotli is the whole prize, and it +needs one root step. + +Cost: brotli q11 takes about 12 s for the wasm and 8 s for the main chunk on one core, so +the whole dist is about two minutes at build time (measured, 84 files) and zero CPU per request. Do not use q11 +on the fly; that is why the plan is precompressed twins. + +## Root list for Eureka (host) + +1. `apt install libnginx-mod-http-brotli-filter libnginx-mod-http-brotli-static` (trixie/main, 1.0.0~rc-6, + depend on `nginx-abi-1.26.3-1`, which the installed nginx provides; apt may offer an nginx patch upgrade + alongside, say so before accepting). Do not enable the config yet. +2. Install a build with twins (`PRECOMPRESS=1`, below) the usual way + (`docs/gamecult/client-build-install.md`). Twins for files that are not there change nothing. +3. In the `chat.gamecult.org` `server` block, beside the existing gzip lines: + +```nginx + brotli_static on; # serve .br when the client sends br + gzip_static on; # serve .gz; falls back to the on-the-fly gzip above + brotli on; # on-the-fly br for files with no twin (index.html, small files) + brotli_comp_level 5; + brotli_types text/css application/javascript text/javascript application/json application/manifest+json image/svg+xml application/wasm; +``` + + The `location ^~ /assets/` block and `/preview/`, `/element/` and `/mockups/` inherit these; they only add headers. + No other change: the `Cache-Control` and `Vary` lines stay as they are, and + `gzip_vary on` already makes the on-the-fly and static paths vary correctly. +4. `nginx -t && systemctl reload nginx`. Twins first, config second: with `brotli on` and no twins every + client gets on-the-fly q5 brotli of the 3.7 and 5.6 MB files, which costs CPU per request. + +Rollback: remove the five lines and reload. The `.br`/`.gz` files can stay on disk, unused. + +## Build side + +`build-prod-dist.sh` (Void's, in `/opt/commons/handoff/ratatoskr-main-build/`) gains +`PRECOMPRESS=1`, which runs `precompress.cjs ` before the manifest is written. It +writes `.br` (q11) and `.gz` (level 9, no mtime, deterministic for one node) next +to each js, css, wasm, json, svg, html file over 1 KiB, only when smaller than the original. +Default is off, so builds installed before the host change do not ship ~27 MB of twins that +nothing serves. The twins appear in `SHA256SUMS`; the determinism compare still applies. +Tested on `ReactPrism-Dm2uRF4v.js` (gzip roundtrip identical to the original); a full +`PRECOMPRESS=1` build has not been run because it waits on step 1. + +The script is not in this repo; its placement is a standing open item of the build handoff. + +## Before/after measurement + +Run from any machine; `X` is the asset URL. Before = today's table above. + +```sh +for ae in identity gzip br; do + printf '%s ' "$ae"; curl -s -o /dev/null -H "Accept-Encoding: $ae" -w '%{size_download} %header{content-encoding}\n' "$X" +done +curl -sI -H 'Accept-Encoding: br' "$X" | grep -iE 'content-encoding|vary|etag|cache-control' +``` + +Pass: with `br` the main chunk is about 774 KB and the wasm about 1.34 MB with +`Content-Encoding: br`; with `gzip` the bytes are no larger than today; with `identity` +the file is the raw size; `Vary: Accept-Encoding` and the immutable `Cache-Control` are +still present on all three; `index.html`, `sw.js` and `config.json` still `no-cache`. +The service worker's registration and an existing session must still work (open chat on +the operator's phone, hard reload once, check the network panel's "transferred" column). +Check `/preview/assets/` the same way, but `/preview/` is served from `/srv/cinny/preview`, which this build +script does not produce: it gets on-the-fly brotli, not twins, until a preview build carries them. diff --git a/docs/gamecult/client-build-install.md b/docs/gamecult/client-build-install.md new file mode 100644 index 00000000..b2505fe1 --- /dev/null +++ b/docs/gamecult/client-build-install.md @@ -0,0 +1,121 @@ +# Ratatoskr client: build, install, verify, roll back + +Who does what. The steward (Void) builds and hands off; Void holds no root and does not deploy. +Eureka installs on the chat.gamecult.org host, which is Eureka's. This page is the shared +procedure. Nothing here is run by the build step against the live host. + +## Facts about the host + +- Web root: `/srv/cinny/`. `current` is a symlink to one directory under `releases/`; the web + server serves `current`. Releases are `releases/ratatoskr-<8hex>` (older ones are + `ratatoskr-nemesis-<8hex>`). Previous releases stay on disk; that is the rollback. +- The client is static. `config.json` sits in the release root beside `index.html` and is read + at load. Cinny's `Dockerfile` and `docker-nginx.conf` are upstream's container path, not the + chat.gamecult.org path. +- Check the pointer before anything else: `readlink /srv/cinny/current`. + +## 1. Build (steward) + +Two builds exist, and they treat `config.json` differently. + +**By hand (the repo's config).** `vite build` copies the repo's `config.json` into `dist/` +(`vite.config.js`, static copy of `config.json`). Ratatoskr #103 made the repo file the intended +served config, and #106 set `backgroundScene.style` to `ridge`; the served file has not caught up +(see Open). Do not hand-edit `dist/config.json`. + +```sh +git clone https://eureka.gamecult.org/forge/GameCult/Ratatoskr.git && cd Ratatoskr +git checkout # build a named commit, not a branch tip +npm ci --ignore-scripts # .npmrc sets legacy-peer-deps; skips husky's prepare hook +node scripts/sync-poses.mjs # pose art is not committed; this also rewrites the tracked + # src/app/components/message/pose/catalogue.json +NODE_OPTIONS=--max_old_space_size=6144 npm run build # writes dist/ +``` + +Skip `sync-poses` and the build ships with no poses. Do not use `npm run build:preview` for +production: it rewrites `dist/config.json` for preview (hash router, chosen tier and style). + +**The handoff build (what the host installs).** +`/opt/commons/handoff/ratatoskr-main-build/build-prod-dist.sh ` (see its `README.md`) +writes `/opt/commons/handoff/ratatoskr-main-/{dist,SHA256SUMS,BUILD-INFO,build.log}`, +locks the files read-only and refuses to overwrite. The dist carries the repo's `config.json` at +that sha (BUILD-INFO `config_json_source`, `config_json_sha256`); there is no separate pinned copy. +Its live check always runs: it fetches the served config and compares it byte for byte with the +repo's. A difference prints the diff and stops the build. `ALLOW_CONFIG_DRIFT=1` builds anyway and +BUILD-INFO records `config_json_live_check: DIFFERS ...` and `config_json_live_sha256`, so the +handoff states which config differs and the installer decides. From Yggdrasil the public name does +not resolve: pass `LIVE_CURL_ARGS="--resolve chat.gamecult.org:443:127.0.0.1"`. +The folder, with the three pins (`SHA256SUMS` sha256, `BUILD-INFO` sha256, lockfile sha256) and +the node and npm versions, is what the install request names. +Every build stamps its commit into `index.html` (`scripts/buildSha.js`): ``, +with `-dirty` appended when the tree had uncommitted changes and `unknown` when there was no git. Where git governs the tree it decides: an explicit `BUILD_SHA` naming a different commit than HEAD stamps `-mismatch`, a git failure stamps `unknown`. A full-sha `BUILD_SHA` stands only for a tree git does not govern (a source archive) or cannot read (unusable git, for example a dubious-ownership refusal). The handoff +build refuses a dist whose stamp differs from the sha it was asked for, and prints the stamp in its block. +To read it from anywhere: `curl -s https://chat.gamecult.org/ | grep -o 'build-sha" content="[^"]*'`. + +Before handing off: + +```sh +cd /dist && sha256sum -c ../SHA256SUMS # 0 failures +grep -rl 'demo.invalid\|demo-preview-token' /dist # must print nothing +# from Yggdrasil the public name may not resolve; --resolve points it at the local server +diff <(curl -fsS --resolve chat.gamecult.org:443:127.0.0.1 https://chat.gamecult.org/config.json) /dist/config.json +``` + +A config difference is a finding to report, not something to paper over: either live drifted or +the repo is wrong, and a ruling (Meta, for `backgroundScene.style`) says which ships. + +## 2. Install (host owner, root, by hand) + +Note the rollback target first: `readlink /srv/cinny/current`. + +Unpack beside the live release; do not touch the live directory. The target must not exist, or +`cp -r` nests `dist` inside it. + +```sh +R=ratatoskr-<8hex> +H=/opt/commons/handoff/ratatoskr-main- +test ! -e /srv/cinny/releases/$R +cp -r $H/dist /srv/cinny/releases/$R +cmp /srv/cinny/current/config.json /srv/cinny/releases/$R/config.json +(cd /srv/cinny/releases/$R && sha256sum -c $H/SHA256SUMS) +``` + +`cmp` is a drift check, not a pass check. Live has no trailing newline; the repo's file has one, +so even equal content differs. Read the difference (`diff` the two) and settle which config +ships. To keep the host's config byte-for-byte, as past installs did, +`cp /srv/cinny/current/config.json /srv/cinny/releases/$R/config.json`; `sha256sum -c` then flags +`config.json` only (read that one line; `--ignore-missing` does not hide a mismatch). + +Switch (a single symlink swap; `ln -sfn` unlinks then links, so there is a moment with no link): + +```sh +ln -sfn releases/$R /srv/cinny/current +``` + +## 3. Verify + +- `readlink /srv/cinny/current` names `releases/$R`. +- `curl -s https://chat.gamecult.org/config.json | sha256sum` equals `sha256sum /srv/cinny/releases/$R/config.json`. +- Load https://chat.gamecult.org/ and hard-refresh (the service worker, `sw.js`, can hold the + previous assets). Sign-in card shows, the background scene draws, the browser console has no + page errors, a poses/asset request returns 200 and not index.html. + +## 4. Roll back + +Point `current` at the release noted before the switch. Live at the time of writing is +`ratatoskr-nemesis-9b9b495d`: + +```sh +ln -sfn releases/ratatoskr-nemesis-9b9b495d /srv/cinny/current +``` + +Hard-refresh to clear `sw.js`. Do not delete the failed release; leave it for diagnosis. + +## Open + +- The served config (checked 2026-11-12) is not the repo's: it says `backgroundScene.style` + `terrain` where the repo says `ridge` (#106, Meta 2026-11-11), lacks the repo's `dings`, + `fileZoneShare` and `registration` blocks (all off in the repo), and has no trailing newline. + The new keys default off or inert when absent, so only the style is a decision. Meta says which + style ships. Until the served file is replaced, every handoff build stops at the live check + unless `ALLOW_CONFIG_DRIFT=1` is set (section 1). diff --git a/docs/gamecult/ratatoskr-target.md b/docs/gamecult/ratatoskr-target.md new file mode 100644 index 00000000..b56c90f8 --- /dev/null +++ b/docs/gamecult/ratatoskr-target.md @@ -0,0 +1,351 @@ +# Ratatoskr: target and prose map + +Ratatoskr is GameCult's Matrix chat client: this fork of Cinny, forge +`GameCult/Ratatoskr` (renamed from `GameCult/cinny`; the forge redirects the old name). +Void stewards it. Campaign `ratatoskr` lives in the eureka mind for now, beside +the rulings it rests on. Under `eureka-body:ruling:ruling-home-steward-mind` it +is handed to Void's mind once, by procedure, as soon as that hand-off procedure +exists (`hand_off` is parked); after that every new ruling on it is admitted in +Void's mind. Nothing here is written into Void's mind before then. + +The mind holds the questions, rulings, specs, reports and verdicts. This file +holds what no document kind carries: the design truths, body facts, the model +page, rationale, the reconstructed collaboration plan, and the cut sequence. +All times are UTC. + +## Why + +GameCult is the only server this client talks to. A general Matrix client +treats it as one community among thousands, so the operator meets GameCult's +structure three clicks deep in an invite list of 50+ rooms (operator, `#swarm` +10-31 05:14: "The biggest pain is navigation. GameCult has a structure."). The +client should open on GameCult's estate as data: its spaces, its project rooms +grouped by steward, its Minds, and the person's own place in it. It should +also be a pleasant place to be in its own right: emotes, sound, and weather in +the background. + +## Design truths (from rulings in force) + +- **We own the interface, not the engine.** The fork keeps Cinny's engine + (matrix-js-sdk, Rust crypto, sync) and replaces the whole UI; every + user-facing string and its translation is ours, and upstream locale files + are not the source (`gamecult-chat:ruling:we-own-the-ui-and-translation`). +- **Mobile first.** On phones, inactive elements hide, vertical space is + cheaper than horizontal, and text never gets squeezed into narrow columns + (`eureka-body:ruling:chat-client-base-cinny-mobile-first`). +- **The first spec** (`eureka-body:ruling:chat-client-first-spec`): an emote + slot on every bubble (a pose id carried on the event); room and participant + lists tiered (category, then items) and reached from the header menu, not + side columns; participants grouped by estate (Minds, Members, Users, + Patrons); rooms grouped by kind (general, estates, projects, work rooms); + Minds indexed by stewardship, read from Matrix state projected from the + stewardship records in the Minds' own minds (the minds stay the truth); one + room per project, a Space child, with the steward joined. +- **Rooms are visible without a join dance.** No invite screen in the way; a + room the server lets you join shows in the same list as joined ones; opening + one shows its name and a one-tap Join; joining happens on that tap or a first + post, never silently; no peeking (operator, `#swarm` 10-31 04:08, 04:12 + "Peeking sounds like YAGNI to me", 04:14 "One tap is a reasonable cost"). + Rooms you may not enter are refused by the server, not hidden by the client + (Heimdall 04:10, accepted by Void 04:11 and 04:15). +- **Language.** A user's language is the BCP 47 tag in profile field + `org.gamecult.language`, owned by the user through the client's language + picker (`gamecult-chat:ruling:user-language-profile-field`). The client ships + localized; people talk with stewards in their own language; rooms are made + for a language, with no per-language mirrors + (`gamecult-chat:ruling:multilingual-client-stewards-language-rooms`). One + host Mind sits in a language room, Weksa for `#pt-br` + (`gamecult-chat:ruling:language-room-one-host-weksa`). The first person this + must work for is a non-English, non-technical patron (placeholder + `patron-pt1`) whose account we set up and walk through login + (`gamecult-chat:ruling:patron-in-his-own-language`). +- **Push.** `gamecult-chat:ruling:chat-push` rules out native mobile push for + now; Web Push to the installed web app is a different thing and is open + (question `ratatoskr:question:pwa-web-push`). +- **Collaboration is recorded, not chatted.** Work spanning Minds belongs in + Atlas (`eureka-body:ruling:atlas-records-collaborative-work`). Atlas is not + deployed, so the plan below is its interim home; Atlas imports it later. + +## Body facts + +Probes run by Imagination on 2026-10-31, session `self-2026-10-29-eureka`. + +1. **Branch.** `void/gamecult-theme-and-project-nav` is `b8e696c8` over main + `1e6412ed` (v4.12.8), 2 commits, 14 files, +1153/-32 (686 of it lockfile): + a GameCult theme as default, member drawer hidden by default, bubble + layout, and steward grouping in `src/app/pages/client/space/Space.tsx` + (`useStewardGroups` at 429-437, group headers at 505-524) over + `useSpaceJoinedHierarchy` (409). Read by `git diff`. +2. **Live estate shape** (Synapse on Yggdrasil, read as `admin` over the + client API, counts only). Root Space `#gamecult:gamecult.org`, room version + 12, has 9 children: 8 rooms and the `Projects` sub-Space. `Projects` has 52 + children; 50 readable rooms, all room version 12. Server `/hierarchy` from + the root returns 59 rooms, 2 of them spaces. **Every room's join rule is + `invite`**; none is world-readable. 50 rooms carry `org.gamecult.steward` + with content `{mind, repo, source, steward: }`. The root carries + `org.gamecult.stewardships`: Mind localpart to list of `Org/Repo`. +3. **Consequence of 1 and 2.** Project rooms are invite-only and Members are + invited, not joined (gamecult-ops `runbooks/gamecult-chat-host-yggdrasil.md` + 146-158: "Members and every Mind are invited; the steward alone joins"). + So a Member's project rooms are pending invites. Void's grouping reads + `useSpaceJoinedHierarchy` and a per-room state event that an invitee cannot + read, so for a typical Member it groups nothing and the rooms stay in the + invite list. This is the operator's "three clicks deep" complaint, seen from + the data. +4. **Provisioning contract.** gamecult-ops + `scripts/synapse-accounts-hand-managed.py` writes the rooms and both state + events from the Minds' stewardship documents; each project room's alias is + `#` (runbook 146-150). This is the only place the client + can learn a not-yet-joined room's steward today: hierarchy `canonical_alias` + joined to the root `org.gamecult.stewardships`. Follow-up + `ratatoskr:follow_up:stewardships-carry-room-ids` asks the projection to + name room ids so the client stops leaning on the alias convention. +5. **Sync and storage** (`src/client/initMatrix.ts`). The client uses + matrix-js-sdk 41.7.0 with `IndexedDBStore` (`web-sync-store`, 15-19) and the + Rust crypto store, and `startClient({ lazyLoadMembers: true })` (43-47): v2 + `/sync`, no sliding sync, no custom filter or `initialSyncLimit`. A cold + start is therefore an IndexedDB load plus an incremental sync from the + stored token, not a full initial sync. +6. **Server sync support.** `/_matrix/client/versions` on Synapse 1.164.1 + (127.0.0.1:8834) advertises `org.matrix.simplified_msc3575: true`: + simplified sliding sync (MSC4186) is available. Cinny 4.12.8 does not use it. +7. **Shell delivery.** `https://chat.gamecult.org/assets/index-DWIvG3QT.js` is + served at 3,736,520 bytes with no `Content-Encoding` even when gzip and br + are offered, and with no `Cache-Control` (only `ETag`/`Last-Modified`). The + service worker (`src/sw.ts`, 158 lines) handles only `install`, `activate`, + a session `message`, and authenticated media `fetch` (79-158); the + `vite-plugin-pwa` config sets `injectionPoint: undefined` + (`vite.config.ts` 101-113), so nothing is precached. +8. **No push path.** `src/sw.ts` has no `push` or `notificationclick` handler; + no code subscribes through `PushManager`; the only pusher the client sets is + the email pusher (`src/app/features/settings/notifications/SystemNotification.tsx` + 22-40). Notifications and sounds fire only from a live page: + `src/app/pages/client/ClientNonUIFeatures.tsx` 79-129 (invites) and 131-258 + (messages: `RoomEvent.Timeline` at 181-235, `new window.Notification` at + 156, `notification.ogg` played at 175-178). Nothing in `src/` listens for + `visibilitychange` or calls `retryImmediately`. +9. **Sygnal WebPush** (matrix-org/sygnal `docs/applications.md`, read + 2026-10-31; the page itself is marked partly out of date): a `webpush` app + takes `vapid_private_key`, `vapid_contact_email`, optional + `allowed_endpoints` and `ttl`; the client's pusher `data` carries + `endpoint`, `auth`, `p256dh` and optional `default_payload`; the push + carries `room_id`, `room_name`, `sender_display_name`, `content`, unread + counts and so on; browsers need a visible notification for each push + (`events_only` avoids event-less pushes). + +10. **Baseline at `dd88424e`** (Yggdrasil, `ygg-verify.sh`, image + `node:22-bookworm`, `npm ci --ignore-scripts`): `vitest run` passes (1 + file, 5 tests, Void's steward grouping); `vite build` succeeds, `dist/` 61 + MB with source maps, main chunk 3,739,716 bytes, Rust crypto wasm + 5,573,870 bytes, `dist/sw.mjs` 1.66 kB. `tsc --noEmit` fails at base with + 793 errors in 246 files (455 of them TS2614, named imports from + `matrix-js-sdk` 41.7.0 not resolved), so typecheck is not a usable gate as + is; a cut gates on zero errors in the files it adds and no rise in the + total. + +Not probed: phone frame times, the real resume timeline on the operator's +Android Chrome install (needs her device or remote DevTools), the Rust crypto +wasm size, and the router base under `/preview/` (its `config.json` sets +`hashRouter.basename` to `/`). + +## Why the PWA resumes slowly (diagnosis, moderate confidence) + +Android freezes a backgrounded installed web app and later discards it. On a +**discard**, returning is a full cold boot: 3.7 MB of uncompressed JS over a +phone link with no precache (fact 7), Rust crypto wasm init, the IndexedDB +store load, then a catch-up `/sync` over every room since the stored token +(fact 5). On a **freeze without discard**, the in-flight long-poll is dead, and +the SDK only notices on its own timers because the client never nudges it on +`visibilitychange` (fact 8). The catch-up sync grows with the number of rooms +and the swarm's chat volume, which is exactly where sliding sync helps +(fact 6), but the first two costs are cheaper to remove and should be measured +first. Without Web Push, a frozen page cannot notify at all, which is the +second half of the operator's report. + +## Model page: the estate + +| Kind | Named by | Over time | Who decides | +|---|---|---|---| +| Stewardship | `:stewardship:.n` in each Mind's mind | sequence advances on hand-over | the operator and the Minds; the minds are the truth | +| Estate projection | `org.gamecult.stewardships` on the root Space; `org.gamecult.steward` per project room | re-PUT on every provisioning run | gamecult-ops provisioning script (Eureka's slice) | +| Estate room list | the server's `/hierarchy` of the root Space for this user | changes with Space children and the user's invites and joins | Synapse (membership and join rules); the client derives | +| Room membership | the user's `m.room.member` | join on tap or first post | the user | +| Navigator view | derived per render | never stored | the client's estate index | + +## Authority map for the navigator + +- **Owner:** one estate index in the client (`src/app/features/estate/`): + `buildEstate()` is pure, `useEstate()` feeds it. It decides what rooms the + person sees, in which tier and group, and with what membership affordance. +- **Inputs:** the paged server `/hierarchy` of the root Space; root Space + state `org.gamecult.stewardships`; the person's membership per room from the + client store; unread and selection atoms for emphasis only; the `m.direct` + list for the direct-messages tier. +- **Outputs:** an ordered list of tiers (one per root room group and per + sub-Space, plus Direct), each holding steward groups or rooms, each room + marked joined, invited or joinable. +- **Derived:** steward group labels (Mind display names); counts; collapsed + state is per-device UI state only. +- **Demoted or dead:** the sidebar rail and its account-data ordering + (`useSidebarItems`) are dead; `useSpaceJoinedHierarchy` no longer decides + what is listed; Void's per-room steward reader is deleted, not kept beside + the index. +- **Shared paths:** every nav slot (home, direct, space routes) renders the + same navigator; tapping Join and sending a first post both go through + `mx.joinRoom`; deep links into a room still resolve through the existing + room routes. + +## Reconstructed collaboration plan (Atlas import) + +Planned in `#swarm` and `#minds` on 2026-10-31 and then lost to scroll. Status +as of 2026-10-31 ~15:00. + +**Who holds which part** + +| Part | Holder | Status | Evidence | +|---|---|---|---| +| The client: integration, navigator, emote slot, sound hookup, background canvas | Void (steward) | theme and steward grouping on branch; nothing else built | `void:stewardship:gamecult_-cinny.n1`; `#swarm` 10-31 14:54 Void report | +| Dings earcons: motif per event kind, one stable instrument per Mind | Aqua | not rendered: render env blocked, then .NET SDK installed and Faust scouted; AquaSynth PR #1 (Linux Faust loader, `faust-loader-platform-aware`) merged at 3b43b7bd | `aqua-body:follow_up:chat-s410-416`, `aqua-body:follow_up:chat-s410-416-render-env`, `aqua-body:resolution:follow_up.chat-s430-430.n1`, forge AquaSynth pull 1 | +| Ambient soundscape for the client | Aqua | idea only; no design recorded | operator `#swarm` 10-31 08:32 ("Motifs for everything"), `eureka-body:ruling:atlas-records-collaborative-work` | +| Background math, in CultMath: the public `fbm` gap, Phacelle erosion's Lipschitz bound, any new GLSL mirrors | Libby (steward of CultLib, which owns CultMath; holds `cultmath-tapes`) | sources located; nothing built | `#swarm` 10-31 06:34 and 06:46 Libby; `libby-body:follow_up:cultmath-ts-noise-absent`; `#swarm` 04:22 Libby accepts `cultmath-tapes` | +| Background scene and render: the raymarched terrain, the tree-and-squirrel intro scene, lighting and atmosphere, march and TAA tuning on the site-ground framework | Nibu (steward of Aetheria, whose volumetric nebula and cloud shaders she owns: `Raymarching/CloudShader.shader`, `Volumetric.cginc`) | proposed by the operator; not yet taken up by Nibu | operator to Self, 10-31: "If we want to spread the work around, Nibu owns the nebulae so she has shader expertise." | +| Animated bubbles, flourishes, corner avatar and XP bar | Void, with Kiko's offer on juice | advice only | `#swarm` 10-31 03:59 Kiko and Void, 06:45 Void | +| Attention bubbles | Eureka (Huginn heartbeat publishes), Void (render), Heimdall (scope rules) | advice only | `#swarm` 10-31 04:00 Eureka, 04:02 Heimdall | + +**Agreements** + +- Dings are pre-rendered clips shipped as static assets, no synth in the + browser: `dings//.ogg` plus a small manifest; an + unknown sender falls back to a neutral ding; ogg or mp3, not wav (Void + `#minds` 03:19, Aqua 07:41). The set is motif times instrument, and the + instrument says who spoke (operator 03:18, Eureka 03:19). Events that sound: + a mention, a DM, an invite, a message in an unmuted room; mentions and DMs + loud, ordinary messages quiet; everything else silent (Void `#minds` 03:19). + The operator wants far more than four motifs (08:32). The instrument per + Mind belongs in a typed document on the Mind side, not hardcoded (Eureka + 03:19, advice). Aqua's ear check comes before anything is called done, and + the operator's ear is ground truth (Aqua 07:41). Browsers block audio until + the first click, so the first ding after a fresh load may be silent. +- The background has its own canvas and budget, separate from the bubble + flourishes; capped at 30 fps, paused when hidden; under reduced motion one + static frame; an adaptive step-down (Aqua 04:17, Void 04:18 and 04:22). +- The background is an infinite raymarched terrain: CultMath noise plus the + Phacelle erosion filter, in the shader (operator 04:18 "painter's algorithm", + 05:24 "all we need is noise", 05:25 erosion, restated by the operator to + Self on 10-31: "use CultMath noise and erosion filter running in the shader + to have an infinite terrain. We can just raymarch that instead"). Voronoi + and Apollonian are dropped (05:24). +- Yggdrasil is the scene's centre (operator to Self, 10-31: "Yggdrasil should + be the star of the show now, so really the camera should just pull back a + bit when you login and the personas should be orbiting the tree, dancing + around each other in accordance with their activity"). On login the camera + pulls back from the tree; there is no separate intro to cut away from. The + Personas orbit the tree and move around each other by their live activity. +- One activity vocabulary drives both sound and motion: Aqua's activity-kinds + grammar (`aqua-body:follow_up:chat-s431-432`, "enumerate the kinds of agent + activity in typed state") is the shared contract between Aqua's motifs and + Nibu's orbits, not two vocabularies. The live feed behind it is the same + typed state the swarm dashboard projects + (`eureka-body:ruling:swarm-dashboard-first-thing2-surface`, Libby; + `libby-body:follow_up:chat-s354-359`): Run documents, the heartbeat trace. + What reaches the client is a member-safe projection: kind, Mind, time and + intensity, never briefs or mind contents. Matrix presence and typing cover + humans and are too coarse for Minds. Where the feed comes from is + `ratatoskr:question:activity-feed-source`. +- The scene renders in two passes, not one loop (operator to Self, 10-31: + "The tape optimized solid surface march should run really fast, i imagine, + way faster than the volumetric version, so they should not be one loop"). + Pass 1, the solid-surface march (terrain, tree, squirrel), sphere-traces + with the field's Lipschitz bound and interval tile culling, at its own + resolution, and writes depth. Pass 2, the volumetric atmosphere march on + the site-ground framework (reduced resolution, TAA), is bounded by pass 1's + depth, so it marches only up to the surface hit. Each pass has its own + budget and its own measurement. Nibu owns both passes. +- The web math path is CultLib's own: HLSL source lowered by dxc and + SPIRV-Cross to WebGL2 GLSL ES (`packages/cultmath/shaders/CultMath.glsl`, + `.Phacelle.glsl`; `tools/glsl/build-glsl.sh`), and C# CPU math through + .NET wasm, with parity presumed for now (operator 06:23, 06:48; Libby + 06:34). No TS or WGSL port. The gamecult.org site ground already + concatenates `CultMath.glsl` in `ground.js` and runs an interval-culled, + TAA-reprojected per-pixel march (CultLib `docs/cultmath-interval-ground-cut.md`, + spec `cultmath-interval-ground-cut-site-ground.r3`): that framework is the + base, not new tape work. +- Gaps go to the owner: a plain public `fbm` (only `fbm_grad`, `span_fbm`, + `af_fbm` exist) is added in CultMath, not in the client + (`libby-body:follow_up:cultmath-ts-noise-absent`; Libby 07:57 "tell me what + you need and I'll fill it at the library"). +- Mood and flourish ride as metadata on the event, never guessed from text; + bounded per-frame budget; reduced-motion fallback (Void 03:59, Kiko 03:59). +- XP is two numbers, never one: a cosmetic, labelled game bar fed by things + worth doing, and contribution credit shown only once a real measure exists + (Void 06:45, with Nibu and Kiko). + +**Open items** + +- Libby: the raymarch step bound needs the field's Lipschitz bound. CultMath + tracks `SNOISE_LIPSCHITZ` for noise; erosion's bound is unknown and must be + probed before the march is trusted. +- Libby: public `fbm` in CultMath, with its GLSL mirror. +- Nibu (once she takes it): the terrain scene, lighting and atmosphere, and + march and TAA tuning on the site ground's framework at a phone budget; + waits on Libby's `fbm` and the erosion bound. Then the intro scene in the + same world. +- Aqua: render the first earcon set once Faust builds in her sandbox; then the + instrument-per-Mind list and the manifest; then the soundscape design. +- Aqua and Nibu: agree the activity vocabulary once (Aqua's grammar), so + motifs and orbits read the same kinds. +- Void: the navigator (next cut); the Dings hook at + `ClientNonUIFeatures.tsx` 131-258; the background canvas after Libby's shader. +- Eureka: compression and caching for the shell on the chat host + (`ratatoskr:follow_up:chat-shell-compression`); the stewardship projection's + room ids; a Web Push gateway if the operator picks it. + +## Cut sequence + +`estate-navigator` is specced (`ratatoskr:cut_spec:cut-estate-navigator.r1`). +Each later cut is one line, with what it waits on. + +- `resume-fast`: precache the app shell in the service worker, nudge sync on + `visibilitychange`, and measure cold and warm resume on the operator's phone + before and after; waits on nothing; runs beside `estate-navigator` in its + own worktree, and pairs with the ops follow-up on compression. +- `pwa-web-push`: service worker `push` and `notificationclick` handlers, a + `webpush` pusher registered with VAPID keys, and a self-hosted Sygnal on the + chat host; waits on `ratatoskr:question:pwa-web-push`. +- `sliding-sync`: move the client to simplified sliding sync (MSC4186), which + the server already offers; waits on `resume-fast`'s measurements showing + catch-up sync dominates. +- `dings`: per-Mind earcons through the manifest at the existing notification + hook, and the same motifs in push notifications; waits on Aqua's renders. +- `emote-slot`: the pose-id emote on every bubble and the picker; waits on + `estate-navigator` (shared header work). +- `participant-tiers`: participants tiered by estate in the header menu, Minds + indexed by stewardship; waits on `estate-navigator`. +- `language`: our own strings and translations, the `org.gamecult.language` + picker, and the walk-through login for `patron-pt1`; waits on + `estate-navigator`. +- `terrain-background`: the raymarched CultMath noise and Phacelle terrain on + the site ground's march and TAA framework, in its own canvas and budget, as + two passes (solid-surface sphere trace writing depth, then the volumetric + atmosphere bounded by it), each measured on a phone; Nibu on the scene and + both passes, Libby's CultMath underneath, Void + integrating; waits on Libby's `fbm` and the erosion Lipschitz probe (both in + CultLib), then a phone measurement. +- `yggdrasil-scene`: the tree and squirrel at the centre of the terrain world; + on login the camera pulls back from the tree, and it may play during the + cold start or resume sync but never holds the client up; Nibu on the scene, + Libby on any CultMath it needs, Void integrating, Aqua's soundscape under + it; waits on `terrain-background`. +- `orbiting-personas`: Personas orbit the tree and dance around each other by + live activity, from the shared activity vocabulary; waits on + `yggdrasil-scene`, Aqua's activity-kinds grammar, and + `ratatoskr:question:activity-feed-source`. +- `bubbles-and-juice`: animated bubbles and mood flourishes from event + metadata, the corner avatar and the cosmetic XP bar; waits on `emote-slot`. +- `attention-bubbles`: per-person attention bubbles from Huginn's typed + attention document, scoped to room members; waits on Huginn publishing it. +- `rename-ratatoskr`: package name, app name and branding, manifest and + titles, the `/preview/` path and its deploy, and every in-repo `cinny` + reference that is ours (not upstream engine paths). The forge rename of + `GameCult/cinny` to `GameCult/Ratatoskr` is done + (`eureka-body:ruling:chat-client-named-ratatoskr`). diff --git a/docs/mind-feedback-pages.md b/docs/mind-feedback-pages.md new file mode 100644 index 00000000..9ff45d08 --- /dev/null +++ b/docs/mind-feedback-pages.md @@ -0,0 +1,242 @@ +# Mind feedback pages + +> The desk reads `gamecult.fork.*` now, not the `org.gamecult.feedback.*` projections named below; see CultLib `contracts/cultnet/governance/fork-card.md` (src/app/features/desk/forkContract.ts). + +Design only. No code exists for this; nothing here is built. Drafted by Kiko Self (2026-11-07) with Heimdall's draft rulings +on guests and hiding; placement ruled for this repo by Void. Follow-up `eureka-body:follow_up:ratatoskr-mind-feedback-pages`. +Every number below is provisional. + +**Schema owner.** The Huginn kinds, their fields, keys and refusals are specified in `huginn-feedback-kinds-proposal.md` (Eureka Self, 2026-11-07, in +`/opt/commons/handoff`), adopted for this page by Void's Persona. Where this page names a field of those kinds it mirrors that proposal; if they differ, the proposal wins. +This repo owns only the client: a typed interface mirroring those shapes, and fixtures. + +Each Mind's section in the estate navigator opens on that Mind's **feedback page**: the Mind's open questions with options, its +recommendation and what each waits on, answerable by the operator and anyone with an account. It replaces the old Fork Desk +(taken from the brief; no trace of it was found in this repo or in the Minds' state). + +## 1. Placement + +Each Mind's navigator section **opens on its feedback page**, rooms below it. A cross-Mind list of everything waiting on a person +is wanted later and is not part of this spec. On phones the page folds each question's options and the answer form behind its title, +so the section root stays a list of questions, not a wall of forms. + +## 2. Authority + +| Concern | Owner | The page's part | +|---|---|---| +| The question | a typed `question` document in the **owning Mind's own mind**, key `:question: