diff --git a/.github/DISCUSSION_TEMPLATE/issue-triage.yml b/.github/DISCUSSION_TEMPLATE/issue-triage.yml
new file mode 100644
index 00000000..e1a5a06a
--- /dev/null
+++ b/.github/DISCUSSION_TEMPLATE/issue-triage.yml
@@ -0,0 +1,127 @@
+labels: ["needs-confirmation"]
+body:
+ - type: markdown #add faqs in future
+ attributes:
+ value: |
+ > [!IMPORTANT]
+ > Please read through [the Discussion rules](https://github.com/cinnyapp/cinny/discussions/2653) and check for both existing [Discussions](https://github.com/cinnyapp/cinny/discussions?discussions_q=) and [Issues](https://github.com/cinnyapp/cinny/issues?q=sort%3Areactions-desc) prior to opening a new Discussion.
+ - type: markdown
+ attributes:
+ value: "# Issue Details"
+ - type: textarea
+ attributes:
+ label: Issue Description
+ description: |
+ Provide a detailed description of the issue. Include relevant information, such as:
+ - The feature or configuration option you encounter the issue with.
+ - Screenshots, screen recordings, or other supporting media (as needed).
+ - If this is a regression of an existing issue that was closed or resolved, please include the previous item reference (Discussion, Issue, PR, commit) in your description.
+ placeholder: |
+ When I try to send a message in a room, the message doesn't appear in the timeline.
+ OR
+ The application crashes when I click on the settings button.
+ validations:
+ required: true
+ - type: textarea
+ attributes:
+ label: Expected Behavior
+ description: |
+ Describe how you expect Cinny to behave in this situation.
+ placeholder: |
+ I expected the message to appear in the room timeline immediately after sending.
+ OR
+ The settings panel should open smoothly without any crashes.
+ validations:
+ required: true
+ - type: textarea
+ attributes:
+ label: Actual Behavior
+ description: |
+ Describe how Cinny actually behaves in this situation. If it is not immediately obvious how the actual behavior differs from the expected behavior described above, please be sure to mention the deviation specifically.
+ placeholder: |
+ The application freezes for 3 seconds and then shows a white screen.
+ validations:
+ required: true
+ - type: textarea
+ attributes:
+ label: Reproduction Steps
+ description: |
+ Provide a detailed set of step-by-step instructions for reproducing this issue.
+ placeholder: |
+ 1. Open Cinny and log in to my account
+ 2. Navigate to the #general room
+ 3. Type a message in the message box
+ 4. Press Enter to send
+ 5. Notice that the message doesn't appear in the timeline
+ validations:
+ required: true
+ - type: textarea
+ attributes:
+ label: Environment
+ description: |
+ Please provide information about your environment. Include the following:
+ - OS:
+ - Browser:
+ - Cinny Web Version: (app.cinny.in or self hosted)
+ - Cinny desktop Version: (appimage or deb or flatpak)
+ - Matrix Homeserver:
+ placeholder: |
+ - OS: Windows 11
+ - Browser: Chrome 120.0.6099.109
+ - Cinny Web Version: 3.2.0 (app.cinny.in or self hosted)
+ - Cinny desktop Version: 3.2.0 (appimage or deb or flatpak)
+ - Matrix Homeserver: matrix.org (Synapse 1.97.0)
+ render: text
+ validations:
+ required: true
+ - type: textarea
+ id: logs
+ attributes:
+ label: Relevant Logs
+ description: |
+ If applicable, add browser console logs to help explain your problem.
+
+ **To get browser console logs:**
+ - Chrome/Edge: Press F12 β Console tab
+ - Firefox: Press F12 β Console tab
+ - Safari: Develop β Show Web Inspector β Console
+
+ Please wrap large log outputs in code blocks with triple backticks (```).
+ placeholder: |
+ ```
+ Error: Failed to send message
+ at MessageComposer.sendMessage (composer.js:245)
+ at HTMLButtonElement.onClick (composer.js:189)
+ TypeError: Cannot read property 'content' of undefined
+ at RoomTimeline.render (timeline.js:567)
+ ```
+ render: shell
+ validations:
+ required: false
+ - type: textarea
+ attributes:
+ label: Additional context
+ description: |
+ Add any other context about the problem here (e.g., when did this start happening, does it happen on different homeservers, etc.)
+ placeholder: |
+ - This started happening after I updated to version 3.2.0
+ - It only happens in encrypted rooms, not in public rooms
+ - I've tried on both Firefox and Chrome with the same result
+ - It works fine on my phone using the same account
+ - This happens on all homeservers I've tested (matrix.org, mozilla.org)
+ validations:
+ required: false
+ - type: markdown
+ attributes:
+ value: |
+ # User Acknowledgements
+ > [!TIP]
+ > Use these links to review the existing Cinny [Discussions](https://github.com/cinnyapp/cinny/discussions?discussions_q=) and [Issues](https://github.com/cinnyapp/cinny/issues?q=sort%3Areactions-desc).
+ - type: checkboxes #add faqs in future
+ attributes:
+ label: "I acknowledge that:"
+ options:
+ - label: I have searched the Cinny repository (both open and closed Discussions and Issues) and confirm this is not a duplicate of an existing issue or discussion.
+ required: true
+ - label: I have checked the "Preview" tab on all text fields to ensure that everything looks right, and have wrapped all configuration and code in code blocks with a group of three backticks (` ``` `) on separate lines.
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
deleted file mode 100644
index 76fc578a..00000000
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ /dev/null
@@ -1,57 +0,0 @@
-name: π Bug Report
-description: Report a bug
-
-body:
- - type: markdown
- attributes:
- value: |
- ## First of all
- 1. Please search for [existing issues](https://github.com/ajbura/cinny/issues?q=is%3Aissue) about this problem first.
- 2. Make sure Cinny is up to date.
- 3. Make sure it's an issue with Cinny and not something else you are using.
- 4. Remember to be friendly.
-
- - type: textarea
- id: description
- attributes:
- label: Describe the bug
- description: A clear description of what the bug is. Include screenshots if applicable.
- placeholder: Bug description
- validations:
- required: true
-
- - type: textarea
- id: reproduction
- attributes:
- label: Reproduction
- description: Steps to reproduce the behavior.
- placeholder: |
- 1. Go to ...
- 2. Click on ...
- 3. See error
-
- - type: textarea
- id: expected-behavior
- attributes:
- label: Expected behavior
- description: A clear description of what you expected to happen.
-
- - type: textarea
- id: info
- attributes:
- label: Platform and versions
- description: "Provide OS, browser and Cinny version with your Homeserver."
- placeholder: |
- 1. OS: [e.g. Windows 10, MacOS]
- 2. Browser: [e.g. chrome 99.5, firefox 97.2]
- 3. Cinny version: [e.g. 1.8.1 (app.cinny.in)]
- 4. Matrix homeserver: [e.g. matrix.org]
- render: shell
- validations:
- required: true
-
- - type: textarea
- id: context
- attributes:
- label: Additional context
- description: Add any other context about the problem here.
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
index 91ba4f0a..a5eea79c 100644
--- a/.github/ISSUE_TEMPLATE/config.yml
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -1,4 +1,5 @@
+blank_issues_enabled: false
contact_links:
- - name: π¬ Matrix Chat
- url: https://matrix.to/#/#cinny:matrix.org
- about: Ask questions and talk to other Cinny users and the maintainers
+ - name: Features, Bug Reports, Questions
+ url: https://github.com/cinnyapp/cinny/discussions/new/choose
+ about: Our preferred starting point if you have any questions or suggestions about features or behavior.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
deleted file mode 100644
index 412d5181..00000000
--- a/.github/ISSUE_TEMPLATE/feature_request.yml
+++ /dev/null
@@ -1,33 +0,0 @@
-name: π‘ Feature Request
-description: Suggest an idea
-
-body:
- - type: textarea
- id: problem
- attributes:
- label: Describe the problem
- description: A clear description of the problem this feature would solve
- placeholder: "I'm always frustrated when..."
- validations:
- required: true
-
- - type: textarea
- id: solution
- attributes:
- label: "Describe the solution you'd like"
- description: A clear description of what change you would like
- placeholder: "I would like to..."
- validations:
- required: true
-
- - type: textarea
- id: alternatives
- attributes:
- label: Alternatives considered
- description: "Any alternative solutions you've considered"
-
- - type: textarea
- id: context
- attributes:
- label: Additional context
- description: Add any other context about the problem here.
diff --git a/.github/ISSUE_TEMPLATE/preapproved.md b/.github/ISSUE_TEMPLATE/preapproved.md
new file mode 100644
index 00000000..485e727e
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/preapproved.md
@@ -0,0 +1,9 @@
+---
+name: Pre-Discussed and Approved Topics
+about: |-
+ Only for topics already discussed and approved in the GitHub Discussions section.
+---
+
+**DO NOT OPEN A NEW ISSUE. PLEASE USE THE DISCUSSIONS SECTION.**
+
+**I DIDN'T READ THE ABOVE LINE. PLEASE CLOSE THIS ISSUE.**
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index 1292f1d2..4b351db6 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -1,22 +1,9 @@
-
-
### Description
-
+
Fixes #
-#### Type of change
-
-- [ ] Bug fix (non-breaking change which fixes an issue)
-- [ ] New feature (non-breaking change which adds functionality)
-- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
-- [ ] This change requires a documentation update
-
### Checklist:
-- [ ] My code follows the style guidelines of this project
-- [ ] I have performed a self-review of my own code
-- [ ] I have commented my code, particularly in hard-to-understand areas
-- [ ] I have made corresponding changes to the documentation
-- [ ] My changes generate no new warnings
+- [ ] I have read and understood the [Contribution policy](https://github.com/ajbura/cinny/blob/dev/CONTRIBUTING.md).
diff --git a/.github/SECURITY.md b/.github/SECURITY.md
deleted file mode 100644
index 2dd642df..00000000
--- a/.github/SECURITY.md
+++ /dev/null
@@ -1,3 +0,0 @@
-# Reporting a Vulnerability
-
-**If you've found a security vulnerability, please report it to cinnyapp@gmail.com**
\ No newline at end of file
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 3683b106..c5a31bb7 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -2,14 +2,14 @@
version: 2
updates:
- - package-ecosystem: npm
- directory: /
- schedule:
- interval: weekly
- day: "tuesday"
- time: "01:00"
- timezone: "Asia/Kolkata"
- open-pull-requests-limit: 15
+# - package-ecosystem: npm
+# directory: /
+# schedule:
+# interval: weekly
+# day: "tuesday"
+# time: "01:00"
+# timezone: "Asia/Kolkata"
+# open-pull-requests-limit: 15
- package-ecosystem: github-actions
directory: /
diff --git a/.github/renovate.json b/.github/renovate.json
index 46ce4fdf..cddac35d 100644
--- a/.github/renovate.json
+++ b/.github/renovate.json
@@ -1,15 +1,26 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
- "config:base",
- ":dependencyDashboardApproval"
+ "config:recommended",
+ ":dependencyDashboardApproval",
+ ":semanticCommits",
+ "group:monorepos"
],
- "labels": [ "Dependencies" ],
+ "labels": ["Dependencies"],
+ "rebaseWhen": "conflicted",
"packageRules": [
{
- "matchUpdateTypes": [ "lockFileMaintenance" ]
+ "matchUpdateTypes": ["lockFileMaintenance"]
+ },
+ {
+ "matchPackageNames": ["slate", "slate-dom", "slate-history", "slate-react"]
+ },
+ {
+ "matchPackageNames": ["linkifyjs", "linkify-react"]
}
],
- "lockFileMaintenance": { "enabled": true },
+ "lockFileMaintenance": {
+ "enabled": true
+ },
"dependencyDashboard": true
-}
\ No newline at end of file
+}
diff --git a/.github/workflows/build-pull-request.yml b/.github/workflows/build-pull-request.yml
index ae1097be..11b5056c 100644
--- a/.github/workflows/build-pull-request.yml
+++ b/.github/workflows/build-pull-request.yml
@@ -12,12 +12,12 @@ jobs:
PR_NUMBER: ${{github.event.number}}
steps:
- name: Checkout repository
- uses: actions/checkout@v4.1.7
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup node
- uses: actions/setup-node@v4.0.3
+ uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
- node-version: 20.12.2
- cache: 'npm'
+ node-version-file: ".node-version"
+ package-manager-cache: false
- name: Install dependencies
run: npm ci
- name: Build app
@@ -25,7 +25,7 @@ jobs:
NODE_OPTIONS: '--max_old_space_size=4096'
run: npm run build
- name: Upload artifact
- uses: actions/upload-artifact@v4.3.6
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: preview
path: dist
@@ -33,7 +33,7 @@ jobs:
- name: Save pr number
run: echo ${PR_NUMBER} > ./pr.txt
- name: Upload pr number
- uses: actions/upload-artifact@v4.3.6
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: pr
path: ./pr.txt
diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml
index 65513427..42eab7ef 100644
--- a/.github/workflows/cla.yml
+++ b/.github/workflows/cla.yml
@@ -12,7 +12,7 @@ jobs:
- 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@v2.5.1
+ 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
diff --git a/.github/workflows/deploy-pull-request.yml b/.github/workflows/deploy-pull-request.yml
index 72ffed87..e59681ce 100644
--- a/.github/workflows/deploy-pull-request.yml
+++ b/.github/workflows/deploy-pull-request.yml
@@ -1,4 +1,5 @@
name: Deploy PR to Netlify
+run-name: "Deploy PR to Netlify (${{ github.event.workflow_run.head_branch }})"
on:
workflow_run:
@@ -15,16 +16,22 @@ jobs:
if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps:
- name: Download pr number
- uses: dawidd6/action-download-artifact@bf251b5aa9c2f7eeb574a96ee720e24f801b7c11
+ uses: dawidd6/action-download-artifact@d63b86af1b34672e53c440b1b83979861906bad7 # v24
with:
workflow: ${{ github.event.workflow.id }}
run_id: ${{ github.event.workflow_run.id }}
name: pr
- - name: Output pr number
+ - name: Validate and output pr number
id: pr
- run: echo "id=$(> $GITHUB_OUTPUT
+ run: |
+ PR_ID=$(> "${GITHUB_OUTPUT}"
- name: Download artifact
- uses: dawidd6/action-download-artifact@bf251b5aa9c2f7eeb574a96ee720e24f801b7c11
+ uses: dawidd6/action-download-artifact@d63b86af1b34672e53c440b1b83979861906bad7 # v24
with:
workflow: ${{ github.event.workflow.id }}
run_id: ${{ github.event.workflow_run.id }}
@@ -32,7 +39,7 @@ jobs:
path: dist
- name: Deploy to Netlify
id: netlify
- uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654
+ uses: nwtgck/actions-netlify@d22a32a27c918fe470bbc562e984f80ec48c2668 # v4.0.0
with:
publish-dir: dist
deploy-message: "Deploy PR ${{ steps.pr.outputs.id }}"
@@ -41,16 +48,13 @@ jobs:
enable-pull-request-comment: false
enable-commit-comment: false
env:
- NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
+ 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: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
with:
- pr_number: ${{ steps.pr.outputs.id }}
- comment_tag: ${{ steps.pr.outputs.id }}
+ number: ${{ steps.pr.outputs.id }}
message: |
- Preview: ${{ steps.netlify.outputs.deploy-url }}
- β οΈ Exercise caution. Use test accounts. β οΈ
\ No newline at end of file
+ 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 7c67c27a..302c3788 100644
--- a/.github/workflows/docker-pr.yml
+++ b/.github/workflows/docker-pr.yml
@@ -5,15 +5,59 @@ on:
paths:
- 'Dockerfile'
- '.github/workflows/docker-pr.yml'
+ - '.github/workflows/prod-deploy.yml'
jobs:
docker-build:
runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+
steps:
- name: Checkout repository
- uses: actions/checkout@v4.1.7
- - name: Build Docker image
- uses: docker/build-push-action@v6.7.0
+ 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
+ if: github.event.pull_request.head.repo.fork == false
+ uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
+ with:
+ username: ${{ secrets.DOCKER_USERNAME }}
+ password: ${{ secrets.DOCKER_PASSWORD }}
+ continue-on-error: true
+
+ - name: Login to the Github Container registry #Do not update this action from a outside PR
+ if: github.event.pull_request.head.repo.fork == false
+ uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+ continue-on-error: true
+
+ - name: Extract metadata (tags, labels) for Docker, GHCR
+ id: meta
+ uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
+ with:
+ images: |
+ ajbura/cinny
+ ghcr.io/${{ github.repository }}
+
+ - name: Build Docker image (no push)
+ uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
+ platforms: linux/amd64
push: false
+ load: true
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+
+ - name: Show Docker images
+ run: docker images
diff --git a/.github/workflows/lockfile.yml b/.github/workflows/lockfile.yml
index 410d2f59..a9cc1a34 100644
--- a/.github/workflows/lockfile.yml
+++ b/.github/workflows/lockfile.yml
@@ -14,9 +14,9 @@ jobs:
pull-requests: write
steps:
- name: Checkout
- uses: actions/checkout@v4.1.7
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: NPM Lockfile Changes
- uses: codepunkt/npm-lockfile-changes@b40543471c36394409466fdb277a73a0856d7891
+ uses: codepunkt/npm-lockfile-changes@b40543471c36394409466fdb277a73a0856d7891 # v1.0.0
with:
token: ${{ secrets.GITHUB_TOKEN }}
# Optional inputs, can be deleted safely if you are happy with default values.
diff --git a/.github/workflows/netlify-dev.yml b/.github/workflows/netlify-dev.yml
index 71d34294..12107685 100644
--- a/.github/workflows/netlify-dev.yml
+++ b/.github/workflows/netlify-dev.yml
@@ -11,12 +11,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v4.1.7
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup node
- uses: actions/setup-node@v4.0.3
+ uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
- node-version: 20.12.2
- cache: 'npm'
+ node-version-file: ".node-version"
+ package-manager-cache: false
- name: Install dependencies
run: npm ci
- name: Build app
@@ -24,7 +24,7 @@ jobs:
NODE_OPTIONS: '--max_old_space_size=4096'
run: npm run build
- name: Deploy to Netlify
- uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654
+ uses: nwtgck/actions-netlify@d22a32a27c918fe470bbc562e984f80ec48c2668 # v4.0.0
with:
publish-dir: dist
deploy-message: 'Dev deploy ${{ github.sha }}'
diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml
new file mode 100644
index 00000000..a52ee8e6
--- /dev/null
+++ b/.github/workflows/pr-title.yml
@@ -0,0 +1,15 @@
+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
index f8f13a7e..2a8192db 100644
--- a/.github/workflows/prod-deploy.yml
+++ b/.github/workflows/prod-deploy.yml
@@ -10,12 +10,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v4.1.7
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup node
- uses: actions/setup-node@v4.0.3
+ uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
- node-version: 20.12.2
- cache: 'npm'
+ node-version-file: '.node-version'
+ package-manager-cache: false
- name: Install dependencies
run: npm ci
- name: Build app
@@ -23,7 +23,7 @@ jobs:
NODE_OPTIONS: '--max_old_space_size=4096'
run: npm run build
- name: Deploy to Netlify
- uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654
+ uses: nwtgck/actions-netlify@d22a32a27c918fe470bbc562e984f80ec48c2668 # v4.0.0
with:
publish-dir: dist
deploy-message: 'Prod deploy ${{ github.ref_name }}'
@@ -52,45 +52,46 @@ jobs:
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@c062e08bd532815e2082a85e87e3ef29c3e6d191
+ 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
+ name: Push Docker image to Docker Hub, GHCR
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
- uses: actions/checkout@v4.1.7
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up QEMU
- uses: docker/setup-qemu-action@v3.2.0
+ uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4.3.0
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3.6.1
- - name: Login to Docker Hub
- uses: docker/login-action@v3.3.0
+ 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 Container registry
- uses: docker/login-action@v3.3.0
+ - 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
+ - name: Extract metadata (tags, labels) for Docker, GHCR
id: meta
- uses: docker/metadata-action@v5.5.1
+ 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@v6.7.0
+ uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
platforms: linux/amd64,linux/arm64
diff --git a/.husky/pre-commit b/.husky/pre-commit
new file mode 100644
index 00000000..7aea7a8d
--- /dev/null
+++ b/.husky/pre-commit
@@ -0,0 +1,3 @@
+# These are commented until we enable lint and typecheck
+# npx tsc -p tsconfig.json --noEmit
+# npx lint-staged
\ No newline at end of file
diff --git a/.node-version b/.node-version
new file mode 100644
index 00000000..f94d3c2e
--- /dev/null
+++ b/.node-version
@@ -0,0 +1 @@
+24.13.1
\ No newline at end of file
diff --git a/.prettierignore b/.prettierignore
index bc876b6c..036ee8bc 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -3,4 +3,4 @@ node_modules
package.json
package-lock.json
LICENSE
-README.md
\ No newline at end of file
+harness
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index fea421b2..859eab14 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -2,43 +2,162 @@
First off, thanks for taking the time to contribute! β€οΈ
-All types of contributions are encouraged and valued. Please make sure to read the relevant section before making your contribution. It will make it a lot easier for us maintainers and smooth out the experience for all involved. The community looks forward to your contributions. π
+This document describes the process of contributing to Cinny. 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.
-> And if you like the project, but just don't have time to contribute, that's fine. There are other easy ways to support the project and show your appreciation, which we would also be very happy about:
+> And if you like the project, but just don't have time to contribute, that's fine.
+> There are other easy ways to support the project and show your
+> appreciation, which we would also be very happy about:
+>
> - Star the project
> - Tweet about it (tag @cinnyapp)
> - Refer this project in your project's readme
> - Mention the project at local meetups and tell your friends/colleagues
> - [Donate to us](https://cinny.in/#sponsor)
-## Bug reports
+## The Critical Rule
-Bug reports and feature suggestions must use descriptive and concise titles and be submitted to [GitHub Issues](https://github.com/ajbura/cinny/issues). Please use the search function to make sure that you are not submitting duplicates, and that a similar report or request has not already been resolved or rejected.
+**The most important rule: you must understand your code.** If you can't
+explain what your changes do and how they interact with the greater system, do not contribute to this project.
-## Pull requests
+## 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.**
+
+## Quick Guide
+
+### I'd like to contribute
> ### Legal Notice
-> 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.
+>
+> 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.
-**NOTE: If you want to add new features, please discuss with maintainers before coding or opening a pull request.** This is to ensure that we are on same track and following our roadmap.
+[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.
+Issues that are extra friendly to new contributors are tagged with
+["contributor friendly"].
-**Please use clean, concise titles for your pull requests.** We use commit squashing, so the final commit in the dev branch will carry the title of the pull request. For easier sorting in changelog, start your pull request titles using one of the verbs "Add", "Change", "Remove", or "Fix" (present tense).
+["contributor friendly"]: https://github.com/cinnyapp/cinny/issues?q=is%3Aissue%20is%3Aopen%20label%3A%22contributor%20friendly%22
+[sign the cla]: https://github.com/cinnyapp/cla
+[why CLA?]: https://github.com/cinnyapp/cla#but-why
-Example:
+### I have a bug! / Something isn't working
-|Not ideal|Better|
-|---|----|
-|Fixed markAllAsRead in RoomTimeline|Fix read marker when paginating room timeline|
+First, search the issue tracker and discussions for similar issues. Tip: also
+search for [closed issues] and [discussions] β your issue might have already
+been fixed!
-It is not always possible to phrase every change in such a manner, but it is desired.
+> [!NOTE]
+>
+> If there is an _open_ issue or discussion that matches your problem,
+> **please do not comment on it unless you have valuable insight to add**.
+>
+> GitHub has a very _noisy_ set of default notification settings which
+> sends an email to _every participant_ in an issue/discussion every time
+> someone adds a comment. Instead, use the handy upvote button for discussions,
+> and/or emoji reactions on both discussions and issues, which are a visible
+> yet non-disruptive way to show your support.
-**The smaller the set of changes in the pull request is, the quicker it can be reviewed and merged.** Splitting tasks into multiple smaller pull requests is often preferable.
+If your issue hasn't been reported already, open an ["Issue Triage"] discussion
+and make sure to fill in the template **completely**. They are vital for
+maintainers to figure out important details about your setup.
-Also, we use [ESLint](https://eslint.org/) for clean and stylistically consistent code syntax, so make sure your pull request follow it.
+> [!WARNING]
+>
+> A _very_ common mistake is to file a bug report either as a Q&A or a Feature
+> Request. **Please don't do this.** Otherwise, maintainers would have to ask
+> for your system information again manually, and sometimes they will even ask
+> you to create a new discussion because of how few detailed information is
+> required for other discussion types compared to Issue Triage.
+>
+> Because of this, please make sure that you _only_ use the "Issue Triage"
+> category for reporting bugs β thank you!
-**For any query or design discussion, join our [Matrix room](https://matrix.to/#/#cinny:matrix.org).**
+[closed issues]: https://github.com/cinnyapp/cinny/issues?q=is%3Aissue%20state%3Aclosed
+[discussions]: https://github.com/cinnyapp/cinny/discussions?discussions_q=is%3Aclosed
+["issue triage"]: https://github.com/cinnyapp/cinny/discussions/new?category=issue-triage
-## Helpful links
-- [BEM methodology](http://getbem.com/introduction/)
-- [Atomic design](https://bradfrost.com/blog/post/atomic-web-design/)
-- [Matrix JavaScript SDK documentation](https://matrix-org.github.io/matrix-js-sdk/index.html)
+### I have an idea for a feature
+
+Like bug reports, first search through both issues and discussions and try to
+find if your feature has already been requested. Otherwise, open a discussion
+in the ["Feature Requests, Ideas"] category.
+
+["feature requests, ideas"]: https://github.com/cinnyapp/cinny/discussions/new?category=feature-requests-ideas
+
+### I've implemented a feature
+
+1. If there is an issue for the feature, open a pull request straight away.
+2. If there is no issue, open a discussion and link to your branch.
+3. If you want to live dangerously, open a pull request and
+ [hope for the best](#pull-requests-implement-an-issue).
+
+### I have a question which is neither a bug report nor a feature request
+
+Open an [Q&A discussion], or join our [Matrix Space] and ask away in the
+`Cinny Support` room.
+
+Do not use other rooms to ask for help as our rooms are mostly specific
+topic only. If you do ask a question there, you will be redirected
+to `Cinny Support` room instead.
+
+> [!NOTE]
+> If your question is about a missing feature, please open a discussion under
+> the ["Feature Requests, Ideas"] category. If Cinny is behaving
+> unexpectedly, use the ["Issue Triage"] category.
+>
+> The "Q&A" category is strictly for other kinds of discussions and do not
+> require detailed information unlike the two other categories, meaning that
+> maintainers would have to spend the extra effort to ask for basic information
+> if you submit a bug report under this category.
+>
+> Therefore, please **pay attention to the category** before opening
+> discussions to save us all some time and energy. Thank you!
+
+[q&a discussion]: https://github.com/cinnyapp/cinny/discussions/new?category=q-a
+[matrix space]: https://matrix.to/#/#cinny:matrix.org
+
+## General Patterns
+
+### Issues are Actionable
+
+> [!NOTE]
+> This is WIP and only issue with number greater than #2876 are actionable.
+
+The Cinny [issue tracker](https://github.com/cinnyapp/cinny/issues)
+is for _actionable items_.
+
+Unlike some other projects, Cinny **does not use the issue tracker for
+discussion or feature requests**. Instead, we use GitHub
+[discussions](https://github.com/cinnyapp/cinny/discussions) for that.
+Once a discussion reaches a point where a well-understood, actionable
+item is identified, it is moved to the issue tracker. **This pattern
+makes it easier for maintainers or contributors to find issues to work on
+since _every issue_ is ready to be worked on.**
+
+If you are experiencing a bug and have clear steps to reproduce it, please
+open an issue. If you are experiencing a bug but you are not sure how to
+reproduce it or aren't sure if it's a bug, please open a discussion.
+If you have an idea for a feature, please open a discussion.
+
+### Pull Requests Implement an Issue
+
+Pull requests should be associated with a previously accepted issue.
+**If you open a pull request for something that wasn't previously discussed,**
+it may be closed or remain stale for an indefinite period of time. I'm not
+saying it will never be accepted, but the odds are stacked against you.
+
+Issues tagged with "feature" represent accepted, well-scoped feature requests.
+If you implement an issue tagged with feature as described in the issue, your
+pull request will be accepted with a high degree of certainty.
+
+> [!NOTE]
+>
+> **Pull requests are NOT a place to discuss feature design.** Please do
+> not open a WIP pull request to discuss a feature. Instead, use a discussion
+> and link to your branch.
diff --git a/Dockerfile b/Dockerfile
index 58e5ba74..c09f7861 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,5 +1,5 @@
## Builder
-FROM node:20.12.2-alpine3.18 as builder
+FROM node:24.13.1-alpine AS builder
WORKDIR /src
@@ -11,7 +11,7 @@ RUN npm run build
## App
-FROM nginx:1.27.0-alpine
+FROM nginx:1.31.5-alpine
COPY --from=builder /src/dist /app
COPY --from=builder /src/docker-nginx.conf /etc/nginx/conf.d/default.conf
diff --git a/HACKING.md b/HACKING.md
new file mode 100644
index 00000000..30c4960f
--- /dev/null
+++ b/HACKING.md
@@ -0,0 +1,60 @@
+# Developing Cinny
+
+> [!TIP]
+> We recommend using a version manager as versions change very quickly.
+> You will likely need to switch between multiple Node.js versions based
+> on the needs of different projects you're working on. [NVM-windows]
+> on Windows and [nvm] on Linux/macOS are pretty good choices. Recommended
+> nodejs version is Krypton LTS (v24.13.1).
+
+[nvm-windows]: https://github.com/coreybutler/nvm-windows#installation--upgrades
+[nvm]: https://github.com/nvm-sh/nvm
+
+Execute the following commands to start a development server:
+
+```sh
+npm ci # Installs all dependencies
+npm start # Serve a development version
+```
+
+To build the app:
+
+```sh
+npm run build # Compiles the app into the dist/ directory
+```
+
+To commit changes:
+
+```sh
+npm run commit
+```
+
+## Running with Docker
+
+This repository includes a Dockerfile, which builds the application from
+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 .
+```
+
+You can then run the container you've built with a command similar to this:
+
+```
+docker run -p 8080:80 cinny:latest
+```
+
+This will forward your `localhost` port 8080 to the container's port 80.
+You can visit the app in your browser by navigating to `http://localhost:8080`.
+
+## Code formatting
+
+We use [ESLint](https://eslint.org/) for clean and stylistically
+consistent code syntax, so make sure your pull request follow it.
+
+## Helpful links
+
+- [BEM methodology](http://getbem.com/introduction/)
+- [Atomic design](https://bradfrost.com/blog/post/atomic-web-design/)
+- [Matrix JavaScript SDK documentation](https://matrix-org.github.io/matrix-js-sdk/index.html)
diff --git a/README.md b/README.md
index 5cee6fa2..46c7a67b 100644
--- a/README.md
+++ b/README.md
@@ -12,34 +12,66 @@
-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.
+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)
+> [!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.
+
+[replacing]: https://github.com/cinnyapp/cinny/issues/257#issuecomment-3714406704
+
## Getting started
-* Web app is available at https://app.cinny.in and gets updated on each new release. The `dev` branch is continuously deployed at https://dev.cinny.in but keep in mind that it could have things broken.
+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 [cinny-desktop repository](https://github.com/cinnyapp/cinny-desktop).
+You can also download our desktop app from the [cinny-desktop repository].
-* To host Cinny on your own, download tarball of the app from [GitHub release](https://github.com/cinnyapp/cinny/releases/latest).
-You can serve the application with a webserver of your choice by simply copying `dist/` directory to the webroot.
-To set default Homeserver on login, register and Explore Community page, place a customized [`config.json`](config.json) in webroot of your choice.
-You will also need to setup redirects to serve the assests. An example setting of redirects for netlify is done in [`netlify.toml`](netlify.toml). You can also set `hashRouter.enabled = true` in [`config.json`](config.json) if you have trouble setting redirects.
-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 change `base: '/app'`.
+[app.cinny.in]: https://app.cinny.in
+[dev.cinny.in]: https://dev.cinny.in
+[cinny-desktop repository]: https://github.com/cinnyapp/cinny-desktop
-* Alternatively you can just pull the [DockerHub image](https://hub.docker.com/r/ajbura/cinny) by:
- ```
- docker pull ajbura/cinny
- ```
- or [ghcr image](https://github.com/cinnyapp/cinny/pkgs/container/cinny) by:
- ```
- docker pull ghcr.io/cinnyapp/cinny:latest
- ```
+## Contributing and Developing
-
-PGP Public Key to verify tarball
+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.
+
+## 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].
+
+* 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-----
@@ -86,31 +118,11 @@ mxFo+ioe/ABCufSmyqFye0psX3Sp
```
-## Local development
-> We recommend using a version manager as versions change very quickly. You will likely need to switch
-between multiple Node.js versions based on the needs of different projects you're working on. [NVM on windows](https://github.com/coreybutler/nvm-windows#installation--upgrades) on Windows and [nvm](https://github.com/nvm-sh/nvm) on Linux/macOS are pretty good choices. Recommended nodejs version is Iron LTS (v20).
+## License
+Cinny Project
+Copyright Β© 2024βpresent Ajay Bura
+https://cinny.in
-Execute the following commands to start a development server:
-```sh
-npm ci # Installs all dependencies
-npm start # Serve a development version
-```
-
-To build the app:
-```sh
-npm run build # Compiles the app into the dist/ directory
-```
-
-### Running with Docker
-This repository includes a Dockerfile, which builds the application from 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 .
-```
-
-You can then run the container you've built with a command similar to this:
-```
-docker run -p 8080:80 cinny:latest
-```
-
-This will forward your `localhost` port 8080 to the container's port 80. You can visit the app in your browser by navigating to `http://localhost:8080`.
+Cinny is licensed under the GNU Affero General Public License,
+Version 3 of the License (AGPL-3.0-only).
+You may obtain a copy of the License at https://www.gnu.org/licenses/agpl-3.0.html
\ No newline at end of file
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 de6015a1..e945d1c4 100644
--- a/config.json
+++ b/config.json
@@ -1,34 +1,17 @@
{
- "defaultHomeserver": 2,
- "homeserverList": [
- "converser.eu",
- "envs.net",
- "matrix.org",
- "monero.social",
- "mozilla.org",
- "xmr.se"
- ],
+ "defaultHomeserver": 1,
+ "homeserverList": ["converser.eu", "matrix.org", "mozilla.org", "unredacted.org", "xmr.se"],
"allowCustomHomeservers": true,
"featuredCommunities": {
"openAsDefault": false,
- "spaces": [
- "#cinny-space:matrix.org",
- "#community:matrix.org",
- "#space:envs.net",
- "#science-space:matrix.org",
- "#libregaming-games:tchncs.de",
- "#mathematics-on:matrix.org"
- ],
+ "spaces": ["#gamecult:gamecult.org"],
"rooms": [
- "#cinny:matrix.org",
+ "#tuwunel:grin.hu",
"#freesoftware:matrix.org",
- "#pcapdroid:matrix.org",
- "#gentoo:matrix.org",
- "#PrivSec.dev:arcticfoxes.net",
- "#disroot:aria-net.org"
+ "#gentoo:matrix.org"
],
- "servers": ["envs.net", "matrix.org", "monero.social", "mozilla.org"]
+ "servers": ["matrixrooms.info", "matrix.org", "mozilla.org", "unredacted.org"]
},
"hashRouter": {
diff --git a/contrib/caddy/caddyfile b/contrib/caddy/caddyfile
new file mode 100644
index 00000000..d23dcccb
--- /dev/null
+++ b/contrib/caddy/caddyfile
@@ -0,0 +1,6 @@
+# more info: https://caddyserver.com/docs/caddyfile/patterns#single-page-apps-spas
+cinny.domain.tld {
+ root * /path/to/cinny/dist
+ try_files {path} / index.html
+ file_server
+}
diff --git a/contrib/nginx/cinny.domain.tld.conf b/contrib/nginx/cinny.domain.tld.conf
index 0ba70f7e..02c7ead9 100644
--- a/contrib/nginx/cinny.domain.tld.conf
+++ b/contrib/nginx/cinny.domain.tld.conf
@@ -1,35 +1,34 @@
server {
- listen 80;
- listen [::]:80;
- server_name cinny.domain.tld;
+ listen 80;
+ listen [::]:80;
+ server_name cinny.domain.tld;
- location / {
- return 301 https://$host$request_uri;
- }
+ location / {
+ return 301 https://$host$request_uri;
+ }
- location /.well-known/acme-challenge/ {
- alias /var/lib/letsencrypt/.well-known/acme-challenge/;
- }
+ location /.well-known/acme-challenge/ {
+ alias /var/lib/letsencrypt/.well-known/acme-challenge/;
+ }
}
server {
- listen 443 ssl http2;
- listen [::]:443 ssl;
- server_name cinny.domain.tld;
+ listen 443 ssl http2;
+ listen [::]:443 ssl;
+ server_name cinny.domain.tld;
- location / {
- root /opt/cinny/dist/;
+ location / {
+ root /opt/cinny/dist/;
- rewrite ^/config.json$ /config.json break;
- rewrite ^/manifest.json$ /manifest.json break;
+ rewrite ^/config.json$ /config.json break;
+ rewrite ^/manifest.json$ /manifest.json break;
- rewrite ^.*/olm.wasm$ /olm.wasm break;
- rewrite ^/sw.js$ /sw.js break;
- rewrite ^/pdf.worker.min.js$ /pdf.worker.min.js break;
+ rewrite ^/sw.js$ /sw.js break;
+ rewrite ^/pdf.worker.min.js$ /pdf.worker.min.js break;
- rewrite ^/public/(.*)$ /public/$1 break;
- rewrite ^/assets/(.*)$ /assets/$1 break;
+ rewrite ^/public/(.*)$ /public/$1 break;
+ rewrite ^/assets/(.*)$ /assets/$1 break;
- rewrite ^(.+)$ /index.html break;
- }
+ rewrite ^(.+)$ /index.html break;
+ }
}
diff --git a/docker-nginx.conf b/docker-nginx.conf
index fca10746..a2dbeba0 100644
--- a/docker-nginx.conf
+++ b/docker-nginx.conf
@@ -8,7 +8,6 @@ server {
rewrite ^/config.json$ /config.json break;
rewrite ^/manifest.json$ /manifest.json break;
- rewrite ^.*/olm.wasm$ /olm.wasm break;
rewrite ^/sw.js$ /sw.js break;
rewrite ^/pdf.worker.min.js$ /pdf.worker.min.js break;
diff --git a/docs/gamecult/ratatoskr-target.md b/docs/gamecult/ratatoskr-target.md
new file mode 100644
index 00000000..166c5c72
--- /dev/null
+++ b/docs/gamecult/ratatoskr-target.md
@@ -0,0 +1,352 @@
+# Ratatoskr: target and prose map
+
+Ratatoskr is GameCult's Matrix chat client: this fork of Cinny, forge
+`GameCult/cinny` (to be renamed `GameCult/Ratatoskr`; see the cut sequence).
+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); waits on Void's forge
+ rename of `GameCult/cinny` to `GameCult/Ratatoskr`
+ (`eureka-body:ruling:chat-client-named-ratatoskr`), and the campaign's repo
+ list moves with it.
diff --git a/harness/estate/check.cjs b/harness/estate/check.cjs
new file mode 100644
index 00000000..656d532e
--- /dev/null
+++ b/harness/estate/check.cjs
@@ -0,0 +1,46 @@
+// Assertions for the estate drives: every drive states the outcome it exists to show and exits
+// non-zero when the page or the fake homeserver's log says otherwise.
+const fs = require('fs');
+const fails = [];
+exports.expect = (label, ok, detail = '') => {
+ console.log(`[ASSERT] ${ok ? 'PASS' : 'FAIL'} ${label}${ok ? '' : ` :: ${detail}`}`);
+ if (!ok) fails.push(label);
+};
+/** Exit code for the drive: 4 when any assertion failed. */
+exports.done = () => (fails.length ? 4 : 0);
+/** The fake homeserver's request log (HSLOG is shared with the server process). */
+exports.hsLog = () => fs.readFileSync(process.env.HSLOG, 'utf8').split('\n');
+/** Bodies of the account-data PUTs for m.direct, in order. */
+exports.mDirectPuts = () =>
+ exports.hsLog().filter((l) => / PUT .*m\.direct BODY /.test(l)).map((l) => l.split(' BODY ')[1]);
+/** Join requests the server saw for a room id. */
+exports.joinsOf = (roomId) => exports.hsLog().filter((l) => new RegExp(` JOIN ${roomId}$`).test(l)).length;
+const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages'];
+/** The tier header above a row, 'absent' when the page does not show the row, 'none-above' when no tier header precedes it. */
+const tierOf = (pg, name) =>
+ pg.evaluate((n) => {
+ const s = document.body.innerText.split('\n').map((x) => x.trim());
+ const i = s.indexOf(n);
+ if (i < 0) return 'absent';
+ for (let j = i; j >= 0; j--) if (/^(elsewhere|direct messages)$/i.test(s[j])) return s[j];
+ return 'none-above';
+ }, name);
+/**
+ * The tier a room is listed under, read from a fresh visit to the room list: a collapsed tier is
+ * opened only if the row is not already showing, so reading never closes what is open.
+ */
+exports.revealedTier = async (pg, name) => {
+ await pg.goto('http://127.0.0.1:8080/#/home/');
+ await pg.waitForTimeout(2500);
+ let t = await tierOf(pg, name);
+ for (const h of HEADERS) {
+ if (t !== 'absent') break;
+ const hl = pg.getByText(h, { exact: true });
+ if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); t = await tierOf(pg, name); }
+ }
+ return t;
+};
+/** True when the page text matches re within ms of now (a message can be replaced by a row change). */
+exports.sawText = (pg, re, ms) =>
+ pg.waitForFunction((src) => new RegExp(src, 'i').test(document.body.innerText), re.source, { timeout: ms, polling: 50 }).then(() => true, () => false);
+exports.isDm = (tier) => /^direct messages$/i.test(tier);
diff --git a/harness/estate/drive-failures.cjs b/harness/estate/drive-failures.cjs
new file mode 100644
index 00000000..d53d88a4
--- /dev/null
+++ b/harness/estate/drive-failures.cjs
@@ -0,0 +1,72 @@
+// Failure-mode scenarios for the estate join paths (estate tap and inbox Accept), run by run.sh
+// against fakehs.cjs with the env flag each mode needs.
+const { chromium } = require('playwright');
+const { expect, done, isDm, mDirectPuts, joinsOf, revealedTier, sawText } = require('./check.cjs');
+const MODE = process.env.MODE;
+const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`;
+const log = (...a) => console.log(`[${MODE}]`, ...a);
+const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages'];
+let b;
+// Close the browser, but never wait on it: a hung close must not hang the run.
+const quit = (code) => Promise.race([Promise.resolve(b && b.close()).catch(() => undefined), new Promise((r) => setTimeout(r, 5000))]).finally(() => process.exit(code));
+setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 240000);
+(async () => {
+ b = await chromium.launch();
+ const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 1 });
+ const pg = await ctx.newPage(); pg.on('console', (m) => { if (m.type() === 'error') console.log('[console]', m.type(), m.text().slice(0, 160)); });
+ const open = async (h = '#/home/') => { await pg.goto('http://127.0.0.1:8080/' + h); await pg.reload(); await pg.waitForTimeout(9000); };
+ await pg.goto('http://127.0.0.1:8080/');
+ await pg.evaluate(() => { localStorage.setItem('cinny_access_token', 'tok'); localStorage.setItem('cinny_device_id', 'DEV'); localStorage.setItem('cinny_user_id', '@t:test'); localStorage.setItem('cinny_hs_base_url', 'http://127.0.0.1:8080'); });
+ await open();
+ const body = async () => (await pg.evaluate(() => document.body.innerText)).replace(/\s+/g, ' ');
+ const reveal = async (name) => { const loc = pg.locator(`[aria-label="Join ${name}"]`); if (await loc.count()) return loc; for (let r = 0; r < 2; r++) for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); if (await loc.count()) return loc; } } return loc; };
+ const tierOf = (name) => revealedTier(pg, name);
+ const tap = async (n) => { const l = await reveal(n); log('JOINCTRL', n, await l.count()); await l.first().click(); log('CLICK', n, t()); };
+ const tiers = async (tag) => { const r = { friend: await tierOf('Friend DM'), slow: await tierOf('Slow DM') }; log('TIER', tag, '| Friend DM', r.friend, '| Slow DM', r.slow); return r; };
+ const neverUnfiled = () => mDirectPuts().every((b) => b.includes('!dm1:test'));
+ const acceptInInbox = async (name) => {
+ // reach the inbox through the navigator's own Inbox tab
+ await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2000);
+ const tab = pg.locator('[aria-label="Inbox"], [aria-label*="nbox"]'); log('INBOX_TAB', await tab.count());
+ if (await tab.count()) { await tab.first().click(); await pg.waitForTimeout(1500); }
+ const inv = pg.getByText('Invites', { exact: true }); if (await inv.count()) { await inv.first().click(); await pg.waitForTimeout(1500); }
+ log('URL', pg.url());
+ if (!/invites/.test(pg.url())) { await pg.goto('http://127.0.0.1:8080/#/inbox/invites/'); await pg.waitForTimeout(3000); log('URL_FALLBACK', pg.url()); }
+ { const pub = pg.getByText('Public', { exact: true }); log('PUBLIC_FILTER', await pub.count()); if (await pub.count()) { await pub.first().click(); await pg.waitForTimeout(1500); } }
+ for (let r = 0; r < 3; r++) { const v = pg.getByText('View All', { exact: true }); if (!(await v.count())) break; await v.first().click(); await pg.waitForTimeout(800); }
+ const b0 = await body(); const i = b0.indexOf(name); log('INBOX has', name, i >= 0, JSON.stringify(b0.slice(Math.max(0, i - 120), i + 120)));
+ const card = pg.locator('div').filter({ hasText: name }).filter({ has: pg.getByText('Accept', { exact: true }) }).last();
+ await card.getByText('Accept', { exact: true }).first().click(); log('ACCEPT', name, t());
+ };
+ if (MODE === 'pjfail') {
+ await tap('Friend DM'); await pg.waitForTimeout(3000);
+ const bb = await body(); const k = bb.search(/Internal server error/); log('AFTER_FAIL', k >= 0 ? bb.slice(Math.max(0, k - 40), k + 80) : 'no message');
+ expect('a failed m.direct write shows the refusal', k >= 0, bb.slice(0, 200));
+ expect('no join is sent while the filing is failing', joinsOf('!dm1:test') === 0, String(joinsOf('!dm1:test')));
+ await tap('Friend DM'); await pg.waitForTimeout(6000); const r = await tiers('after retry');
+ expect('the retry joins once', joinsOf('!dm1:test') === 1, String(joinsOf('!dm1:test')));
+ expect('Friend DM is filed under Direct Messages after the retry', isDm(r.friend), r.friend);
+ } else if (MODE === 'jdrop') {
+ await tap('Friend DM'); const saw = await sawText(pg, /tap again to retry/, 3000); log('AFTER_DROP', saw);
+ expect('a dropped join shows the retryable message', saw, (await body()).slice(0, 200));
+ await pg.waitForTimeout(6000); const r1 = await tiers('after drop'); await open(); const r2 = await tiers('after reload');
+ expect('the entry is kept after the drop', isDm(r1.friend) && neverUnfiled(), r1.friend);
+ expect('Friend DM is under Direct Messages after the reload', isDm(r2.friend), r2.friend);
+ } else if (MODE === 'admin') {
+ await tap('Friend DM'); await pg.waitForTimeout(6000); const r = await tiers('admin estate');
+ expect('an admin-invited DM is filed under Direct Messages', isDm(r.friend), r.friend);
+ } else if (MODE === 'inboxadmin') {
+ await acceptInInbox('Friend DM'); await pg.waitForTimeout(6000); const r = await tiers('admin inbox');
+ expect('an admin-invited DM accepted in the inbox is filed under Direct Messages', isDm(r.friend), r.friend);
+ } else if (MODE === 'inboxreload') {
+ await acceptInInbox('Slow DM'); await pg.waitForTimeout(2000); log('RELOAD', t()); await open('#/inbox/invites/');
+ await pg.waitForTimeout(30000); const r = await tiers('inbox reload');
+ expect('a DM accepted in the inbox is filed under Direct Messages after a reload', isDm(r.slow), r.slow);
+ } else if (MODE === 'pjreload') {
+ await tap('Slow DM'); await pg.waitForTimeout(2000); log('RELOAD', t()); await open();
+ await pg.waitForTimeout(8000); await tiers('pjreload');
+ const n = await (await reveal('Slow DM')).count(); log('SLOW_DM_STILL_JOINABLE', n);
+ expect('a join cut off by a reload stays joinable', n === 1, String(n));
+ }
+ await quit(done());
+})().catch((e) => { log('ERROR', String(e.stack || e).slice(0, 300)); quit(1); });
diff --git a/harness/estate/drive-routes.cjs b/harness/estate/drive-routes.cjs
new file mode 100644
index 00000000..75c9a8df
--- /dev/null
+++ b/harness/estate/drive-routes.cjs
@@ -0,0 +1,42 @@
+const { chromium } = require('playwright');
+const { expect, done, isDm, mDirectPuts, hsLog, revealedTier, sawText } = require('./check.cjs');
+const MODE = process.env.MODE; const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`;
+const log = (...a) => console.log(`[${MODE}]`, ...a);
+const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages'];
+let b; const quit = (c) => Promise.race([Promise.resolve(b && b.close()).catch(() => 0), new Promise((r) => setTimeout(r, 5000))]).finally(() => process.exit(c));
+setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 120000);
+(async () => {
+ b = await chromium.launch();
+ const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
+ const pg = await ctx.newPage();
+ await pg.goto('http://127.0.0.1:8080/');
+ await pg.evaluate(() => { localStorage.setItem('cinny_access_token', 'tok'); localStorage.setItem('cinny_device_id', 'DEV'); localStorage.setItem('cinny_user_id', '@t:test'); localStorage.setItem('cinny_hs_base_url', 'http://127.0.0.1:8080'); });
+ await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.reload(); await pg.waitForTimeout(9000);
+ const body = async () => (await pg.evaluate(() => document.body.innerText)).replace(/\s+/g, ' ');
+ const tiers = async (tag) => { const f = await revealedTier(pg, 'Friend DM'); log('TIER', tag, '| Friend DM', f); return f; };
+ let joined = false;
+ if (MODE === 'deeplink') {
+ for (const route of ['direct', 'home']) {
+ await pg.goto(`http://127.0.0.1:8080/#/${route}/${encodeURIComponent('!dm1:test')}/`); await pg.waitForTimeout(4000);
+ const bb = await body(); log('ROUTE', route, JSON.stringify(bb.slice(0, 300)));
+ const j = pg.getByRole('button', { name: /^Join$/ }); log('JOIN_BUTTONS', route, await j.count());
+ if (await j.count()) { joined = true; await j.first().click(); log('CLICK_JOIN', route, t()); await pg.waitForTimeout(6000); break; }
+ }
+ const f = await tiers('after deeplink join');
+ const stamps = (re) => hsLog().filter((l) => re.test(l)).map((l) => Number(l.split(' ')[0]));
+ const put = stamps(/ PUT .*m\.direct BODY .*!dm1:test/)[0], join = stamps(/ JOIN !dm1:test$/)[0];
+ expect('the deep link offers one Join button', joined, 'no Join button');
+ expect('the DM is filed before the join is sent', put !== undefined && join !== undefined && put <= join, `put ${put} join ${join}`);
+ expect('the DM is filed under Direct Messages', isDm(f), f);
+ } else if (MODE === 'gw504') {
+ const HL = HEADERS; let l = pg.locator('[aria-label="Join Friend DM"]');
+ for (let r = 0; r < 2 && !(await l.count()); r++) for (const h of HL) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } }
+ log('JOINCTRL', await l.count()); await l.first().click(); log('CLICK', t());
+ const saw = await sawText(pg, /tap again to retry/, 3000); log('AFTER_504', saw);
+ expect('a gateway timeout shows the retryable message', saw, (await body()).slice(0, 200));
+ await pg.waitForTimeout(4000); const f = await tiers('after 504 (join delivered by sync)');
+ expect('the DM stays filed after a gateway timeout', mDirectPuts().every((b) => b.includes('!dm1:test')) && mDirectPuts().length > 0, JSON.stringify(mDirectPuts()));
+ expect('the DM is filed under Direct Messages once sync delivers it', isDm(f), f);
+ }
+ await quit(done());
+})().catch((e) => { log('ERROR', String(e.stack || e).slice(0, 300)); quit(1); });
diff --git a/harness/estate/drive.cjs b/harness/estate/drive.cjs
new file mode 100644
index 00000000..03bd2ad0
--- /dev/null
+++ b/harness/estate/drive.cjs
@@ -0,0 +1,48 @@
+const { chromium } = require('playwright');
+const { expect, done, isDm, revealedTier } = require('./check.cjs');
+const MODE = process.env.MODE; // twodm | reload
+const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`;
+const log = (...a) => console.log(`[${MODE}]`, ...a);
+const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages'];
+let tierBefore;
+(async () => {
+ const b = await chromium.launch();
+ const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 1 });
+ const pg = await ctx.newPage(); pg.on('console', (m) => { if (m.type() === 'error' || /command|direct/i.test(m.text())) console.log('[console]', m.type(), m.text().slice(0, 200)); });
+ const open = async () => { await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.reload(); await pg.waitForTimeout(9000); };
+ await pg.goto('http://127.0.0.1:8080/');
+ await pg.evaluate(() => { localStorage.setItem('cinny_access_token', 'tok'); localStorage.setItem('cinny_device_id', 'DEV'); localStorage.setItem('cinny_user_id', '@t:test'); localStorage.setItem('cinny_hs_base_url', 'http://127.0.0.1:8080'); });
+ await open();
+ const text = () => pg.evaluate(() => document.body.innerText);
+ const reveal = async (name) => { const loc = pg.locator(`[aria-label="Join ${name}"]`); if (await loc.count()) return loc; for (let r = 0; r < 2; r++) for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); if (await loc.count()) return loc; } } return loc; };
+ const tierOf = (name) => revealedTier(pg, name);
+ const tap = async (n) => { const l = await reveal(n); log('JOINCTRL', n, await l.count()); await l.first().click(); log('CLICK', n, t()); };
+ if (MODE === 'twodm') {
+ await tap('Slow DM'); await pg.waitForTimeout(300); await tap('Late DM');
+ await pg.waitForTimeout(45000);
+ } else if (MODE === 'convert') {
+ await tap('Friend DM'); await pg.waitForTimeout(6000);
+ tierBefore = await tierOf('Friend DM'); log('TIER before', tierBefore);
+ await pg.goto('http://127.0.0.1:8080/#/direct/!dm1:test/'); await pg.waitForTimeout(4000);
+ const ed = pg.locator('[contenteditable="true"]'); log('EDITORS', await ed.count(), pg.url());
+ await ed.first().click(); await pg.keyboard.type('/converttoroom', { delay: 30 }); await pg.waitForTimeout(500);
+ log('ED_TEXT', JSON.stringify(await ed.first().innerText())); log('BODY', JSON.stringify((await text()).slice(-600)));
+ await pg.getByText('Convert direct message to room').first().click(); await pg.waitForTimeout(400); log('ED_PICKED', JSON.stringify(await ed.first().innerHTML()).slice(0, 300));
+ await pg.keyboard.press('Enter'); log('SENT', t()); await pg.waitForTimeout(800); log('ED_TEXT2', JSON.stringify(await ed.first().innerText().catch(()=>'')));
+ if (await ed.first().innerText().catch(() => '')) { await pg.keyboard.press('Enter'); log('SENT2', t()); }
+ await pg.waitForTimeout(8000);
+ await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(3000);
+ } else {
+ await tap('Slow DM'); await pg.waitForTimeout(3000);
+ log('RELOAD', t()); await open();
+ await pg.waitForTimeout(30000);
+ }
+ const slow = await tierOf('Slow DM'), late = await tierOf('Late DM'), friend = await tierOf('Friend DM');
+ log('TIER Slow DM', slow, '| Late DM', late, '| Friend DM', friend);
+ if (MODE === 'twodm') { expect('Slow DM is filed under Direct Messages', isDm(slow), slow); expect('Late DM is filed under Direct Messages', isDm(late), late); }
+ else if (MODE === 'convert') {
+ expect('Friend DM was filed under Direct Messages before the conversion', isDm(tierBefore), tierBefore);
+ expect('the converted room left Direct Messages and is still listed', !isDm(friend) && friend !== 'absent', friend);
+ } else expect('Slow DM is filed under Direct Messages after the reload', isDm(slow), slow);
+ await b.close(); process.exitCode = done();
+})().catch((e) => { log('ERROR', String(e.stack || e)); process.exitCode = 1; });
diff --git a/harness/estate/fakehs.cjs b/harness/estate/fakehs.cjs
new file mode 100644
index 00000000..4a6875e0
--- /dev/null
+++ b/harness/estate/fakehs.cjs
@@ -0,0 +1,127 @@
+// Soul s2 fake homeserver. Invitee with no root state; stewards only in m.space.child content.
+// Rooms: GEN joined; OP invited; 6 project rooms invited (odin slow: join arrives at +24 s);
+// PUB public joinable, not in store; STRAY joined outside hierarchy; DM1 invited direct (fast);
+// DM2 invited direct (slow, +24 s). NOROOT=1: the root alias does not resolve.
+const http = require('http'), fs = require('fs'), path = require('path');
+const DIST = process.argv[2], PORT = 8080, ME = '@t:test';
+const NOROOT = process.env.NOROOT === '1';
+const log = [];
+const P = ['aetheria', 'ghostlight', 'huginn', 'idunn', 'bifrost', 'odin'];
+const STEWARD = { aetheria: 'nibu', ghostlight: 'nibu', huginn: 'eureka', idunn: 'eureka' };
+const PROJ = P.map((p, i) => ({ id: `!p${i}:test`, alias: `#${p}:test`, name: p[0].toUpperCase() + p.slice(1), key: p }));
+const ROOT = '!root:test', PSP = '!proj:test', GEN = '!gen:test', OP = '!op:test', PUB = '!pub:test', STRAY = '!stray:test', DM1 = '!dm1:test', DM2 = '!dm2:test', DM3 = '!dm3:test';
+const SLOW = new Set([PROJ[5].id, DM2, DM3]);
+const NAMES = { [PUB]: 'Commons', [DM1]: 'Friend DM', [DM2]: 'Slow DM', [DM3]: 'Late DM', [STRAY]: 'Adhoc' };
+PROJ.forEach((r) => (NAMES[r.id] = r.name));
+const held = []; const pendingState = []; const pending = []; const adLog = []; let ev = 0;
+const se = (type, content, sk = '', sender = '@admin:test') => ({ type, content, state_key: sk, sender, event_id: `$e${++ev}`, origin_server_ts: Date.now() });
+const strip = (id, name, space, direct) => [
+ { type: 'm.room.create', state_key: '', sender: direct ? '@friend:test' : '@admin:test', content: space ? { type: 'm.space', room_version: '12' } : { room_version: '12' } },
+ { type: 'm.room.name', state_key: '', sender: '@admin:test', content: { name } },
+ { type: 'm.room.join_rules', state_key: '', sender: '@admin:test', content: { join_rule: 'invite' } },
+ ...(direct ? [{ type: 'm.room.member', state_key: '@friend:test', sender: '@friend:test', content: { membership: 'join', displayname: 'Friend' } }] : []),
+ { type: 'm.room.member', state_key: ME, sender: direct ? (process.env.INVITER_ADMIN === '1' && id === DM1 ? '@admin:test' : '@friend:test') : '@admin:test', content: direct ? { membership: 'invite', is_direct: true } : { membership: 'invite' } },
+];
+const joinedRoom = (id, name, extra = [], space = false) => ({
+ state: { events: [se('m.room.create', space ? { type: 'm.space', room_version: '12' } : { room_version: '12' }), se('m.room.name', { name }), se('m.room.join_rules', { join_rule: 'invite' }), se('m.room.member', { membership: 'join' }, ME, ME), ...extra] },
+ timeline: { events: [{ type: 'm.room.message', sender: '@admin:test', content: { msgtype: 'm.text', body: `welcome to ${name}` }, event_id: `$m${++ev}`, origin_server_ts: Date.now() }], limited: false },
+ ephemeral: { events: [] }, account_data: { events: [] }, unread_notifications: {}, summary: {},
+});
+const invites = () => {
+ const o = {};
+ o[ROOT] = { invite_state: { events: strip(ROOT, 'GameCult', true) } };
+ o[PSP] = { invite_state: { events: strip(PSP, 'Projects', true) } };
+ o[OP] = { invite_state: { events: strip(OP, 'Operator') } };
+ PROJ.forEach((r) => (o[r.id] = { invite_state: { events: strip(r.id, r.name) } }));
+ o[DM1] = { invite_state: { events: strip(DM1, NAMES[DM1], false, true) } };
+ o[DM2] = { invite_state: { events: strip(DM2, NAMES[DM2], false, true) } };
+ o[DM3] = { invite_state: { events: strip(DM3, NAMES[DM3], false, true) } };
+ return o;
+};
+const child = (id, steward) => ({ type: 'm.space.child', state_key: id, content: { via: ['test'], ...(steward ? { 'org.gamecult.steward': { mind: steward, repo: 'x', source: 'y', steward: 'z' } } : {}) }, sender: '@admin:test', origin_server_ts: 1 });
+const hroom = (id, name, alias, extra = {}) => ({ room_id: id, name, canonical_alias: alias, num_joined_members: 1, world_readable: false, guest_can_join: false, join_rule: 'invite', children_state: [], ...extra });
+const hierarchy = () => [
+ hroom(ROOT, 'GameCult', '#gamecult:test', { room_type: 'm.space', children_state: [child(GEN), child(OP), child(PUB), child(PSP)] }),
+ hroom(GEN, 'General', '#general:test'),
+ hroom(OP, 'Operator', '#operator:test'),
+ hroom(PUB, 'Commons', '#commons:test', { join_rule: 'public' }),
+ hroom(PSP, 'Projects', '#projects:test', { room_type: 'm.space', children_state: PROJ.map((r) => child(r.id, STEWARD[r.key])) }),
+ ...PROJ.map((r) => hroom(r.id, r.name, r.alias)),
+];
+let syncN = 1;
+const json = (res, code, body) => { res.writeHead(code, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': '*', 'Access-Control-Allow-Methods': '*' }); res.end(JSON.stringify(body)); };
+const mime = { '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css', '.html': 'text/html', '.json': 'application/json', '.wasm': 'application/wasm', '.svg': 'image/svg+xml', '.png': 'image/png', '.woff2': 'font/woff2' };
+const flush = () => fs.writeFileSync(process.env.HSLOG, log.join('\n'));
+http.createServer((req, res) => {
+ const u = new URL(req.url, 'http://x'); const p = decodeURIComponent(u.pathname);
+ if (req.method === 'OPTIONS') return json(res, 200, {});
+ if (!p.startsWith('/_matrix') && !p.startsWith('/.well-known')) {
+ let f = path.join(DIST, p === '/' ? 'index.html' : p);
+ if (!fs.existsSync(f) || fs.statSync(f).isDirectory()) f = path.join(DIST, 'index.html');
+ res.writeHead(200, { 'Content-Type': mime[path.extname(f)] || 'application/octet-stream' });
+ return fs.createReadStream(f).pipe(res);
+ }
+ let body = ''; req.on('data', (c) => (body += c)); req.on('end', () => {
+ if (req.method === 'PUT' && p.includes('m.direct')) console.log('MDIRECT_PUT ' + (Date.now()) + ' ' + body);
+ log.push(`${Date.now()} ${req.method} ${p}${req.method === 'PUT' && p.includes('account_data') ? ' BODY ' + body : ''}`);
+ if (p === '/_matrix/client/versions') return json(res, 200, { versions: ['v1.1', 'v1.5', 'v1.11'], unstable_features: {} });
+ if (p.endsWith('/sync')) {
+ const since = u.searchParams.get('since');
+ if (!since) {
+ const join = {}; join[GEN] = joinedRoom(GEN, 'General'); join[STRAY] = joinedRoom(STRAY, 'Adhoc');
+ return json(res, 200, { next_batch: `s${syncN}`, rooms: { join, invite: invites(), leave: {} }, account_data: { events: held.slice() }, presence: { events: [] }, to_device: { events: [] }, device_one_time_keys_count: { signed_curve25519: 50 } });
+ }
+ const respond = () => {
+ const now = Date.now(); const join = {};
+ for (let i = pending.length - 1; i >= 0; i--) if (pending[i].at <= now) {
+ const id = pending[i].id; pending.splice(i, 1);
+ const extra = id === DM1 || id === DM2 || id === DM3 ? [se('m.room.member', { membership: 'join', displayname: 'Friend' }, '@friend:test', '@friend:test')] : [];
+ join[id] = joinedRoom(id, NAMES[id] || id, extra); if (extra.length) { const me = join[id].state.events.find((e) => e.type === 'm.room.member' && e.state_key === ME); me.unsigned = { prev_content: { membership: 'invite', is_direct: true } }; } log.push(`${now} SYNC delivers join ${id}`); console.log('DELIVER ' + now + ' ' + id);
+ }
+ for (const s of pendingState.splice(0)) { join[s.id] = join[s.id] || { state: { events: [] }, timeline: { events: [], limited: false, prev_batch: 'p' }, ephemeral: { events: [] }, account_data: { events: [] }, unread_notifications: {}, summary: {} }; join[s.id].timeline.events.push(s.ev); console.log('STATE_ECHO ' + now + ' ' + s.id + ' ' + JSON.stringify(s.ev.content)); }
+ const k = Number(String(since).replace(/^s/, '')) || 0; const ad = adLog.filter((a) => a.after >= k).map((a) => ({ type: a.type, content: a.content })); // a server answers with what changed since the client's token, whichever client asks
+ if (Object.keys(join).length || ad.length) return json(res, 200, { next_batch: `s${++syncN}`, rooms: { join, invite: {}, leave: {} }, account_data: { events: ad } });
+ return json(res, 200, { next_batch: since, rooms: {} });
+ };
+ return setTimeout(respond, 300);
+ }
+ const jm = p.match(/\/rooms\/([^/]+)\/join$/) || p.match(/\/join\/([^/]+)$/);
+ if (req.method === 'POST' && jm) {
+ const id = jm[1]; log.push(`${Date.now()} JOIN ${id}`);
+ if (id === PROJ[1].id) return json(res, 403, { errcode: 'M_FORBIDDEN', error: 'You are not invited to this room.' });
+ const prior = pending.find((q) => q.id === DM2 || q.id === DM3);
+ pending.push({ id, at: (id === DM3 || id === DM2) && prior ? prior.at : Date.now() + (SLOW.has(id) ? 24000 : 1000) });
+ if (process.env.JOIN_504 === '1' && id === DM1) { console.log('JOIN_504 ' + Date.now() + ' ' + id); res.writeHead(504, {'Content-Type':'text/html'}); return res.end('504 Gateway Time-out'); }
+ if (process.env.JOIN_DROP === '1' && id === DM1) { console.log('JOIN_DROPPED ' + Date.now() + ' ' + id); return req.socket.destroy(); }
+ if (process.env.JOIN_DELAY === '1') { console.log('JOIN_DELAYED ' + Date.now() + ' ' + id); return setTimeout(() => json(res, 200, { room_id: id }), 6000); }
+ return json(res, 200, { room_id: id });
+ }
+ const sm = p.match(/\/rooms\/([^/]+)\/state\/m\.room\.member\/(.+)$/);
+ if (req.method === 'PUT' && sm) { console.log('STATE_PUT ' + Date.now() + ' ' + sm[1] + ' ' + body); const e = se('m.room.member', JSON.parse(body), sm[2], sm[2]); e.unsigned = { prev_content: { membership: 'join' } }; setTimeout(() => pendingState.push({ id: sm[1], ev: e }), 400); return json(res, 200, { event_id: e.event_id }); }
+ if (p.includes('/account_data/') && req.method === 'PUT' && p.includes('m.direct') && process.env.FAIL_MDIRECT === '1' && !global.failedOnce) { global.failedOnce = 1; console.log('MDIRECT_FAIL ' + Date.now()); return json(res, 500, { errcode: 'M_UNKNOWN', error: 'Internal server error' }); }
+ if (p.includes('/account_data/') && req.method === 'PUT') {
+ const DELAY = p.includes('m.direct') && process.env.DELAY_MDIRECT === '1';
+ const type = decodeURIComponent(p.split('/account_data/')[1]);
+ try { const c = JSON.parse(body); adLog.push({ type, content: c, after: syncN }); const i = held.findIndex((h) => h.type === type); if (i >= 0) held.splice(i, 1); held.push({ type, content: c }); } catch (e) {}
+ if (DELAY) { console.log('MDIRECT_DELAYED ' + Date.now()); return setTimeout(() => json(res, 200, {}), 6000); }
+ return json(res, 200, {});
+ }
+ if (p.includes('/hierarchy')) return json(res, 200, { rooms: hierarchy() });
+ if (p.includes('/directory/room/')) return NOROOT ? json(res, 404, { errcode: 'M_NOT_FOUND', error: 'Room alias not found' }) : json(res, 200, { room_id: ROOT, servers: ['test'] });
+ if (p.endsWith('/keys/upload')) return json(res, 200, { one_time_key_counts: { signed_curve25519: 50 } });
+ if (p.endsWith('/keys/query')) return json(res, 200, { device_keys: {}, failures: {} });
+ if (p.includes('/room_keys/version')) return json(res, 404, { errcode: 'M_NOT_FOUND', error: 'none' });
+ if (p.includes('/pushrules')) return json(res, 200, { global: { override: [], underride: [], room: [], sender: [], content: [] } });
+ if (p.includes('/filter')) return json(res, 200, { filter_id: '1' });
+ if (p.includes('/account_data/')) return json(res, 404, { errcode: 'M_NOT_FOUND', error: 'none' });
+ if (p.includes('/messages')) return json(res, 200, { chunk: [], start: 't0', end: 't0' });
+ if (p.includes('/members')) return json(res, 200, { chunk: [] });
+ if (p.includes('/profile/')) return json(res, 200, { displayname: 'Tess' });
+ if (p.includes('/capabilities')) return json(res, 200, { capabilities: {} });
+ if (p.includes('/devices')) return json(res, 200, { devices: [] });
+ if (p.includes('summary')) return json(res, 200, { room_id: p.split('/').pop(), name: 'Summary card', num_joined_members: 1, membership: 'invite', join_rule: 'invite' });
+ if (p.includes('/login')) return json(res, 200, { flows: [{ type: 'm.login.password' }] });
+ return json(res, 200, {});
+ });
+}).listen(PORT, () => console.log('fakehs up'));
+setInterval(flush, 300);
diff --git a/harness/estate/run.sh b/harness/estate/run.sh
new file mode 100644
index 00000000..65242ab0
--- /dev/null
+++ b/harness/estate/run.sh
@@ -0,0 +1,35 @@
+#!/usr/bin/env bash
+# Runs inside mcr.microsoft.com/playwright:v1.48.2-jammy with the repo at /src:
+# builds the client, then drives the direct-message and join-failure scenarios against fakehs.cjs.
+set -e
+cd /src
+npm ci --no-audit --no-fund --ignore-scripts >/tmp/npmci.log 2>&1 || { tail -20 /tmp/npmci.log; exit 1; }
+npm run build > /tmp/build.log 2>&1 || { tail -30 /tmp/build.log; exit 1; }
+echo BUILD_OK $(git rev-parse HEAD)
+printf '{"defaultHomeserver":0,"homeserverList":["127.0.0.1:8080"],"allowCustomHomeservers":true,"featuredCommunities":{"spaces":["#gamecult:test"],"rooms":[],"servers":["test"]},"hashRouter":{"enabled":true,"basename":"/"}}' > dist/config.json
+mkdir -p /tmp/pw && cd /tmp/pw && npm init -y >/dev/null && npm i --no-audit --no-fund playwright@1.48.2 >/tmp/pw.log 2>&1
+cp /src/harness/estate/check.cjs /src/harness/estate/drive.cjs /src/harness/estate/drive-failures.cjs /src/harness/estate/drive-routes.cjs /tmp/pw/
+set +e
+for m in twodm reload convert; do
+ HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 &
+ HS=$!; sleep 2
+ HSLOG=/tmp/hslog-$m.txt MODE=$m node /tmp/pw/drive.cjs; rc=$?; echo "DRIVE $m RC=$rc"; [ $rc -eq 0 ] || FAILED="$FAILED $m"
+ kill $HS; wait $HS 2>/dev/null; sleep 1
+ echo "=== hs $m"; grep -E "MDIRECT_PUT|DELIVER|STATE_" /tmp/hs-$m.out; grep -E " JOIN " /tmp/hslog-$m.txt
+done
+failure() { m=$1; shift
+ env "$@" HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 &
+ HS=$!; sleep 2
+ HSLOG=/tmp/hslog-$m.txt MODE=$m node /tmp/pw/${DRIVER:-drive-failures}.cjs; rc=$?; echo "DRIVE $m RC=$rc"; [ $rc -eq 0 ] || FAILED="$FAILED $m"
+ kill $HS; wait $HS 2>/dev/null; sleep 1
+ echo "=== hs $m"; grep -E "MDIRECT|DELIVER|JOIN_|STATE_" /tmp/hs-$m.out; grep -E " JOIN " /tmp/hslog-$m.txt; }
+failure pjfail FAIL_MDIRECT=1
+failure pjreload DELAY_MDIRECT=1
+failure jdrop JOIN_DROP=1
+failure admin INVITER_ADMIN=1
+failure inboxadmin INVITER_ADMIN=1
+failure inboxreload JOIN_DELAY=1
+DRIVER=drive-routes failure deeplink
+DRIVER=drive-routes failure gw504 JOIN_504=1
+echo "FAILED_DRIVES:${FAILED:- none}"
+[ -z "$FAILED" ]
diff --git a/index.html b/index.html
index 9196cf3d..2eefeee2 100644
--- a/index.html
+++ b/index.html
@@ -90,6 +90,7 @@
window.global ||= window;
+