From c21eaaa07d0b41da04ff0ef73f168757cfecfbee Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 21 Mar 2026 17:39:43 -0400 Subject: [PATCH 01/69] Change GitHub owner from 'SableClient' to 'Just-Insane' --- knope.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/knope.toml b/knope.toml index fc533824c..9be54d79d 100644 --- a/knope.toml +++ b/knope.toml @@ -62,7 +62,7 @@ help_text = "Create a new change file to be included in the next release" type = "CreateChangeFile" [github] -owner = "SableClient" +owner = "Just-Insane" repo = "Sable" [release_notes] From e7907483e9571e196de8fe7e8d6752b76df6a0d2 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 21 Mar 2026 17:40:40 -0400 Subject: [PATCH 02/69] Change default custom domain for Worker --- infra/web/variables.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infra/web/variables.tf b/infra/web/variables.tf index 3569c9822..7d3a50ece 100644 --- a/infra/web/variables.tf +++ b/infra/web/variables.tf @@ -7,7 +7,7 @@ variable "account_id" { variable "custom_domain" { description = "Custom domain attached to the Worker" type = string - default = "app.sable.moe" + default = "app.cloudhub.social" } variable "worker_name" { From 3bbf495bb8d3b842bdc16c7c51ef58569bc9b57a Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 21 Mar 2026 17:45:52 -0400 Subject: [PATCH 03/69] Change default custom domain in variables.tf --- infra/web/variables.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infra/web/variables.tf b/infra/web/variables.tf index 7d3a50ece..96a6be2c5 100644 --- a/infra/web/variables.tf +++ b/infra/web/variables.tf @@ -7,7 +7,7 @@ variable "account_id" { variable "custom_domain" { description = "Custom domain attached to the Worker" type = string - default = "app.cloudhub.social" + default = "sable.cloudhub.social" } variable "worker_name" { From 3e6555b7d211d89f897f20543d005863ac951174 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 21 Mar 2026 18:20:43 -0400 Subject: [PATCH 04/69] Change default custom domain to dev.cloudhub.social --- infra/web/variables.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infra/web/variables.tf b/infra/web/variables.tf index 96a6be2c5..8ddd72ae4 100644 --- a/infra/web/variables.tf +++ b/infra/web/variables.tf @@ -7,7 +7,7 @@ variable "account_id" { variable "custom_domain" { description = "Custom domain attached to the Worker" type = string - default = "sable.cloudhub.social" + default = "dev.cloudhub.social" } variable "worker_name" { From 74a25d29923e4fe1ddb108e6c2a565c61ae1fc57 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 21 Mar 2026 18:30:52 -0400 Subject: [PATCH 05/69] Refactor config.json for new homeserver settings Updated configuration for homeserver and push notifications. --- config.json | 65 +++++++++++++++++++---------------------------------- 1 file changed, 23 insertions(+), 42 deletions(-) diff --git a/config.json b/config.json index 1bdffb675..90daf2190 100644 --- a/config.json +++ b/config.json @@ -1,45 +1,26 @@ { - "defaultHomeserver": 0, - "homeserverList": ["matrix.org", "mozilla.org", "unredacted.org", "sable.moe", "kendama.moe"], - "allowCustomHomeservers": true, - "elementCallUrl": null, - - "disableAccountSwitcher": false, - "hideUsernamePasswordFields": false, - - "pushNotificationDetails": { - "pushNotifyUrl": "https://sygnal.sable.moe/_matrix/push/v1/notify", - "vapidPublicKey": "BCnS4SbHjeOaqVFW4wjt5xDt_pYIL62qMzKePfYF9fl9PQU14RieIaObh7nLR_9dQf4sykZa-CTrcjkgMIE1mcg", - "webPushAppID": "moe.sable.app.sygnal" - }, - - "slidingSync": { - "enabled": true - }, - - "featuredCommunities": { - "openAsDefault": false, - "spaces": [ - "#sable:sable.moe", - "#community:matrix.org", - "#space:unredacted.org", - "#science-space:matrix.org", - "#libregaming-games:tchncs.de", - "#mathematics-on:matrix.org" + "allowCustomHomeservers": true, + "defaultHomeserver": 0, + "elementCallUrl": "matrix.cloudhub.social", + "featuredCommunities": { + "openAsDefault": false, + "rooms": [], + "servers": [ + "matrixrooms.info", + "gitter.im", + "matrix.org" + ], + "spaces": [] + }, + "homeserverList": [ + "https://matrix.cloudhub.social" ], - "rooms": [ - "#announcements:sable.moe", - "#freesoftware:matrix.org", - "#pcapdroid:matrix.org", - "#gentoo:matrix.org", - "#PrivSec.dev:arcticfoxes.net", - "#disroot:aria-net.org" - ], - "servers": ["matrixrooms.info", "mozilla.org", "unredacted.org"] - }, - - "hashRouter": { - "enabled": false, - "basename": "/" - } + "pushNotificationDetails": { + "pushNotifyUrl": "https://sygnal.cloudhub.social/_matrix/push/v1/notify", + "vapidPublicKey": "BEBdK6VUiqYxcOauFCM1ZB38llgiODAs6pR5EEcC7YBoUh2YvrULagwo5t-Ms0Is0lEmKDhpdUoMiy_i7ArI3oE", + "webPushAppID": "social.cloudhub.sable.web" + }, + "slidingSync": { + "enabled": "true" + } } From a0b6d710324f961f8143d293050df30689969274 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 24 Mar 2026 20:20:08 -0400 Subject: [PATCH 06/69] chore: ignore .vscode/launch.json --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 7ec719709..ab23d31ea 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ build.sh # the following line was added with nvim by Shea because its annoying to clear every so often .vscode/bookmarks.json +.vscode/launch.json From fa4c7a31167371c6a8a19a116e97db080eff20a6 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 24 Mar 2026 22:33:41 -0400 Subject: [PATCH 07/69] ci: build latest Docker image from integration branch too --- .github/workflows/docker-publish.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 03a63ef99..426f1d826 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -2,7 +2,7 @@ name: Build and publish Docker image on: push: - branches: [dev] + branches: [dev, integration] tags: - 'v*' pull_request: @@ -70,9 +70,9 @@ jobs: flavor: | latest=false tags: | - # dev branch or manual dispatch without a tag: short commit SHA + latest - type=sha,prefix=,format=short,enable=${{ github.ref == 'refs/heads/dev' || (github.event_name == 'workflow_dispatch' && inputs.git_tag == '') }} - type=raw,value=latest,enable=${{ github.ref == 'refs/heads/dev' || (github.event_name == 'workflow_dispatch' && inputs.git_tag == '') }} + # dev/integration branch or manual dispatch without a tag: short commit SHA + latest + type=sha,prefix=,format=short,enable=${{ github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/integration' || (github.event_name == 'workflow_dispatch' && inputs.git_tag == '') }} + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/integration' || (github.event_name == 'workflow_dispatch' && inputs.git_tag == '') }} # git tags (push or manual dispatch with a tag): semver breakdown type=semver,pattern={{version}},value=${{ steps.release_tag.outputs.value }},enable=${{ steps.release_tag.outputs.is_release == 'true' }} From 550b780180ba0ef91f6e9e4bcf05721f6fd538f4 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 25 Mar 2026 00:08:01 -0400 Subject: [PATCH 08/69] ci: add Sentry env vars to Docker image build step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass VITE_SENTRY_DSN, VITE_SENTRY_ENVIRONMENT, VITE_APP_VERSION, SENTRY_AUTH_TOKEN, SENTRY_ORG, and SENTRY_PROJECT to the build step so that the Docker image build (dev, integration, and release tags) includes Sentry instrumentation and source map uploads, matching the Cloudflare deploy workflow. Environment mapping: - dev branch / release tags → production - integration branch / manual dispatch without tag → preview --- .github/workflows/docker-publish.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 426f1d826..64c78f755 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -90,6 +90,12 @@ jobs: env: VITE_BUILD_HASH: ${{ steps.vars.outputs.short_sha }} VITE_IS_RELEASE_TAG: ${{ steps.release_tag.outputs.is_release }} + VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }} + VITE_SENTRY_ENVIRONMENT: ${{ (steps.release_tag.outputs.is_release == 'true' || github.ref == 'refs/heads/dev') && 'production' || 'preview' }} + VITE_APP_VERSION: ${{ github.ref_name }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} run: | NODE_OPTIONS=--max_old_space_size=4096 pnpm run build From 2bbea32cb103b068c710ccc58b6d59b4d3b58a4c Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 27 Mar 2026 10:26:35 -0400 Subject: [PATCH 09/69] ci: tag integration branch Docker image as 'integration' --- .github/workflows/docker-publish.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 64c78f755..82fa8406f 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -70,9 +70,14 @@ jobs: flavor: | latest=false tags: | - # dev/integration branch or manual dispatch without a tag: short commit SHA + latest + # dev/integration branch or manual dispatch without a tag: short commit SHA type=sha,prefix=,format=short,enable=${{ github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/integration' || (github.event_name == 'workflow_dispatch' && inputs.git_tag == '') }} - type=raw,value=latest,enable=${{ github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/integration' || (github.event_name == 'workflow_dispatch' && inputs.git_tag == '') }} + + # dev branch or manual dispatch without a tag: latest tag + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/dev' || (github.event_name == 'workflow_dispatch' && inputs.git_tag == '') }} + + # integration branch: stable integration tag + type=raw,value=integration,enable=${{ github.ref == 'refs/heads/integration' }} # git tags (push or manual dispatch with a tag): semver breakdown type=semver,pattern={{version}},value=${{ steps.release_tag.outputs.value }},enable=${{ steps.release_tag.outputs.is_release == 'true' }} From 143b47fafefa1e5ae2375a4068f1f0452f14ddac Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 28 Mar 2026 12:14:10 -0400 Subject: [PATCH 10/69] feat: add pre-push git hook for quality checks - Adds pre-push hook that runs typecheck, lint, and format checks - Blocks pushes that would fail CI - Includes install script for easy setup - Tracked on personal/config to persist across dev pulls --- scripts/git-hooks/README.md | 28 ++++++++++++++++++++++++++++ scripts/git-hooks/pre-push | 35 +++++++++++++++++++++++++++++++++++ scripts/install-git-hooks.sh | 25 +++++++++++++++++++++++++ 3 files changed, 88 insertions(+) create mode 100644 scripts/git-hooks/README.md create mode 100644 scripts/git-hooks/pre-push create mode 100644 scripts/install-git-hooks.sh diff --git a/scripts/git-hooks/README.md b/scripts/git-hooks/README.md new file mode 100644 index 000000000..2793d1921 --- /dev/null +++ b/scripts/git-hooks/README.md @@ -0,0 +1,28 @@ +# Git Hooks + +This directory contains git hooks that enforce quality standards before pushing code. + +## Installation + +Run the installation script from the repository root: + +```bash +./scripts/install-git-hooks.sh +``` + +This will copy the hooks to `.git/hooks/` and make them executable. + +## Hooks + +### pre-push + +Runs before every `git push` and enforces: +- TypeScript type checking (`npm run typecheck`) +- ESLint checks (`npm run lint`) +- Prettier formatting (`npm run fmt:check`) + +If any check fails, the push is blocked. To bypass in emergencies: `git push --no-verify` + +## Maintenance + +This directory is tracked on the `personal/config` branch to persist across `dev` pulls and merges. diff --git a/scripts/git-hooks/pre-push b/scripts/git-hooks/pre-push new file mode 100644 index 000000000..d4c02c37a --- /dev/null +++ b/scripts/git-hooks/pre-push @@ -0,0 +1,35 @@ +#!/bin/zsh +# Pre-push hook: Run quality checks before allowing push +# This prevents pushing code that will fail CI checks + +set -e + +echo "🔍 Running pre-push quality checks..." + +# Run typecheck +echo " → Running typecheck..." +if ! npm run typecheck > /dev/null 2>&1; then + echo "❌ Typecheck failed. Fix errors before pushing." + npm run typecheck + exit 1 +fi +echo " ✓ Typecheck passed" + +# Run lint +echo " → Running lint..." +if ! npm run lint > /dev/null 2>&1; then + echo "❌ Lint failed. Fix errors before pushing." + npm run lint + exit 1 +fi +echo " ✓ Lint passed" + +# Run format check +echo " → Running format check..." +if ! npm run fmt:check > /dev/null 2>&1; then + echo "❌ Format check failed. Run 'npm run fmt' to fix." + exit 1 +fi +echo " ✓ Format check passed" + +echo "✅ All quality checks passed. Proceeding with push..." diff --git a/scripts/install-git-hooks.sh b/scripts/install-git-hooks.sh new file mode 100644 index 000000000..c90efc819 --- /dev/null +++ b/scripts/install-git-hooks.sh @@ -0,0 +1,25 @@ +#!/bin/zsh +# Setup script: Install git hooks from scripts/git-hooks/ + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +HOOKS_DIR="$REPO_ROOT/.git/hooks" +SOURCE_DIR="$REPO_ROOT/scripts/git-hooks" + +echo "🔧 Installing git hooks..." + +# Install pre-push hook +if [ -f "$SOURCE_DIR/pre-push" ]; then + cp "$SOURCE_DIR/pre-push" "$HOOKS_DIR/pre-push" + chmod +x "$HOOKS_DIR/pre-push" + echo " ✓ Installed pre-push hook" +else + echo " ⚠ pre-push hook not found in $SOURCE_DIR" +fi + +echo "✅ Git hooks installation complete!" +echo "" +echo "The pre-push hook will now run quality checks (typecheck, lint, format)" +echo "before every git push. To bypass in emergencies, use: git push --no-verify" From 64afea772cae318d5ee87668e9fe5af311d5d741 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 28 Mar 2026 19:56:52 -0400 Subject: [PATCH 11/69] ci(docker): load env-specific client config overrides --- .github/workflows/docker-publish.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 82fa8406f..5badb90a4 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -23,12 +23,16 @@ env: jobs: build-and-push: runs-on: ubuntu-latest + environment: ${{ github.event_name == 'pull_request' && (github.base_ref == 'dev' && 'production' || github.base_ref == 'integration' && 'preview' || 'preview') || github.ref == 'refs/heads/dev' && 'production' || github.ref == 'refs/heads/integration' && 'preview' || 'preview' }} permissions: contents: read packages: write attestations: write artifact-metadata: write id-token: write + env: + CLIENT_CONFIG_OVERRIDES_JSON: ${{ vars.CLIENT_CONFIG_OVERRIDES_JSON }} + CLIENT_CONFIG_OVERRIDES_STRICT: ${{ vars.CLIENT_CONFIG_OVERRIDES_STRICT || 'false' }} steps: - name: Checkout repository From 5089c7ad6496704a7fd0dc6adb7b316c2ffe209d Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 28 Mar 2026 20:01:23 -0400 Subject: [PATCH 12/69] ci: integration uses preview env, dev uses production env --- .github/workflows/cloudflare-dev-deploy.yml | 103 +++++++++++++++++++ .github/workflows/cloudflare-web-preview.yml | 2 +- 2 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/cloudflare-dev-deploy.yml diff --git a/.github/workflows/cloudflare-dev-deploy.yml b/.github/workflows/cloudflare-dev-deploy.yml new file mode 100644 index 000000000..e113e954d --- /dev/null +++ b/.github/workflows/cloudflare-dev-deploy.yml @@ -0,0 +1,103 @@ +name: Cloudflare Worker Dev Deploy + +on: + push: + branches: + - dev + paths: + - 'src/**' + - 'index.html' + - 'package.json' + - 'package-lock.json' + - 'vite.config.ts' + - 'tsconfig.json' + - '.github/workflows/cloudflare-dev-deploy.yml' + - '.github/actions/setup/**' + +concurrency: + group: cloudflare-worker-dev-deploy + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + environment: production + permissions: + contents: read + env: + CLIENT_CONFIG_OVERRIDES_JSON: ${{ vars.CLIENT_CONFIG_OVERRIDES_JSON }} + CLIENT_CONFIG_OVERRIDES_STRICT: ${{ vars.CLIENT_CONFIG_OVERRIDES_STRICT || 'false' }} + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Prepare preview metadata + id: metadata + shell: bash + run: | + preview_message="$(git log -1 --pretty=%s)" + preview_message="$(printf '%s' "$preview_message" | head -c 100)" + + { + echo 'preview_message<> "$GITHUB_OUTPUT" + + - name: Set Sentry build environment + env: + VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} + shell: bash + run: | + echo "VITE_SENTRY_DSN=$VITE_SENTRY_DSN" >> "$GITHUB_ENV" + echo "VITE_SENTRY_ENVIRONMENT=production" >> "$GITHUB_ENV" + echo "SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN" >> "$GITHUB_ENV" + echo "SENTRY_ORG=$SENTRY_ORG" >> "$GITHUB_ENV" + echo "SENTRY_PROJECT=$SENTRY_PROJECT" >> "$GITHUB_ENV" + + - name: Setup app and build + uses: ./.github/actions/setup + with: + build: 'true' + + - name: Upload Worker preview + id: deploy + uses: cloudflare/wrangler-action@da0e0dfe58b7a431659754fdf3f186c529afbe65 # v3.14.1 + env: + PREVIEW_MESSAGE: ${{ steps.metadata.outputs.preview_message }} + with: + apiToken: ${{ secrets.TF_CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.TF_VAR_ACCOUNT_ID }} + command: > + versions upload + -c dist/wrangler.json + --preview-alias dev + --message "$PREVIEW_MESSAGE" + + - name: Publish summary + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + DEPLOYMENT_URL: ${{ steps.deploy.outputs.deployment-url }} + SHORT_SHA: ${{ github.sha }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const deploymentUrl = process.env.DEPLOYMENT_URL; + const shortSha = process.env.SHORT_SHA?.slice(0, 7); + const now = new Date().toUTCString().replace(':00 GMT', ' UTC'); + + const tableRow = "| ✅ Dev deployment successful! | " + deploymentUrl + " | " + shortSha + " | `dev` | " + now + " |"; + const comment = [ + `## Deploying with  Cloudflare Workers  Cloudflare Workers (dev → production config)`, + ``, + `| Status | URL | Commit | Alias | Updated (UTC) |`, + `| - | - | - | - | - |`, + tableRow, + ].join("\n"); + + await core.summary.addRaw(comment).write(); diff --git a/.github/workflows/cloudflare-web-preview.yml b/.github/workflows/cloudflare-web-preview.yml index 8b93a4bb9..eb81532fb 100644 --- a/.github/workflows/cloudflare-web-preview.yml +++ b/.github/workflows/cloudflare-web-preview.yml @@ -13,7 +13,7 @@ on: - '.github/actions/setup/**' push: branches: - - dev + - integration paths: - 'src/**' - 'index.html' From d9f5ca323d466340122c78d671f0d13c03adcdf5 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 28 Mar 2026 20:01:41 -0400 Subject: [PATCH 13/69] ci(workflows): trigger app deploys on config.json changes --- .github/workflows/cloudflare-dev-deploy.yml | 2 ++ .github/workflows/cloudflare-web-preview.yml | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/.github/workflows/cloudflare-dev-deploy.yml b/.github/workflows/cloudflare-dev-deploy.yml index e113e954d..5bc6421e0 100644 --- a/.github/workflows/cloudflare-dev-deploy.yml +++ b/.github/workflows/cloudflare-dev-deploy.yml @@ -6,9 +6,11 @@ on: - dev paths: - 'src/**' + - 'config.json' - 'index.html' - 'package.json' - 'package-lock.json' + - 'scripts/inject-client-config.js' - 'vite.config.ts' - 'tsconfig.json' - '.github/workflows/cloudflare-dev-deploy.yml' diff --git a/.github/workflows/cloudflare-web-preview.yml b/.github/workflows/cloudflare-web-preview.yml index eb81532fb..d7df99897 100644 --- a/.github/workflows/cloudflare-web-preview.yml +++ b/.github/workflows/cloudflare-web-preview.yml @@ -4,9 +4,11 @@ on: pull_request: paths: - 'src/**' + - 'config.json' - 'index.html' - 'package.json' - 'package-lock.json' + - 'scripts/inject-client-config.js' - 'vite.config.ts' - 'tsconfig.json' - '.github/workflows/cloudflare-web-preview.yml' @@ -16,9 +18,11 @@ on: - integration paths: - 'src/**' + - 'config.json' - 'index.html' - 'package.json' - 'package-lock.json' + - 'scripts/inject-client-config.js' - 'vite.config.ts' - 'tsconfig.json' - '.github/workflows/cloudflare-web-preview.yml' From 99ce6fa45fb6590e19cefd359e2cae5c375482cf Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 28 Mar 2026 23:24:27 -0400 Subject: [PATCH 14/69] chore: codespace devcontainer config --- .devcontainer/devcontainer.json | 71 ++++++++++++++++++++++++++++++++ .devcontainer/on-create.sh | 19 +++++++++ .devcontainer/post-create.sh | 72 +++++++++++++++++++++++++++++++++ .devcontainer/post-start.sh | 39 ++++++++++++++++++ .devcontainer/setup-signing.sh | 51 +++++++++++++++++++++++ .devcontainer/update-content.sh | 19 +++++++++ sable.code-workspace | 27 +++++++++++++ 7 files changed, 298 insertions(+) create mode 100644 .devcontainer/devcontainer.json create mode 100644 .devcontainer/on-create.sh create mode 100644 .devcontainer/post-create.sh create mode 100644 .devcontainer/post-start.sh create mode 100644 .devcontainer/setup-signing.sh create mode 100644 .devcontainer/update-content.sh create mode 100644 sable.code-workspace diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 000000000..45329c341 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,71 @@ +{ + "name": "Sable", + "image": "mcr.microsoft.com/devcontainers/javascript-node:1-24-bookworm", + + // Minimum 4 cores / 8 GB RAM so Vite builds and TypeScript checks don't crawl + "hostRequirements": { + "cpus": 4, + "memory": "8gb", + "storage": "32gb" + }, + + "features": { + // GitHub CLI for PR/issue/fork management + "ghcr.io/devcontainers/features/github-cli:1": {} + }, + + // Expose Vite dev server and Zola docs preview + "forwardPorts": [5173, 8080, 1111], + "portsAttributes": { + "5173": { "label": "Vite Dev Server", "onAutoForward": "notify" }, + "8080": { "label": "App Preview", "onAutoForward": "notify" }, + "1111": { "label": "Docs Preview (Zola)", "onAutoForward": "notify" } + }, + + // Open the multi-root workspace covering both Sable + Sable-Docs + "workspaceFile": "${localWorkspaceFolder}/sable.code-workspace", + + "customizations": { + "vscode": { + "extensions": [ + // JS/TS toolchain + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode", + "webpro.vscode-knip", + "ms-vscode.vscode-typescript-next", + // Git & GitHub + "github.vscode-pull-request-github", + "eamodio.gitlens", + // Docs (Zola / TOML / Markdown) + "tamasfe.even-better-toml", + "yzhang.markdown-all-in-one", + "eliostruyf.vscode-front-matter", + // Misc + "EditorConfig.EditorConfig" + ], + "settings": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "typescript.tsdk": "node_modules/typescript/lib", + "[jsonc]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, + "[json]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, + "[toml]": { "editor.defaultFormatter": "tamasfe.even-better-toml" }, + "git.autofetch": true, + "terminal.integrated.defaultProfile.linux": "bash" + } + } + }, + + // ── Lifecycle hooks ──────────────────────────────────────────────────────── + // on-create : runs ONCE when the prebuild image is first built (cached) + // update-content: re-runs on each prebuild refresh & new codespace create (cached) + // post-create : runs once on each new codespace (not cached) – user-specific setup + // post-start : runs on EVERY codespace start (fetch upstream, signing check) + + "onCreateCommand": "bash .devcontainer/on-create.sh", + "updateContentCommand": "bash .devcontainer/update-content.sh", + "postCreateCommand": "bash .devcontainer/post-create.sh", + "postStartCommand": "bash .devcontainer/post-start.sh", + + "remoteUser": "node" +} diff --git a/.devcontainer/on-create.sh b/.devcontainer/on-create.sh new file mode 100644 index 000000000..1d5123eaa --- /dev/null +++ b/.devcontainer/on-create.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# on-create.sh — runs ONCE when the prebuild image is first built +# Everything here is cached between prebuild refreshes. +set -euo pipefail + +echo "==> [on-create] Enabling corepack (pnpm)..." +corepack enable +corepack prepare pnpm@latest --activate + +echo "==> [on-create] Configuring pnpm global store..." +pnpm config set store-dir /home/node/.local/share/pnpm/store + +echo "==> [on-create] Installing Zola (for Sable-Docs preview)..." +ZOLA_VERSION="0.19.2" +ZOLA_URL="https://github.com/getzola/zola/releases/download/v${ZOLA_VERSION}/zola-v${ZOLA_VERSION}-x86_64-unknown-linux-gnu.tar.gz" +curl -fsSL "$ZOLA_URL" | sudo tar xz -C /usr/local/bin +zola --version + +echo "==> [on-create] Done." diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh new file mode 100644 index 000000000..4f2ef27a1 --- /dev/null +++ b/.devcontainer/post-create.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# post-create.sh — runs ONCE per new codespace (not cached in prebuild). +# Handles user-specific git setup: remotes, branches, signing. +set -euo pipefail + +SABLE_DIR="/workspaces/Sable" +DOCS_DIR="/workspaces/Sable-Docs" + +# ── 1. Upstream remotes ─────────────────────────────────────────────────────── +echo "==> [post-create] Configuring upstream remotes..." + +# Sable: fork = origin (Just-Insane/Sable), upstream = SableClient/Sable +if ! git -C "$SABLE_DIR" remote | grep -q "^upstream$"; then + git -C "$SABLE_DIR" remote add upstream https://github.com/SableClient/Sable.git + echo " Added upstream → SableClient/Sable" +else + echo " upstream remote already set" +fi +git -C "$SABLE_DIR" fetch --all --quiet + +# Docs: fork = origin (Just-Insane/docs), upstream = SableClient/docs +if ! git -C "$DOCS_DIR" remote | grep -q "^upstream$"; then + git -C "$DOCS_DIR" remote add upstream https://github.com/SableClient/docs.git + echo " [docs] Added upstream → SableClient/docs" +else + echo " [docs] upstream remote already set" +fi +git -C "$DOCS_DIR" fetch --all --quiet + +# ── 2. Ensure required branches exist ──────────────────────────────────────── +echo "==> [post-create] Ensuring branches exist in Sable..." + +ensure_branch() { + local dir="$1" + local branch="$2" + local start_point="${3:-HEAD}" + if git -C "$dir" ls-remote --heads origin "$branch" | grep -q "$branch"; then + echo " Branch '$branch' already exists on origin, checking out..." + git -C "$dir" fetch origin "$branch" --quiet + if ! git -C "$dir" show-ref --quiet "refs/heads/$branch"; then + git -C "$dir" branch --track "$branch" "origin/$branch" + fi + else + echo " Creating branch '$branch' from $start_point and pushing to origin..." + git -C "$dir" checkout -b "$branch" "$start_point" 2>/dev/null || true + git -C "$dir" push -u origin "$branch" + fi +} + +# Switch back to integration after branch ops +CURRENT_BRANCH=$(git -C "$SABLE_DIR" rev-parse --abbrev-ref HEAD) + +ensure_branch "$SABLE_DIR" "integration" "upstream/dev" +ensure_branch "$SABLE_DIR" "personal/config" "integration" +ensure_branch "$DOCS_DIR" "integration" "upstream/main" + +# Return to whatever branch we were on +git -C "$SABLE_DIR" checkout "$CURRENT_BRANCH" 2>/dev/null || true + +# ── 3. Git signing (SSH via forwarded YubiKey) ──────────────────────────────── +echo "==> [post-create] Configuring SSH commit signing..." +bash "$SABLE_DIR/.devcontainer/setup-signing.sh" || true + +# ── 4. Install git hooks ────────────────────────────────────────────────────── +echo "==> [post-create] Installing git hooks..." +if [ -f "$SABLE_DIR/scripts/install-git-hooks.sh" ]; then + bash "$SABLE_DIR/scripts/install-git-hooks.sh" +fi + +echo "" +echo "==> [post-create] Done! Open sable.code-workspace for the multi-root view." +echo " Run '.devcontainer/setup-signing.sh' any time to reconfigure commit signing." diff --git a/.devcontainer/post-start.sh b/.devcontainer/post-start.sh new file mode 100644 index 000000000..c49b2eeb2 --- /dev/null +++ b/.devcontainer/post-start.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# post-start.sh — runs on EVERY codespace start. +# Fetches upstream changes and re-checks signing (agent may have changed). +set -euo pipefail + +SABLE_DIR="/workspaces/Sable" +DOCS_DIR="/workspaces/Sable-Docs" + +# ── Fetch upstream for both repos ──────────────────────────────────────────── +echo "==> [post-start] Fetching upstream..." +git -C "$SABLE_DIR" fetch upstream --quiet 2>/dev/null && echo " Sable upstream fetched" || echo " ⚠ Could not fetch Sable upstream" +git -C "$DOCS_DIR" fetch upstream --quiet 2>/dev/null && echo " Docs upstream fetched" || echo " ⚠ Could not fetch Docs upstream" + +# ── Show how far behind integration is from upstream/dev ───────────────────── +BEHIND=$(git -C "$SABLE_DIR" rev-list --count HEAD..upstream/dev 2>/dev/null || echo "?") +if [ "$BEHIND" != "0" ] && [ "$BEHIND" != "?" ]; then + echo "" + echo " ℹ Your current branch is $BEHIND commit(s) behind upstream/dev." + echo " To sync: git merge upstream/dev (or: git rebase upstream/dev)" +fi + +# ── Re-configure SSH signing if not already set (agent may now be available) ─ +if [ "$(git config --global gpg.format 2>/dev/null)" != "ssh" ]; then + bash "$SABLE_DIR/.devcontainer/setup-signing.sh" || true +else + # Verify the key still exists in the agent (yubikey could have changed) + CONFIGURED_KEY=$(git config --global user.signingkey 2>/dev/null || echo "") + if [ -n "$CONFIGURED_KEY" ]; then + if ! ssh-add -L 2>/dev/null | grep -qF "$CONFIGURED_KEY"; then + echo "" + echo " ⚠ Signing key not found in SSH agent. YubiKey present?" + echo " Re-run: bash .devcontainer/setup-signing.sh" + else + echo " ✓ Commit signing ready (SSH via forwarded agent)" + fi + fi +fi + +echo "" diff --git a/.devcontainer/setup-signing.sh b/.devcontainer/setup-signing.sh new file mode 100644 index 000000000..d8262cf3b --- /dev/null +++ b/.devcontainer/setup-signing.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# setup-signing.sh — configures SSH commit signing via forwarded SSH agent. +# Safe to re-run at any time. YubiKey-backed keys work as long as the +# SSH agent from your local machine is forwarded (VS Code handles this). +set -euo pipefail + +SABLE_DIR="/workspaces/Sable" +ALLOWED_SIGNERS_FILE="$HOME/.config/git/allowed_signers" + +# Check if SSH agent is available and has keys loaded +if ! ssh-add -L &>/dev/null || [ -z "$(ssh-add -L 2>/dev/null)" ]; then + echo "⚠ No SSH keys found in the forwarded agent." + echo " Make sure your local SSH agent is running and your YubiKey key is loaded." + echo " On macOS: ssh-add --apple-use-keychain ~/.ssh/id_ed25519" + echo " To retry: bash .devcontainer/setup-signing.sh" + exit 0 +fi + +# Pick the first key; if your YubiKey-backed key is not first, adjust: +# e.g. SIGNING_KEY=$(ssh-add -L | grep "cardno:" | head -1) +SIGNING_KEY=$(ssh-add -L | head -1) +KEY_COMMENT=$(echo "$SIGNING_KEY" | awk '{print $NF}') + +echo "✓ Found SSH key: ...${KEY_COMMENT}" + +# Configure git to use SSH signing +git config --global gpg.format ssh +git config --global user.signingkey "$SIGNING_KEY" +git config --global commit.gpgsign true +git config --global tag.gpgsign true + +# Set up allowed_signers for local verification +USER_EMAIL=$(git config --global user.email 2>/dev/null || echo "") +if [ -n "$USER_EMAIL" ]; then + mkdir -p "$(dirname "$ALLOWED_SIGNERS_FILE")" + # Remove stale entry for this email if present, then add fresh one + if [ -f "$ALLOWED_SIGNERS_FILE" ]; then + grep -v "^$USER_EMAIL " "$ALLOWED_SIGNERS_FILE" > "${ALLOWED_SIGNERS_FILE}.tmp" || true + mv "${ALLOWED_SIGNERS_FILE}.tmp" "$ALLOWED_SIGNERS_FILE" + fi + echo "$USER_EMAIL namespaces=\"git\" $SIGNING_KEY" >> "$ALLOWED_SIGNERS_FILE" + git config --global gpg.ssh.allowedSignersFile "$ALLOWED_SIGNERS_FILE" + echo "✓ SSH commit signing configured for <$USER_EMAIL>" +else + echo "⚠ user.email not set globally. Run: git config --global user.email 'you@example.com'" + echo " Then re-run: bash .devcontainer/setup-signing.sh" +fi + +echo "" +echo "Test signing with: git commit --allow-empty -m 'test signing'" +echo "Verify with: git log --show-signature -1" diff --git a/.devcontainer/update-content.sh b/.devcontainer/update-content.sh new file mode 100644 index 000000000..572ae73ba --- /dev/null +++ b/.devcontainer/update-content.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# update-content.sh — runs on each prebuild refresh AND on new codespace creation. +# The resulting filesystem state is cached in the prebuild snapshot. +set -euo pipefail + +echo "==> [update-content] Installing Sable dependencies (pnpm install)..." +pnpm install --frozen-lockfile + +echo "==> [update-content] Cloning / updating Sable-Docs..." +DOCS_DIR="/workspaces/Sable-Docs" +if [ -d "$DOCS_DIR/.git" ]; then + echo " Docs already present, fetching latest..." + git -C "$DOCS_DIR" fetch --all +else + echo " Cloning Just-Insane/docs → $DOCS_DIR" + git clone https://github.com/Just-Insane/docs "$DOCS_DIR" +fi + +echo "==> [update-content] Done." diff --git a/sable.code-workspace b/sable.code-workspace new file mode 100644 index 000000000..b7b699ce8 --- /dev/null +++ b/sable.code-workspace @@ -0,0 +1,27 @@ +{ + "folders": [ + { + "path": ".", + "name": "Sable" + }, + { + "path": "../Sable-Docs", + "name": "Sable-Docs" + } + ], + "settings": { + "editor.formatOnSave": true, + "typescript.tsdk": "Sable/node_modules/typescript/lib" + }, + "extensions": { + "recommendations": [ + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode", + "webpro.vscode-knip", + "tamasfe.even-better-toml", + "yzhang.markdown-all-in-one", + "github.vscode-pull-request-github", + "eamodio.gitlens" + ] + } +} From ee36041cd6614e3ced4237976d8484b6c65ace7f Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 28 Mar 2026 23:29:30 -0400 Subject: [PATCH 15/69] Update image --- .devcontainer/devcontainer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 45329c341..ddf43676c 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,6 +1,6 @@ { "name": "Sable", - "image": "mcr.microsoft.com/devcontainers/javascript-node:1-24-bookworm", + "image": "mcr.microsoft.com/devcontainers/javascript-node:24-bookworm", // Minimum 4 cores / 8 GB RAM so Vite builds and TypeScript checks don't crawl "hostRequirements": { From b7c7ad1f848202ac3b66629b63a7b38e841b1833 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 28 Mar 2026 23:35:08 -0400 Subject: [PATCH 16/69] update startup script --- .devcontainer/on-create.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.devcontainer/on-create.sh b/.devcontainer/on-create.sh index 1d5123eaa..7f6f789d8 100644 --- a/.devcontainer/on-create.sh +++ b/.devcontainer/on-create.sh @@ -4,7 +4,7 @@ set -euo pipefail echo "==> [on-create] Enabling corepack (pnpm)..." -corepack enable +sudo corepack enable corepack prepare pnpm@latest --activate echo "==> [on-create] Configuring pnpm global store..." From 33fdde8a3636c5882e131d9667c6fe7c9228e694 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 28 Mar 2026 23:56:22 -0400 Subject: [PATCH 17/69] Update setup-signing script --- .devcontainer/setup-signing.sh | 61 ++++++++++++++++++++++------------ 1 file changed, 40 insertions(+), 21 deletions(-) diff --git a/.devcontainer/setup-signing.sh b/.devcontainer/setup-signing.sh index d8262cf3b..9191572f9 100644 --- a/.devcontainer/setup-signing.sh +++ b/.devcontainer/setup-signing.sh @@ -1,29 +1,49 @@ #!/usr/bin/env bash -# setup-signing.sh — configures SSH commit signing via forwarded SSH agent. -# Safe to re-run at any time. YubiKey-backed keys work as long as the -# SSH agent from your local machine is forwarded (VS Code handles this). +# setup-signing.sh — configures SSH commit signing. +# Supports two modes: +# 1. Forwarded SSH agent (VS Code desktop + YubiKey) +# 2. Codespace-local SSH key (browser/web Codespaces) +# Safe to re-run at any time. set -euo pipefail SABLE_DIR="/workspaces/Sable" ALLOWED_SIGNERS_FILE="$HOME/.config/git/allowed_signers" +CODESPACE_KEY="$HOME/.ssh/codespace_signing_ed25519" -# Check if SSH agent is available and has keys loaded -if ! ssh-add -L &>/dev/null || [ -z "$(ssh-add -L 2>/dev/null)" ]; then - echo "⚠ No SSH keys found in the forwarded agent." - echo " Make sure your local SSH agent is running and your YubiKey key is loaded." - echo " On macOS: ssh-add --apple-use-keychain ~/.ssh/id_ed25519" - echo " To retry: bash .devcontainer/setup-signing.sh" - exit 0 -fi - -# Pick the first key; if your YubiKey-backed key is not first, adjust: -# e.g. SIGNING_KEY=$(ssh-add -L | grep "cardno:" | head -1) -SIGNING_KEY=$(ssh-add -L | head -1) -KEY_COMMENT=$(echo "$SIGNING_KEY" | awk '{print $NF}') +# ── MODE 1: Forwarded SSH agent (desktop VS Code) ──────────────────────────── +if ssh-add -L &>/dev/null && [ -n "$(ssh-add -L 2>/dev/null)" ]; then + echo "✓ Detected forwarded SSH agent (desktop VS Code + YubiKey mode)" + SIGNING_KEY=$(ssh-add -L | head -1) + KEY_COMMENT=$(echo "$SIGNING_KEY" | awk '{print $NF}') + echo " Using key: ...${KEY_COMMENT}" -echo "✓ Found SSH key: ...${KEY_COMMENT}" +# ── MODE 2: Codespace-local key (web Codespaces) ───────────────────────────── +else + echo "ℹ No forwarded agent (web Codespace mode)" + + if [ ! -f "$CODESPACE_KEY" ]; then + echo " Generating new Ed25519 signing key..." + mkdir -p "$HOME/.ssh" + ssh-keygen -t ed25519 -f "$CODESPACE_KEY" -N "" -C "codespace-signing@$(hostname)" + echo "" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo " 🔑 Add this PUBLIC KEY to GitHub as a SIGNING key:" + echo "" + cat "${CODESPACE_KEY}.pub" + echo "" + echo " 👉 https://github.com/settings/keys → New SSH key" + echo " Title: Codespace Signing Key" + echo " Key type: Signing Key" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + read -p "Press Enter after adding the key to GitHub..." + fi + + SIGNING_KEY=$(cat "${CODESPACE_KEY}.pub") + echo " Using Codespace key: ${CODESPACE_KEY}" +fi -# Configure git to use SSH signing +# ── Common: Configure git ──────────────────────────────────────────────────── git config --global gpg.format ssh git config --global user.signingkey "$SIGNING_KEY" git config --global commit.gpgsign true @@ -33,7 +53,6 @@ git config --global tag.gpgsign true USER_EMAIL=$(git config --global user.email 2>/dev/null || echo "") if [ -n "$USER_EMAIL" ]; then mkdir -p "$(dirname "$ALLOWED_SIGNERS_FILE")" - # Remove stale entry for this email if present, then add fresh one if [ -f "$ALLOWED_SIGNERS_FILE" ]; then grep -v "^$USER_EMAIL " "$ALLOWED_SIGNERS_FILE" > "${ALLOWED_SIGNERS_FILE}.tmp" || true mv "${ALLOWED_SIGNERS_FILE}.tmp" "$ALLOWED_SIGNERS_FILE" @@ -47,5 +66,5 @@ else fi echo "" -echo "Test signing with: git commit --allow-empty -m 'test signing'" -echo "Verify with: git log --show-signature -1" +echo "Test signing: git commit --allow-empty -m 'test signing'" +echo "Verify: git log --show-signature -1" \ No newline at end of file From 678a45a67059d7bde2bcb6148035ed9de3617bd5 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 29 Mar 2026 00:06:13 -0400 Subject: [PATCH 18/69] Updates for ssh --- .devcontainer/post-start.sh | 20 +++++++++++++++----- .devcontainer/setup-signing.sh | 10 ++++++++++ sable.code-workspace | 14 +++++++------- 3 files changed, 32 insertions(+), 12 deletions(-) diff --git a/.devcontainer/post-start.sh b/.devcontainer/post-start.sh index c49b2eeb2..7afe0c3cc 100644 --- a/.devcontainer/post-start.sh +++ b/.devcontainer/post-start.sh @@ -20,18 +20,28 @@ if [ "$BEHIND" != "0" ] && [ "$BEHIND" != "?" ]; then fi # ── Re-configure SSH signing if not already set (agent may now be available) ─ +CODESPACE_KEY="$HOME/.ssh/codespace_signing_ed25519" if [ "$(git config --global gpg.format 2>/dev/null)" != "ssh" ]; then bash "$SABLE_DIR/.devcontainer/setup-signing.sh" || true else - # Verify the key still exists in the agent (yubikey could have changed) + # Verify the key still exists in the agent CONFIGURED_KEY=$(git config --global user.signingkey 2>/dev/null || echo "") if [ -n "$CONFIGURED_KEY" ]; then if ! ssh-add -L 2>/dev/null | grep -qF "$CONFIGURED_KEY"; then - echo "" - echo " ⚠ Signing key not found in SSH agent. YubiKey present?" - echo " Re-run: bash .devcontainer/setup-signing.sh" + # In web Codespace mode, reload the key into a fresh agent + if [ -f "$CODESPACE_KEY" ]; then + echo " ↻ Reloading Codespace signing key into SSH agent..." + if [ -z "$SSH_AUTH_SOCK" ] || ! ssh-add -l &>/dev/null; then + eval "$(ssh-agent -s)" > /dev/null + echo "export SSH_AUTH_SOCK=$SSH_AUTH_SOCK" >> "$HOME/.bashrc" + echo "export SSH_AGENT_PID=$SSH_AGENT_PID" >> "$HOME/.bashrc" + fi + ssh-add "$CODESPACE_KEY" 2>/dev/null && echo " ✓ Commit signing ready" + else + echo " ⚠ Signing key not found. YubiKey present or re-run: bash .devcontainer/setup-signing.sh" + fi else - echo " ✓ Commit signing ready (SSH via forwarded agent)" + echo " ✓ Commit signing ready" fi fi fi diff --git a/.devcontainer/setup-signing.sh b/.devcontainer/setup-signing.sh index 9191572f9..189f29383 100644 --- a/.devcontainer/setup-signing.sh +++ b/.devcontainer/setup-signing.sh @@ -39,6 +39,16 @@ else read -p "Press Enter after adding the key to GitHub..." fi + # Start ssh-agent if not already running and add the key + if [ -z "$SSH_AUTH_SOCK" ] || ! ssh-add -l &>/dev/null; then + echo " Starting SSH agent and loading key..." + eval "$(ssh-agent -s)" > /dev/null + ssh-add "$CODESPACE_KEY" 2>/dev/null + # Persist SSH_AUTH_SOCK for future shells + echo "export SSH_AUTH_SOCK=$SSH_AUTH_SOCK" >> "$HOME/.bashrc" + echo "export SSH_AGENT_PID=$SSH_AGENT_PID" >> "$HOME/.bashrc" + fi + SIGNING_KEY=$(cat "${CODESPACE_KEY}.pub") echo " Using Codespace key: ${CODESPACE_KEY}" fi diff --git a/sable.code-workspace b/sable.code-workspace index b7b699ce8..f937d83ca 100644 --- a/sable.code-workspace +++ b/sable.code-workspace @@ -2,16 +2,16 @@ "folders": [ { "path": ".", - "name": "Sable" + "name": "Sable", }, { "path": "../Sable-Docs", - "name": "Sable-Docs" - } + "name": "Sable-Docs", + }, ], "settings": { "editor.formatOnSave": true, - "typescript.tsdk": "Sable/node_modules/typescript/lib" + "typescript.tsdk": "Sable/node_modules/typescript/lib", }, "extensions": { "recommendations": [ @@ -21,7 +21,7 @@ "tamasfe.even-better-toml", "yzhang.markdown-all-in-one", "github.vscode-pull-request-github", - "eamodio.gitlens" - ] - } + "eamodio.gitlens", + ], + }, } From 5c9c29762f1e14f6bf536f89630f61f891d36610 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 29 Mar 2026 00:13:24 -0400 Subject: [PATCH 19/69] More script fixes --- .devcontainer/post-start.sh | 2 +- .devcontainer/setup-signing.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.devcontainer/post-start.sh b/.devcontainer/post-start.sh index 7afe0c3cc..e4f64eda4 100644 --- a/.devcontainer/post-start.sh +++ b/.devcontainer/post-start.sh @@ -31,7 +31,7 @@ else # In web Codespace mode, reload the key into a fresh agent if [ -f "$CODESPACE_KEY" ]; then echo " ↻ Reloading Codespace signing key into SSH agent..." - if [ -z "$SSH_AUTH_SOCK" ] || ! ssh-add -l &>/dev/null; then + if [ -z "${SSH_AUTH_SOCK:-}" ] || ! ssh-add -l &>/dev/null; then eval "$(ssh-agent -s)" > /dev/null echo "export SSH_AUTH_SOCK=$SSH_AUTH_SOCK" >> "$HOME/.bashrc" echo "export SSH_AGENT_PID=$SSH_AGENT_PID" >> "$HOME/.bashrc" diff --git a/.devcontainer/setup-signing.sh b/.devcontainer/setup-signing.sh index 189f29383..647c5926c 100644 --- a/.devcontainer/setup-signing.sh +++ b/.devcontainer/setup-signing.sh @@ -40,7 +40,7 @@ else fi # Start ssh-agent if not already running and add the key - if [ -z "$SSH_AUTH_SOCK" ] || ! ssh-add -l &>/dev/null; then + if [ -z "${SSH_AUTH_SOCK:-}" ] || ! ssh-add -l &>/dev/null; then echo " Starting SSH agent and loading key..." eval "$(ssh-agent -s)" > /dev/null ssh-add "$CODESPACE_KEY" 2>/dev/null From 78b48e80c6dfe368808d017ddb2a7fa56991233b Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 29 Mar 2026 00:20:15 -0400 Subject: [PATCH 20/69] more fixes --- .devcontainer/post-start.sh | 27 ++++++++++++++------------- .devcontainer/setup-signing.sh | 25 +++++++++++++------------ 2 files changed, 27 insertions(+), 25 deletions(-) diff --git a/.devcontainer/post-start.sh b/.devcontainer/post-start.sh index e4f64eda4..f2353f39f 100644 --- a/.devcontainer/post-start.sh +++ b/.devcontainer/post-start.sh @@ -24,24 +24,25 @@ CODESPACE_KEY="$HOME/.ssh/codespace_signing_ed25519" if [ "$(git config --global gpg.format 2>/dev/null)" != "ssh" ]; then bash "$SABLE_DIR/.devcontainer/setup-signing.sh" || true else - # Verify the key still exists in the agent + # Verify the signing key is still accessible CONFIGURED_KEY=$(git config --global user.signingkey 2>/dev/null || echo "") if [ -n "$CONFIGURED_KEY" ]; then - if ! ssh-add -L 2>/dev/null | grep -qF "$CONFIGURED_KEY"; then - # In web Codespace mode, reload the key into a fresh agent - if [ -f "$CODESPACE_KEY" ]; then - echo " ↻ Reloading Codespace signing key into SSH agent..." - if [ -z "${SSH_AUTH_SOCK:-}" ] || ! ssh-add -l &>/dev/null; then - eval "$(ssh-agent -s)" > /dev/null - echo "export SSH_AUTH_SOCK=$SSH_AUTH_SOCK" >> "$HOME/.bashrc" - echo "export SSH_AGENT_PID=$SSH_AGENT_PID" >> "$HOME/.bashrc" - fi - ssh-add "$CODESPACE_KEY" 2>/dev/null && echo " ✓ Commit signing ready" + # If it's a file path (MODE 2), check file exists + if [[ "$CONFIGURED_KEY" == /* ]]; then + if [ -f "$CONFIGURED_KEY" ]; then + echo " ✓ Commit signing ready (private key file)" else - echo " ⚠ Signing key not found. YubiKey present or re-run: bash .devcontainer/setup-signing.sh" + echo " ⚠ Signing key file not found: $CONFIGURED_KEY" + echo " Re-run: bash .devcontainer/setup-signing.sh" fi + # If it's a public key string (MODE 1), check agent else - echo " ✓ Commit signing ready" + if ssh-add -L 2>/dev/null | grep -qF "$CONFIGURED_KEY"; then + echo " ✓ Commit signing ready (forwarded agent)" + else + echo " ⚠ Signing key not in SSH agent. YubiKey present?" + echo " Re-run: bash .devcontainer/setup-signing.sh" + fi fi fi fi diff --git a/.devcontainer/setup-signing.sh b/.devcontainer/setup-signing.sh index 647c5926c..ca5866095 100644 --- a/.devcontainer/setup-signing.sh +++ b/.devcontainer/setup-signing.sh @@ -39,17 +39,8 @@ else read -p "Press Enter after adding the key to GitHub..." fi - # Start ssh-agent if not already running and add the key - if [ -z "${SSH_AUTH_SOCK:-}" ] || ! ssh-add -l &>/dev/null; then - echo " Starting SSH agent and loading key..." - eval "$(ssh-agent -s)" > /dev/null - ssh-add "$CODESPACE_KEY" 2>/dev/null - # Persist SSH_AUTH_SOCK for future shells - echo "export SSH_AUTH_SOCK=$SSH_AUTH_SOCK" >> "$HOME/.bashrc" - echo "export SSH_AGENT_PID=$SSH_AGENT_PID" >> "$HOME/.bashrc" - fi - - SIGNING_KEY=$(cat "${CODESPACE_KEY}.pub") + # Use the private key file directly (git supports this without ssh-agent) + SIGNING_KEY="$CODESPACE_KEY" echo " Using Codespace key: ${CODESPACE_KEY}" fi @@ -67,7 +58,17 @@ if [ -n "$USER_EMAIL" ]; then grep -v "^$USER_EMAIL " "$ALLOWED_SIGNERS_FILE" > "${ALLOWED_SIGNERS_FILE}.tmp" || true mv "${ALLOWED_SIGNERS_FILE}.tmp" "$ALLOWED_SIGNERS_FILE" fi - echo "$USER_EMAIL namespaces=\"git\" $SIGNING_KEY" >> "$ALLOWED_SIGNERS_FILE" + + # For allowed_signers, always use the public key (even if signing with private key file) + if [ -f "$CODESPACE_KEY" ]; then + # MODE 2: read public key from file + PUBLIC_KEY=$(cat "${CODESPACE_KEY}.pub") + else + # MODE 1: already have public key in $SIGNING_KEY + PUBLIC_KEY="$SIGNING_KEY" + fi + + echo "$USER_EMAIL namespaces=\"git\" $PUBLIC_KEY" >> "$ALLOWED_SIGNERS_FILE" git config --global gpg.ssh.allowedSignersFile "$ALLOWED_SIGNERS_FILE" echo "✓ SSH commit signing configured for <$USER_EMAIL>" else From 4dc1b9f9ff165e6cb9a809ed0df1c8ff1c2bc26c Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 29 Mar 2026 04:31:10 +0000 Subject: [PATCH 21/69] updates --- .devcontainer/on-create.sh | 0 .devcontainer/post-create.sh | 0 .devcontainer/post-start.sh | 0 .devcontainer/setup-signing.sh | 0 .devcontainer/update-content.sh | 0 5 files changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 .devcontainer/on-create.sh mode change 100644 => 100755 .devcontainer/post-create.sh mode change 100644 => 100755 .devcontainer/post-start.sh mode change 100644 => 100755 .devcontainer/setup-signing.sh mode change 100644 => 100755 .devcontainer/update-content.sh diff --git a/.devcontainer/on-create.sh b/.devcontainer/on-create.sh old mode 100644 new mode 100755 diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh old mode 100644 new mode 100755 diff --git a/.devcontainer/post-start.sh b/.devcontainer/post-start.sh old mode 100644 new mode 100755 diff --git a/.devcontainer/setup-signing.sh b/.devcontainer/setup-signing.sh old mode 100644 new mode 100755 diff --git a/.devcontainer/update-content.sh b/.devcontainer/update-content.sh old mode 100644 new mode 100755 From 6639eb44da5d10286c151dc09c3d1ea31ac6c826 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 29 Mar 2026 04:36:12 +0000 Subject: [PATCH 22/69] add/setup extensions --- .devcontainer/devcontainer.json | 21 ++++++++++++++++++++- .vscode/extensions.json | 27 ++++++++++++++++++++++++++- .vscode/settings.json | 17 +++++++++++++++++ 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index ddf43676c..c1ffa7c9e 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -33,15 +33,34 @@ "esbenp.prettier-vscode", "webpro.vscode-knip", "ms-vscode.vscode-typescript-next", + "usernamehw.errorlens", + "christian-kohler.path-intellisense", + "styled-components.vscode-styled-components", + "bradlc.vscode-tailwindcss", + // React/TypeScript + "dsznajder.es7-react-js-snippets", + "formulahendry.auto-rename-tag", + "wix.vscode-import-cost", + // i18n + "lokalise.i18n-ally", + // Testing + "vitest.explorer", // Git & GitHub "github.vscode-pull-request-github", "eamodio.gitlens", + // Infrastructure + "hashicorp.terraform", + "zamerick.vscode-caddyfile-syntax", // Docs (Zola / TOML / Markdown) "tamasfe.even-better-toml", "yzhang.markdown-all-in-one", "eliostruyf.vscode-front-matter", + "streetsidesoftware.code-spell-checker", + "davidanson.vscode-markdownlint", // Misc - "EditorConfig.EditorConfig" + "EditorConfig.EditorConfig", + "gruntfuggly.todo-tree", + "wayou.vscode-todo-highlight" ], "settings": { "editor.formatOnSave": true, diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 434432fea..c58bff3e3 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,3 +1,28 @@ { - "recommendations": ["webpro.vscode-knip", "oxc.oxc-vscode"] + "recommendations": [ + // JS/TS toolchain + "webpro.vscode-knip", + "oxc.oxc-vscode", + "usernamehw.errorlens", + "christian-kohler.path-intellisense", + "styled-components.vscode-styled-components", + "bradlc.vscode-tailwindcss", + // React/TypeScript + "dsznajder.es7-react-js-snippets", + "formulahendry.auto-rename-tag", + "wix.vscode-import-cost", + // i18n + "lokalise.i18n-ally", + // Testing + "vitest.explorer", + // Infrastructure + "hashicorp.terraform", + "zamerick.vscode-caddyfile-syntax", + // Documentation + "streetsidesoftware.code-spell-checker", + "davidanson.vscode-markdownlint", + // Quality of Life + "gruntfuggly.todo-tree", + "wayou.vscode-todo-highlight" + ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index f2ceb43b5..bf5196aad 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -12,5 +12,22 @@ }, "[json]": { "editor.defaultFormatter": "oxc.oxc-vscode" + }, + // i18n Ally configuration + "i18n-ally.localesPaths": ["public/locales"], + "i18n-ally.keystyle": "nested", + "i18n-ally.enabledFrameworks": ["react", "i18next"], + "i18n-ally.namespace": true, + "i18n-ally.pathMatcher": "{locale}.json", + // Error Lens configuration + "errorLens.enabled": true, + // Import Cost configuration + "importCost.bundleSizeDecoration": "both", + "importCost.showCalculatingDecoration": true, + // Todo Tree configuration + "todo-tree.general.tags": ["TODO", "FIXME", "HACK", "XXX", "NOTE", "BUG"], + "todo-tree.highlights.defaultHighlight": { + "icon": "alert", + "type": "text" } } From e4c77d665a707f76feb00bfc0da52e5b4262f29a Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 29 Mar 2026 10:18:03 -0400 Subject: [PATCH 23/69] chore(config): enable phase1 and phase2 session sync flags --- config.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/config.json b/config.json index 90daf2190..8f685bfd4 100644 --- a/config.json +++ b/config.json @@ -20,6 +20,11 @@ "vapidPublicKey": "BEBdK6VUiqYxcOauFCM1ZB38llgiODAs6pR5EEcC7YBoUh2YvrULagwo5t-Ms0Is0lEmKDhpdUoMiy_i7ArI3oE", "webPushAppID": "social.cloudhub.sable.web" }, + "sessionSync": { + "phase1ForegroundResync": true, + "phase2VisibleHeartbeat": true, + "phase3AdaptiveBackoffJitter": false + }, "slidingSync": { "enabled": "true" } From 94ad314e43ec69efbdb6934eff5f023705ae109d Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 29 Mar 2026 11:53:49 -0400 Subject: [PATCH 24/69] chore(config): add Copilot workspace instructions --- .github/copilot-instructions.md | 82 +++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 .github/copilot-instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000..882847e80 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,82 @@ +# Sable – GitHub Copilot Workspace Instructions + +These rules apply to every chat and agent session in this workspace. + +--- + +## Git & Branching + +- **Never commit directly to `dev` or `integration`.** All work goes on a dedicated branch (`fix/…`, `feat/…`, `chore/…`, etc.). +- Before building `integration`, always **force-update `dev` from `upstream/dev`**: + ``` + git fetch upstream && git checkout dev && git reset --hard upstream/dev + ``` +- When asked to build `integration`, **always prompt for which feature/fix branches to include**. If a needed branch doesn't exist yet, create it first. +- Use short, scoped commit messages: `type(scope): description` (e.g. `fix(timeline): correct scroll anchor on bulk load`). + +## Quality Gates (must pass before every commit) + +Run these in order and fix all failures before committing: + +``` +pnpm lint # ESLint +pnpm fmt:check # Prettier +pnpm typecheck # TypeScript +pnpm test:run # Vitest unit tests +pnpm knip # Dead-code / unused exports check +``` + +Also run a **production build** and confirm it succeeds with no errors: +``` +pnpm build +``` + +## Pull Requests + +- Use the upstream PR template ([`.github/PULL_REQUEST_TEMPLATE.md`](.github/PULL_REQUEST_TEMPLATE.md)) in full — all checkboxes must be present. +- Descriptions should be short, clear, and human-readable. No AI-generated explanations in the AI disclosure section. +- Each PR gets **one changeset line** (or one `fix:` + one `feat:` if both are genuinely present, though prefer separate PRs). +- PRs must not target `dev` directly without a reviewed branch. +- Before opening a PR, **search for related open and merged PRs on both `upstream` (SableClient/Sable or cinnyapp/cinny) and `origin`**. Review them to understand what else may be in flight that could affect the change. Summarise any findings and ask the user how to proceed if there is overlap or conflict. +- Before opening a PR, **search for related open issues on both `upstream` and `origin`**. If any are related, prompt the user to confirm, then link them in the PR description (`Closes #N` / `Related to #N`). +- If the PR has a corresponding `SableClient/docs` PR, link both PRs to each other in their descriptions. + +## Matrix Spec Compliance + +- New features and fixes must match the **current Matrix spec** or the relevant **MSC** if the spec change is pending. +- Check how **Element Web**, **FluffyChat**, or **Nheko** implement the same thing before diverging from established client patterns. +- Link the relevant spec section or MSC in the PR description when the change is spec-driven. + +## Feature Flags + +- Every user-visible new feature must be gated behind a **feature flag** in `config.json` / `useClientConfig`. +- Flags default to `false` (opt-in) unless the feature is a bug fix or a non-breaking improvement with no regressions. +- Document the flag in `docs/sample.env` and in the Sable-Docs documentation repo. + +## Code Quality + +- Code must follow **TypeScript/React best practices**: functional components, hooks, no class components, proper dependency arrays on `useEffect`/`useCallback`/`useMemo`. +- No `any` casts without a comment explaining why it's unavoidable. +- Comments must be **short and purposeful** — explain *why*, not *what*. No decorative separator lines (`//------`), no block comments restating the code. +- Do not add docstrings, comments, or type annotations to code that wasn't changed in the current task. +- Prefer explicit types over inferred types for public function signatures. + +## Documentation + +- When a new feature is added (or an existing one materially changed), **update the Sable-Docs repo** (`/Users/evie/git/Sable-Docs`). Add or update the relevant page under `content/features/` or `content/general/`. +- Keep docs concise — match the style of existing pages. + +## Security + +- Follow OWASP Top 10 guidance. No `innerHTML`, no `eval`, sanitise all user/Matrix-sourced content before rendering. +- Do not log or expose access tokens, room keys, or other secrets. +- Content Security Policy headers (Caddyfile / Dockerfile) must not be weakened without a documented reason. + +## Additional Rules + +- **No over-engineering**: only make changes directly requested or clearly necessary. Don't add abstractions for one-off operations. +- **Reversible actions only**: ask before deleting files/branches, force-pushing, or dropping data. +- **Dependency changes** (adding/removing packages) require explicit confirmation before running `pnpm install`. +- When resolving merge conflicts, prefer the version from the feature branch; ask if the intent is ambiguous. +- Test files live alongside source in `src/` (e.g. `*.test.ts`). Match the naming convention of existing tests. +- **Write tests when needed**: any new utility function, hook, or non-trivial logic should have a corresponding Vitest test. Bug fixes should include a regression test where feasible. From 51301c10522d29f9473db24ee8a8648fac942513 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 29 Mar 2026 12:29:40 -0400 Subject: [PATCH 25/69] chore(config): remove devcontainer (setup didn't work out) --- .devcontainer/devcontainer.json | 90 --------------------------------- .devcontainer/on-create.sh | 19 ------- .devcontainer/post-create.sh | 72 -------------------------- .devcontainer/post-start.sh | 50 ------------------ .devcontainer/setup-signing.sh | 81 ----------------------------- .devcontainer/update-content.sh | 19 ------- 6 files changed, 331 deletions(-) delete mode 100644 .devcontainer/devcontainer.json delete mode 100755 .devcontainer/on-create.sh delete mode 100755 .devcontainer/post-create.sh delete mode 100755 .devcontainer/post-start.sh delete mode 100755 .devcontainer/setup-signing.sh delete mode 100755 .devcontainer/update-content.sh diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json deleted file mode 100644 index c1ffa7c9e..000000000 --- a/.devcontainer/devcontainer.json +++ /dev/null @@ -1,90 +0,0 @@ -{ - "name": "Sable", - "image": "mcr.microsoft.com/devcontainers/javascript-node:24-bookworm", - - // Minimum 4 cores / 8 GB RAM so Vite builds and TypeScript checks don't crawl - "hostRequirements": { - "cpus": 4, - "memory": "8gb", - "storage": "32gb" - }, - - "features": { - // GitHub CLI for PR/issue/fork management - "ghcr.io/devcontainers/features/github-cli:1": {} - }, - - // Expose Vite dev server and Zola docs preview - "forwardPorts": [5173, 8080, 1111], - "portsAttributes": { - "5173": { "label": "Vite Dev Server", "onAutoForward": "notify" }, - "8080": { "label": "App Preview", "onAutoForward": "notify" }, - "1111": { "label": "Docs Preview (Zola)", "onAutoForward": "notify" } - }, - - // Open the multi-root workspace covering both Sable + Sable-Docs - "workspaceFile": "${localWorkspaceFolder}/sable.code-workspace", - - "customizations": { - "vscode": { - "extensions": [ - // JS/TS toolchain - "dbaeumer.vscode-eslint", - "esbenp.prettier-vscode", - "webpro.vscode-knip", - "ms-vscode.vscode-typescript-next", - "usernamehw.errorlens", - "christian-kohler.path-intellisense", - "styled-components.vscode-styled-components", - "bradlc.vscode-tailwindcss", - // React/TypeScript - "dsznajder.es7-react-js-snippets", - "formulahendry.auto-rename-tag", - "wix.vscode-import-cost", - // i18n - "lokalise.i18n-ally", - // Testing - "vitest.explorer", - // Git & GitHub - "github.vscode-pull-request-github", - "eamodio.gitlens", - // Infrastructure - "hashicorp.terraform", - "zamerick.vscode-caddyfile-syntax", - // Docs (Zola / TOML / Markdown) - "tamasfe.even-better-toml", - "yzhang.markdown-all-in-one", - "eliostruyf.vscode-front-matter", - "streetsidesoftware.code-spell-checker", - "davidanson.vscode-markdownlint", - // Misc - "EditorConfig.EditorConfig", - "gruntfuggly.todo-tree", - "wayou.vscode-todo-highlight" - ], - "settings": { - "editor.formatOnSave": true, - "editor.defaultFormatter": "esbenp.prettier-vscode", - "typescript.tsdk": "node_modules/typescript/lib", - "[jsonc]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, - "[json]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, - "[toml]": { "editor.defaultFormatter": "tamasfe.even-better-toml" }, - "git.autofetch": true, - "terminal.integrated.defaultProfile.linux": "bash" - } - } - }, - - // ── Lifecycle hooks ──────────────────────────────────────────────────────── - // on-create : runs ONCE when the prebuild image is first built (cached) - // update-content: re-runs on each prebuild refresh & new codespace create (cached) - // post-create : runs once on each new codespace (not cached) – user-specific setup - // post-start : runs on EVERY codespace start (fetch upstream, signing check) - - "onCreateCommand": "bash .devcontainer/on-create.sh", - "updateContentCommand": "bash .devcontainer/update-content.sh", - "postCreateCommand": "bash .devcontainer/post-create.sh", - "postStartCommand": "bash .devcontainer/post-start.sh", - - "remoteUser": "node" -} diff --git a/.devcontainer/on-create.sh b/.devcontainer/on-create.sh deleted file mode 100755 index 7f6f789d8..000000000 --- a/.devcontainer/on-create.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env bash -# on-create.sh — runs ONCE when the prebuild image is first built -# Everything here is cached between prebuild refreshes. -set -euo pipefail - -echo "==> [on-create] Enabling corepack (pnpm)..." -sudo corepack enable -corepack prepare pnpm@latest --activate - -echo "==> [on-create] Configuring pnpm global store..." -pnpm config set store-dir /home/node/.local/share/pnpm/store - -echo "==> [on-create] Installing Zola (for Sable-Docs preview)..." -ZOLA_VERSION="0.19.2" -ZOLA_URL="https://github.com/getzola/zola/releases/download/v${ZOLA_VERSION}/zola-v${ZOLA_VERSION}-x86_64-unknown-linux-gnu.tar.gz" -curl -fsSL "$ZOLA_URL" | sudo tar xz -C /usr/local/bin -zola --version - -echo "==> [on-create] Done." diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh deleted file mode 100755 index 4f2ef27a1..000000000 --- a/.devcontainer/post-create.sh +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env bash -# post-create.sh — runs ONCE per new codespace (not cached in prebuild). -# Handles user-specific git setup: remotes, branches, signing. -set -euo pipefail - -SABLE_DIR="/workspaces/Sable" -DOCS_DIR="/workspaces/Sable-Docs" - -# ── 1. Upstream remotes ─────────────────────────────────────────────────────── -echo "==> [post-create] Configuring upstream remotes..." - -# Sable: fork = origin (Just-Insane/Sable), upstream = SableClient/Sable -if ! git -C "$SABLE_DIR" remote | grep -q "^upstream$"; then - git -C "$SABLE_DIR" remote add upstream https://github.com/SableClient/Sable.git - echo " Added upstream → SableClient/Sable" -else - echo " upstream remote already set" -fi -git -C "$SABLE_DIR" fetch --all --quiet - -# Docs: fork = origin (Just-Insane/docs), upstream = SableClient/docs -if ! git -C "$DOCS_DIR" remote | grep -q "^upstream$"; then - git -C "$DOCS_DIR" remote add upstream https://github.com/SableClient/docs.git - echo " [docs] Added upstream → SableClient/docs" -else - echo " [docs] upstream remote already set" -fi -git -C "$DOCS_DIR" fetch --all --quiet - -# ── 2. Ensure required branches exist ──────────────────────────────────────── -echo "==> [post-create] Ensuring branches exist in Sable..." - -ensure_branch() { - local dir="$1" - local branch="$2" - local start_point="${3:-HEAD}" - if git -C "$dir" ls-remote --heads origin "$branch" | grep -q "$branch"; then - echo " Branch '$branch' already exists on origin, checking out..." - git -C "$dir" fetch origin "$branch" --quiet - if ! git -C "$dir" show-ref --quiet "refs/heads/$branch"; then - git -C "$dir" branch --track "$branch" "origin/$branch" - fi - else - echo " Creating branch '$branch' from $start_point and pushing to origin..." - git -C "$dir" checkout -b "$branch" "$start_point" 2>/dev/null || true - git -C "$dir" push -u origin "$branch" - fi -} - -# Switch back to integration after branch ops -CURRENT_BRANCH=$(git -C "$SABLE_DIR" rev-parse --abbrev-ref HEAD) - -ensure_branch "$SABLE_DIR" "integration" "upstream/dev" -ensure_branch "$SABLE_DIR" "personal/config" "integration" -ensure_branch "$DOCS_DIR" "integration" "upstream/main" - -# Return to whatever branch we were on -git -C "$SABLE_DIR" checkout "$CURRENT_BRANCH" 2>/dev/null || true - -# ── 3. Git signing (SSH via forwarded YubiKey) ──────────────────────────────── -echo "==> [post-create] Configuring SSH commit signing..." -bash "$SABLE_DIR/.devcontainer/setup-signing.sh" || true - -# ── 4. Install git hooks ────────────────────────────────────────────────────── -echo "==> [post-create] Installing git hooks..." -if [ -f "$SABLE_DIR/scripts/install-git-hooks.sh" ]; then - bash "$SABLE_DIR/scripts/install-git-hooks.sh" -fi - -echo "" -echo "==> [post-create] Done! Open sable.code-workspace for the multi-root view." -echo " Run '.devcontainer/setup-signing.sh' any time to reconfigure commit signing." diff --git a/.devcontainer/post-start.sh b/.devcontainer/post-start.sh deleted file mode 100755 index f2353f39f..000000000 --- a/.devcontainer/post-start.sh +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env bash -# post-start.sh — runs on EVERY codespace start. -# Fetches upstream changes and re-checks signing (agent may have changed). -set -euo pipefail - -SABLE_DIR="/workspaces/Sable" -DOCS_DIR="/workspaces/Sable-Docs" - -# ── Fetch upstream for both repos ──────────────────────────────────────────── -echo "==> [post-start] Fetching upstream..." -git -C "$SABLE_DIR" fetch upstream --quiet 2>/dev/null && echo " Sable upstream fetched" || echo " ⚠ Could not fetch Sable upstream" -git -C "$DOCS_DIR" fetch upstream --quiet 2>/dev/null && echo " Docs upstream fetched" || echo " ⚠ Could not fetch Docs upstream" - -# ── Show how far behind integration is from upstream/dev ───────────────────── -BEHIND=$(git -C "$SABLE_DIR" rev-list --count HEAD..upstream/dev 2>/dev/null || echo "?") -if [ "$BEHIND" != "0" ] && [ "$BEHIND" != "?" ]; then - echo "" - echo " ℹ Your current branch is $BEHIND commit(s) behind upstream/dev." - echo " To sync: git merge upstream/dev (or: git rebase upstream/dev)" -fi - -# ── Re-configure SSH signing if not already set (agent may now be available) ─ -CODESPACE_KEY="$HOME/.ssh/codespace_signing_ed25519" -if [ "$(git config --global gpg.format 2>/dev/null)" != "ssh" ]; then - bash "$SABLE_DIR/.devcontainer/setup-signing.sh" || true -else - # Verify the signing key is still accessible - CONFIGURED_KEY=$(git config --global user.signingkey 2>/dev/null || echo "") - if [ -n "$CONFIGURED_KEY" ]; then - # If it's a file path (MODE 2), check file exists - if [[ "$CONFIGURED_KEY" == /* ]]; then - if [ -f "$CONFIGURED_KEY" ]; then - echo " ✓ Commit signing ready (private key file)" - else - echo " ⚠ Signing key file not found: $CONFIGURED_KEY" - echo " Re-run: bash .devcontainer/setup-signing.sh" - fi - # If it's a public key string (MODE 1), check agent - else - if ssh-add -L 2>/dev/null | grep -qF "$CONFIGURED_KEY"; then - echo " ✓ Commit signing ready (forwarded agent)" - else - echo " ⚠ Signing key not in SSH agent. YubiKey present?" - echo " Re-run: bash .devcontainer/setup-signing.sh" - fi - fi - fi -fi - -echo "" diff --git a/.devcontainer/setup-signing.sh b/.devcontainer/setup-signing.sh deleted file mode 100755 index ca5866095..000000000 --- a/.devcontainer/setup-signing.sh +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env bash -# setup-signing.sh — configures SSH commit signing. -# Supports two modes: -# 1. Forwarded SSH agent (VS Code desktop + YubiKey) -# 2. Codespace-local SSH key (browser/web Codespaces) -# Safe to re-run at any time. -set -euo pipefail - -SABLE_DIR="/workspaces/Sable" -ALLOWED_SIGNERS_FILE="$HOME/.config/git/allowed_signers" -CODESPACE_KEY="$HOME/.ssh/codespace_signing_ed25519" - -# ── MODE 1: Forwarded SSH agent (desktop VS Code) ──────────────────────────── -if ssh-add -L &>/dev/null && [ -n "$(ssh-add -L 2>/dev/null)" ]; then - echo "✓ Detected forwarded SSH agent (desktop VS Code + YubiKey mode)" - SIGNING_KEY=$(ssh-add -L | head -1) - KEY_COMMENT=$(echo "$SIGNING_KEY" | awk '{print $NF}') - echo " Using key: ...${KEY_COMMENT}" - -# ── MODE 2: Codespace-local key (web Codespaces) ───────────────────────────── -else - echo "ℹ No forwarded agent (web Codespace mode)" - - if [ ! -f "$CODESPACE_KEY" ]; then - echo " Generating new Ed25519 signing key..." - mkdir -p "$HOME/.ssh" - ssh-keygen -t ed25519 -f "$CODESPACE_KEY" -N "" -C "codespace-signing@$(hostname)" - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo " 🔑 Add this PUBLIC KEY to GitHub as a SIGNING key:" - echo "" - cat "${CODESPACE_KEY}.pub" - echo "" - echo " 👉 https://github.com/settings/keys → New SSH key" - echo " Title: Codespace Signing Key" - echo " Key type: Signing Key" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - read -p "Press Enter after adding the key to GitHub..." - fi - - # Use the private key file directly (git supports this without ssh-agent) - SIGNING_KEY="$CODESPACE_KEY" - echo " Using Codespace key: ${CODESPACE_KEY}" -fi - -# ── Common: Configure git ──────────────────────────────────────────────────── -git config --global gpg.format ssh -git config --global user.signingkey "$SIGNING_KEY" -git config --global commit.gpgsign true -git config --global tag.gpgsign true - -# Set up allowed_signers for local verification -USER_EMAIL=$(git config --global user.email 2>/dev/null || echo "") -if [ -n "$USER_EMAIL" ]; then - mkdir -p "$(dirname "$ALLOWED_SIGNERS_FILE")" - if [ -f "$ALLOWED_SIGNERS_FILE" ]; then - grep -v "^$USER_EMAIL " "$ALLOWED_SIGNERS_FILE" > "${ALLOWED_SIGNERS_FILE}.tmp" || true - mv "${ALLOWED_SIGNERS_FILE}.tmp" "$ALLOWED_SIGNERS_FILE" - fi - - # For allowed_signers, always use the public key (even if signing with private key file) - if [ -f "$CODESPACE_KEY" ]; then - # MODE 2: read public key from file - PUBLIC_KEY=$(cat "${CODESPACE_KEY}.pub") - else - # MODE 1: already have public key in $SIGNING_KEY - PUBLIC_KEY="$SIGNING_KEY" - fi - - echo "$USER_EMAIL namespaces=\"git\" $PUBLIC_KEY" >> "$ALLOWED_SIGNERS_FILE" - git config --global gpg.ssh.allowedSignersFile "$ALLOWED_SIGNERS_FILE" - echo "✓ SSH commit signing configured for <$USER_EMAIL>" -else - echo "⚠ user.email not set globally. Run: git config --global user.email 'you@example.com'" - echo " Then re-run: bash .devcontainer/setup-signing.sh" -fi - -echo "" -echo "Test signing: git commit --allow-empty -m 'test signing'" -echo "Verify: git log --show-signature -1" \ No newline at end of file diff --git a/.devcontainer/update-content.sh b/.devcontainer/update-content.sh deleted file mode 100755 index 572ae73ba..000000000 --- a/.devcontainer/update-content.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env bash -# update-content.sh — runs on each prebuild refresh AND on new codespace creation. -# The resulting filesystem state is cached in the prebuild snapshot. -set -euo pipefail - -echo "==> [update-content] Installing Sable dependencies (pnpm install)..." -pnpm install --frozen-lockfile - -echo "==> [update-content] Cloning / updating Sable-Docs..." -DOCS_DIR="/workspaces/Sable-Docs" -if [ -d "$DOCS_DIR/.git" ]; then - echo " Docs already present, fetching latest..." - git -C "$DOCS_DIR" fetch --all -else - echo " Cloning Just-Insane/docs → $DOCS_DIR" - git clone https://github.com/Just-Insane/docs "$DOCS_DIR" -fi - -echo "==> [update-content] Done." From f25eb50fa3adb8925d85d71a1e69f67331a206af Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 29 Mar 2026 17:25:18 -0400 Subject: [PATCH 26/69] Revise GitHub Copilot workspace instructions Updated instructions for pull requests and feature flags. --- .github/copilot-instructions.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 882847e80..1b7ec036c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,6 +1,6 @@ # Sable – GitHub Copilot Workspace Instructions -These rules apply to every chat and agent session in this workspace. +These rules apply to every chat and agent session in this workspace. Follow all rules that follow while responding to chat requests. --- @@ -34,7 +34,7 @@ pnpm build ## Pull Requests - Use the upstream PR template ([`.github/PULL_REQUEST_TEMPLATE.md`](.github/PULL_REQUEST_TEMPLATE.md)) in full — all checkboxes must be present. -- Descriptions should be short, clear, and human-readable. No AI-generated explanations in the AI disclosure section. +- Descriptions should be short, clear, and human-readable. - Each PR gets **one changeset line** (or one `fix:` + one `feat:` if both are genuinely present, though prefer separate PRs). - PRs must not target `dev` directly without a reviewed branch. - Before opening a PR, **search for related open and merged PRs on both `upstream` (SableClient/Sable or cinnyapp/cinny) and `origin`**. Review them to understand what else may be in flight that could affect the change. Summarise any findings and ask the user how to proceed if there is overlap or conflict. @@ -51,7 +51,7 @@ pnpm build - Every user-visible new feature must be gated behind a **feature flag** in `config.json` / `useClientConfig`. - Flags default to `false` (opt-in) unless the feature is a bug fix or a non-breaking improvement with no regressions. -- Document the flag in `docs/sample.env` and in the Sable-Docs documentation repo. +- Document the flag in `config.json` and in the Sable-Docs documentation repo. ## Code Quality From a9605691231996d25918c6b2e3a6699a0fbf89c1 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 29 Mar 2026 18:07:14 -0400 Subject: [PATCH 27/69] Update branching instructions for syncing with upstream Added instructions for syncing branches before creating a new branch. --- .github/copilot-instructions.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 1b7ec036c..10a5a8b50 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -7,6 +7,7 @@ These rules apply to every chat and agent session in this workspace. Follow all ## Git & Branching - **Never commit directly to `dev` or `integration`.** All work goes on a dedicated branch (`fix/…`, `feat/…`, `chore/…`, etc.). + - When creating a branch, always sync `upstream/dev` to `origin/dev` and `dev`, and build the branch from `dev` - Before building `integration`, always **force-update `dev` from `upstream/dev`**: ``` git fetch upstream && git checkout dev && git reset --hard upstream/dev From d9f52877c19dcfd058587f24c1915aa5162d7d4e Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 30 Mar 2026 13:29:02 -0400 Subject: [PATCH 28/69] Revise instructions for clarity and consistency Updated wording for clarity and consistency in instructions. --- .github/copilot-instructions.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 10a5a8b50..e23b792a5 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,13 +1,13 @@ # Sable – GitHub Copilot Workspace Instructions -These rules apply to every chat and agent session in this workspace. Follow all rules that follow while responding to chat requests. +These rules apply to every chat and agent session in this workspace. Follow all instructions below while responding to chat requests. --- ## Git & Branching - **Never commit directly to `dev` or `integration`.** All work goes on a dedicated branch (`fix/…`, `feat/…`, `chore/…`, etc.). - - When creating a branch, always sync `upstream/dev` to `origin/dev` and `dev`, and build the branch from `dev` + - When creating a branch, always sync `upstream/dev` to `origin/dev` and `dev`, and then build the branch from `dev` - Before building `integration`, always **force-update `dev` from `upstream/dev`**: ``` git fetch upstream && git checkout dev && git reset --hard upstream/dev From 0d160920c9c6a823361bb7404e16b119c3da9e82 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 30 Mar 2026 13:32:23 -0400 Subject: [PATCH 29/69] Move `copilot-instructions.md` to correct location --- .github/copilot-instructions.md => copilot-instructions.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/copilot-instructions.md => copilot-instructions.md (100%) diff --git a/.github/copilot-instructions.md b/copilot-instructions.md similarity index 100% rename from .github/copilot-instructions.md rename to copilot-instructions.md From 0fc97504a086d6c8082176e71a9978ecd36bf0e8 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 30 Mar 2026 13:41:11 -0400 Subject: [PATCH 30/69] Clarify branch creation and PR instructions --- copilot-instructions.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/copilot-instructions.md b/copilot-instructions.md index e23b792a5..bf4716e92 100644 --- a/copilot-instructions.md +++ b/copilot-instructions.md @@ -7,12 +7,12 @@ These rules apply to every chat and agent session in this workspace. Follow all ## Git & Branching - **Never commit directly to `dev` or `integration`.** All work goes on a dedicated branch (`fix/…`, `feat/…`, `chore/…`, etc.). - - When creating a branch, always sync `upstream/dev` to `origin/dev` and `dev`, and then build the branch from `dev` + - When creating a branch (i.e. if a branch for the requested change doesn't exist or there isn't an existing branch that fits), always sync `upstream/dev` to `origin/dev` and `dev`, and then build the branch from `dev` - Before building `integration`, always **force-update `dev` from `upstream/dev`**: ``` git fetch upstream && git checkout dev && git reset --hard upstream/dev ``` -- When asked to build `integration`, **always prompt for which feature/fix branches to include**. If a needed branch doesn't exist yet, create it first. +- When asked to build `integration`, **always prompt for which feature/fix branches to include**. In general, all feat/fix/chore/etc branches should be inlcuded. - Use short, scoped commit messages: `type(scope): description` (e.g. `fix(timeline): correct scroll anchor on bulk load`). ## Quality Gates (must pass before every commit) @@ -34,10 +34,9 @@ pnpm build ## Pull Requests -- Use the upstream PR template ([`.github/PULL_REQUEST_TEMPLATE.md`](.github/PULL_REQUEST_TEMPLATE.md)) in full — all checkboxes must be present. +- Use the upstream PR template (i.e. [`.github/PULL_REQUEST_TEMPLATE.md`](.github/PULL_REQUEST_TEMPLATE.md)) in full — all checkboxes must be present. - Descriptions should be short, clear, and human-readable. - Each PR gets **one changeset line** (or one `fix:` + one `feat:` if both are genuinely present, though prefer separate PRs). -- PRs must not target `dev` directly without a reviewed branch. - Before opening a PR, **search for related open and merged PRs on both `upstream` (SableClient/Sable or cinnyapp/cinny) and `origin`**. Review them to understand what else may be in flight that could affect the change. Summarise any findings and ask the user how to proceed if there is overlap or conflict. - Before opening a PR, **search for related open issues on both `upstream` and `origin`**. If any are related, prompt the user to confirm, then link them in the PR description (`Closes #N` / `Related to #N`). - If the PR has a corresponding `SableClient/docs` PR, link both PRs to each other in their descriptions. @@ -60,6 +59,7 @@ pnpm build - No `any` casts without a comment explaining why it's unavoidable. - Comments must be **short and purposeful** — explain *why*, not *what*. No decorative separator lines (`//------`), no block comments restating the code. - Do not add docstrings, comments, or type annotations to code that wasn't changed in the current task. +- Add concise docstrings, comments, and/or type annotations on updating/new code in the current task. - Prefer explicit types over inferred types for public function signatures. ## Documentation From d6c2e403370a55a99519c28bae9529621a466dcf Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 30 Mar 2026 15:22:58 -0400 Subject: [PATCH 31/69] Docs have this location too... --- copilot-instructions.md => .github/copilot-instructions.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename copilot-instructions.md => .github/copilot-instructions.md (100%) diff --git a/copilot-instructions.md b/.github/copilot-instructions.md similarity index 100% rename from copilot-instructions.md rename to .github/copilot-instructions.md From e3c222005763d4b791b9e1e9644828abd59379ed Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 31 Mar 2026 10:55:38 -0400 Subject: [PATCH 32/69] chore(config): split copilot-instructions into scoped instruction files and AGENTS.md --- .github/copilot-instructions.md | 85 ++----------------- .github/instructions/security.instructions.md | 10 +++ .../instructions/typescript.instructions.md | 29 +++++++ AGENTS.md | 73 ++++++++++++++++ 4 files changed, 121 insertions(+), 76 deletions(-) create mode 100644 .github/instructions/security.instructions.md create mode 100644 .github/instructions/typescript.instructions.md create mode 100644 AGENTS.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index bf4716e92..401bc55cb 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,83 +1,16 @@ -# Sable – GitHub Copilot Workspace Instructions +# Sable – GitHub Copilot Instructions -These rules apply to every chat and agent session in this workspace. Follow all instructions below while responding to chat requests. +Universal rules that apply to every session. Detailed guidance lives in `.github/instructions/` and `AGENTS.md`. ---- - -## Git & Branching +## Core Rules - **Never commit directly to `dev` or `integration`.** All work goes on a dedicated branch (`fix/…`, `feat/…`, `chore/…`, etc.). - - When creating a branch (i.e. if a branch for the requested change doesn't exist or there isn't an existing branch that fits), always sync `upstream/dev` to `origin/dev` and `dev`, and then build the branch from `dev` -- Before building `integration`, always **force-update `dev` from `upstream/dev`**: +- Use short, scoped commit messages: `type(scope): description` (e.g. `fix(timeline): correct scroll anchor on bulk load`). +- Run quality gates in order and fix all failures before committing: ``` - git fetch upstream && git checkout dev && git reset --hard upstream/dev + pnpm lint && pnpm fmt:check && pnpm typecheck && pnpm test:run && pnpm knip && pnpm build ``` -- When asked to build `integration`, **always prompt for which feature/fix branches to include**. In general, all feat/fix/chore/etc branches should be inlcuded. -- Use short, scoped commit messages: `type(scope): description` (e.g. `fix(timeline): correct scroll anchor on bulk load`). - -## Quality Gates (must pass before every commit) - -Run these in order and fix all failures before committing: - -``` -pnpm lint # ESLint -pnpm fmt:check # Prettier -pnpm typecheck # TypeScript -pnpm test:run # Vitest unit tests -pnpm knip # Dead-code / unused exports check -``` - -Also run a **production build** and confirm it succeeds with no errors: -``` -pnpm build -``` - -## Pull Requests - -- Use the upstream PR template (i.e. [`.github/PULL_REQUEST_TEMPLATE.md`](.github/PULL_REQUEST_TEMPLATE.md)) in full — all checkboxes must be present. -- Descriptions should be short, clear, and human-readable. -- Each PR gets **one changeset line** (or one `fix:` + one `feat:` if both are genuinely present, though prefer separate PRs). -- Before opening a PR, **search for related open and merged PRs on both `upstream` (SableClient/Sable or cinnyapp/cinny) and `origin`**. Review them to understand what else may be in flight that could affect the change. Summarise any findings and ask the user how to proceed if there is overlap or conflict. -- Before opening a PR, **search for related open issues on both `upstream` and `origin`**. If any are related, prompt the user to confirm, then link them in the PR description (`Closes #N` / `Related to #N`). -- If the PR has a corresponding `SableClient/docs` PR, link both PRs to each other in their descriptions. - -## Matrix Spec Compliance - -- New features and fixes must match the **current Matrix spec** or the relevant **MSC** if the spec change is pending. -- Check how **Element Web**, **FluffyChat**, or **Nheko** implement the same thing before diverging from established client patterns. -- Link the relevant spec section or MSC in the PR description when the change is spec-driven. - -## Feature Flags - -- Every user-visible new feature must be gated behind a **feature flag** in `config.json` / `useClientConfig`. -- Flags default to `false` (opt-in) unless the feature is a bug fix or a non-breaking improvement with no regressions. -- Document the flag in `config.json` and in the Sable-Docs documentation repo. - -## Code Quality - -- Code must follow **TypeScript/React best practices**: functional components, hooks, no class components, proper dependency arrays on `useEffect`/`useCallback`/`useMemo`. -- No `any` casts without a comment explaining why it's unavoidable. -- Comments must be **short and purposeful** — explain *why*, not *what*. No decorative separator lines (`//------`), no block comments restating the code. -- Do not add docstrings, comments, or type annotations to code that wasn't changed in the current task. -- Add concise docstrings, comments, and/or type annotations on updating/new code in the current task. -- Prefer explicit types over inferred types for public function signatures. - -## Documentation - -- When a new feature is added (or an existing one materially changed), **update the Sable-Docs repo** (`/Users/evie/git/Sable-Docs`). Add or update the relevant page under `content/features/` or `content/general/`. -- Keep docs concise — match the style of existing pages. - -## Security - -- Follow OWASP Top 10 guidance. No `innerHTML`, no `eval`, sanitise all user/Matrix-sourced content before rendering. -- Do not log or expose access tokens, room keys, or other secrets. -- Content Security Policy headers (Caddyfile / Dockerfile) must not be weakened without a documented reason. - -## Additional Rules - +- No `any` casts without an inline comment explaining why it's unavoidable. - **No over-engineering**: only make changes directly requested or clearly necessary. Don't add abstractions for one-off operations. -- **Reversible actions only**: ask before deleting files/branches, force-pushing, or dropping data. -- **Dependency changes** (adding/removing packages) require explicit confirmation before running `pnpm install`. -- When resolving merge conflicts, prefer the version from the feature branch; ask if the intent is ambiguous. -- Test files live alongside source in `src/` (e.g. `*.test.ts`). Match the naming convention of existing tests. -- **Write tests when needed**: any new utility function, hook, or non-trivial logic should have a corresponding Vitest test. Bug fixes should include a regression test where feasible. +- **Reversible actions only**: ask before deleting files/branches, force-pushing, dropping data, or running `pnpm install` to add/remove packages. +- Do not log or expose access tokens, room keys, or other secrets. diff --git a/.github/instructions/security.instructions.md b/.github/instructions/security.instructions.md new file mode 100644 index 000000000..9586e7e1f --- /dev/null +++ b/.github/instructions/security.instructions.md @@ -0,0 +1,10 @@ +--- +applyTo: "src/**,Caddyfile,Dockerfile" +--- + +## Security + +- Follow OWASP Top 10 guidance. +- No `innerHTML`, no `eval`; sanitise all user-supplied and Matrix-sourced content before rendering. +- Do not log or expose access tokens, room keys, or other secrets. +- Content Security Policy headers in `Caddyfile` and `Dockerfile` must not be weakened without a documented reason. diff --git a/.github/instructions/typescript.instructions.md b/.github/instructions/typescript.instructions.md new file mode 100644 index 000000000..4ea1a1ac3 --- /dev/null +++ b/.github/instructions/typescript.instructions.md @@ -0,0 +1,29 @@ +--- +applyTo: "src/**" +--- + +## TypeScript & React + +- Functional components and hooks only. No class components. +- Proper dependency arrays on `useEffect`, `useCallback`, and `useMemo`. +- Prefer explicit types over inferred types for public/exported function signatures. +- No `any` casts without an inline comment explaining why it's unavoidable. + +## Comments & Documentation + +- Comments must be **short and purposeful** — explain *why*, not *what*. +- No decorative separator lines (`//------`), no block comments restating the code. +- Do not add docstrings, comments, or type annotations to code that was not changed in the current task. +- Add concise docstrings, comments, and/or type annotations to new or updated code. + +## Testing + +- Test files live alongside source in `src/` (e.g. `foo.test.ts`). Match the naming convention of existing tests. +- Write Vitest tests for any new utility function, hook, or non-trivial logic. +- Bug fixes should include a regression test where feasible. + +## Feature Flags + +- Every user-visible new feature must be gated behind a feature flag in `config.json` / `useClientConfig`. +- Flags default to `false` (opt-in) unless the feature is a bug fix or a non-breaking improvement with no regressions. +- Document the flag in `config.json` and in the Sable-Docs documentation repo. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..62c3f8d99 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,73 @@ +# Sable – Agent Instructions + +Workflow and process rules for AI agents. These complement the universal rules in `.github/copilot-instructions.md`. + +--- + +## Git & Branching + +- Never commit directly to `dev` or `integration`. +- When creating a branch, first sync `upstream/dev` to `origin/dev` and local `dev`, then branch from `dev`: + ``` + git fetch upstream + git checkout dev && git reset --hard upstream/dev + git push origin dev + git checkout -b feat/your-branch dev + ``` +- Before building `integration`, always force-update `dev` from `upstream/dev`: + ``` + git fetch upstream && git checkout dev && git reset --hard upstream/dev + ``` +- When asked to build `integration`, always prompt for which feature/fix/chore branches to include. In general, include all non-`dev` branches. + +## Quality Gates + +Run these in order and fix all failures before committing: + +``` +pnpm lint # ESLint +pnpm fmt:check # Prettier +pnpm typecheck # TypeScript +pnpm test:run # Vitest unit tests +pnpm knip # Dead-code / unused exports check +pnpm build # Production build — must succeed with no errors +``` + +## Pull Requests + +- Use the PR template (`.github/PULL_REQUEST_TEMPLATE.md`) in full — all checkboxes must be present. +- Descriptions should be short, clear, and human-readable. +- Each PR gets one changeset line (or `fix:` + `feat:` if both are genuinely present; prefer separate PRs otherwise). + +### Pre-PR Research + +1. Search for related open **and** merged PRs on `upstream` (`SableClient/Sable` and `cinnyapp/cinny`) and `origin`. Summarise findings and ask how to proceed if there is overlap or conflict. +2. Search for related open **issues** on `upstream` and `origin`. Confirm with the user, then link any related ones in the PR description (`Closes #N` / `Related to #N`). +3. If the PR has a corresponding `SableClient/docs` PR, link both PRs to each other. + +## Matrix Spec Compliance + +- New features and fixes must match the current Matrix spec, or the relevant MSC if the spec change is pending. +- Check how Element Web, FluffyChat, or Nheko implement the same thing before diverging from established client patterns. +- Link the relevant spec section or MSC in the PR description when the change is spec-driven. + +## Documentation + +- When a new feature is added (or an existing one materially changed), update the Sable-Docs repo (`/Users/evie/git/Sable-Docs`). Add or update the relevant page under `content/features/` or `content/general/`. +- Keep docs concise — match the style of existing pages. + +## Dependency Changes + +- Adding or removing packages requires explicit user confirmation before running `pnpm install`. + +## Merge Conflicts + +- When resolving merge conflicts, prefer the version from the feature branch; ask if the intent is ambiguous. + +## Destructive Actions + +Always ask before: +- Deleting files or branches (`git branch -D`, `rm`, etc.) +- Force-pushing (`git push --force`) +- Hard-resetting local branches other than `dev`/`integration` (`git reset --hard`) +- Dropping or truncating data From 5abd3950e17e0f45c87bacea531254c39bb7c95e Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 31 Mar 2026 11:02:00 -0400 Subject: [PATCH 33/69] Update git instructions in AGENTS.md --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 62c3f8d99..266aa22d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ Workflow and process rules for AI agents. These complement the universal rules i ## Git & Branching - Never commit directly to `dev` or `integration`. -- When creating a branch, first sync `upstream/dev` to `origin/dev` and local `dev`, then branch from `dev`: +- When creating a branch, first sync `upstream/dev` to `origin/dev` and local `dev`, then branch from `dev`, with `origin/dev` as the remote: ``` git fetch upstream git checkout dev && git reset --hard upstream/dev From 3cb6c0561cc11f1fe08ab76f25e944206bdcf3b6 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 31 Mar 2026 11:42:00 -0400 Subject: [PATCH 34/69] Update git commands --- AGENTS.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 266aa22d2..c44ee7052 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,9 +14,9 @@ Workflow and process rules for AI agents. These complement the universal rules i git push origin dev git checkout -b feat/your-branch dev ``` -- Before building `integration`, always force-update `dev` from `upstream/dev`: +- Before building `integration`, always force-update `origin/dev` from `upstream/dev`, then force-update `dev`: ``` - git fetch upstream && git checkout dev && git reset --hard upstream/dev + git fetch upstream && git push origin upstream/dev:dev --force && git fetch origin && git checkout dev && git reset --hard origin/dev ``` - When asked to build `integration`, always prompt for which feature/fix/chore branches to include. In general, include all non-`dev` branches. @@ -67,6 +67,7 @@ pnpm build # Production build — must succeed with no errors ## Destructive Actions Always ask before: + - Deleting files or branches (`git branch -D`, `rm`, etc.) - Force-pushing (`git push --force`) - Hard-resetting local branches other than `dev`/`integration` (`git reset --hard`) From 2b60866b341b7c1694381c17fcc9c56c99c8580c Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 6 Apr 2026 19:28:26 +0000 Subject: [PATCH 35/69] chore(codespace): add devcontainer for iPad browser + SSH signing --- .devcontainer/devcontainer.json | 90 +++++++++++++++++++++++++++++++++ .devcontainer/postCreate.sh | 65 ++++++++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 .devcontainer/devcontainer.json create mode 100644 .devcontainer/postCreate.sh diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 000000000..30f6a2a33 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,90 @@ +// Codespace configuration — lives on personal/config (not ephemeral dev/feat branches). +// This file intentionally targets browser-based use on iPad. +{ + "name": "Sable", + "image": "mcr.microsoft.com/devcontainers/javascript-node:1-24-bookworm", + + "features": { + // Keep git up-to-date for SSH signing support (git ≥ 2.34). + "ghcr.io/devcontainers/features/git:1": {}, + "ghcr.io/devcontainers/features/github-cli:1": {} + }, + + // ── Codespace user secrets ────────────────────────────────────────────────── + // Configure these at: github.com/settings/codespaces > Secrets + // + // GIT_SIGNING_KEY — passphrase-free SSH private key (ed25519 recommended). + // Add the matching public key to your GitHub account as a + // "signing key": github.com/settings/keys + // postCreate.sh will wire up git automatically if set. + // + // GIT_USER_NAME — e.g. "Evie" + // GIT_USER_EMAIL — e.g. "you@example.com" + // ─────────────────────────────────────────────────────────────────────────── + + "remoteEnv": { + // Pin the pnpm store to a known path so the volume mount works across rebuilds. + "PNPM_STORE_DIR": "/home/node/.pnpm-store" + }, + + "customizations": { + "vscode": { + "settings": { + // ── Layout — tuned for iPad browser (vscode.dev / Codespaces web) ───── + // Move the activity bar to the top so it isn't hidden by the iOS Safari + // toolbar or the browser's combined title/status bar. + "workbench.activityBar.location": "top", + // Use a menu for the layout control — fewer tiny hit targets on touch. + "workbench.layoutControl.type": "menu", + // Place the panel (Terminal, Problems, Copilot Chat history) on the + // right so it doesn't fight with the keyboard on small screens. + "workbench.panel.defaultLocation": "right", + // Keep editor tabs visible and wrap them so none are hidden off-screen. + "workbench.editor.showTabs": "multiple", + "workbench.editor.wrapTabs": true, + // Disable minimap — saves horizontal space, improves touch accuracy. + "editor.minimap.enabled": false, + "editor.scrollBeyondLastLine": false, + // Larger default fonts for retina/HiDPI iPad displays. + "editor.fontSize": 14, + "terminal.integrated.fontSize": 14, + + // ── Git signing ─────────────────────────────────────────────────────── + // postCreate.sh configures gpg.format and user.signingkey if + // GIT_SIGNING_KEY secret is present. This just keeps VS Code's git + // UI in sync. + "git.enableCommitSigning": true, + "git.confirmSync": false, + + // ── Copilot Chat ────────────────────────────────────────────────────── + // Always show follow-ups and keep chat history accessible. + "github.copilot.chat.followUps": "always" + }, + "extensions": [ + "GitHub.copilot", + "GitHub.copilot-chat", + "esbenp.prettier-vscode", + "dbaeumer.vscode-eslint", + "vitest.explorer" + ] + } + }, + + // ── Port forwarding ───────────────────────────────────────────────────────── + "forwardPorts": [5173, 4173], + "portsAttributes": { + "5173": { "label": "Vite dev", "onAutoForward": "notify" }, + "4173": { "label": "Vite preview", "onAutoForward": "notify" } + }, + + // ── Persistence ───────────────────────────────────────────────────────────── + // Named volume keeps the pnpm content-addressable store across rebuilds. + // Combined with the PNPM_STORE_DIR env var above so postCreate can also + // point pnpm at the same path. + "mounts": [ + "source=sable-pnpm-store,target=/home/node/.pnpm-store,type=volume" + ], + + "postCreateCommand": "bash .devcontainer/postCreate.sh", + "remoteUser": "node" +} diff --git a/.devcontainer/postCreate.sh b/.devcontainer/postCreate.sh new file mode 100644 index 000000000..f6c539985 --- /dev/null +++ b/.devcontainer/postCreate.sh @@ -0,0 +1,65 @@ +#!/bin/bash +# postCreate.sh — runs once after the Codespace container is created. +set -euo pipefail + +# ── pnpm ────────────────────────────────────────────────────────────────────── +# Enable corepack so the exact pnpm version from package.json#packageManager is used. +corepack enable + +# Point pnpm at the persistent named-volume store so packages survive rebuilds. +if [ -n "${PNPM_STORE_DIR:-}" ]; then + pnpm config set store-dir "${PNPM_STORE_DIR}" +fi + +pnpm install + +# ── Git identity ────────────────────────────────────────────────────────────── +# Populate from Codespace user secrets if they aren't already set by dotfiles. +if [ -n "${GIT_USER_NAME:-}" ] && [ -z "$(git config --global user.name 2>/dev/null)" ]; then + git config --global user.name "${GIT_USER_NAME}" +fi + +if [ -n "${GIT_USER_EMAIL:-}" ] && [ -z "$(git config --global user.email 2>/dev/null)" ]; then + git config --global user.email "${GIT_USER_EMAIL}" +fi + +# ── Git SSH commit signing ──────────────────────────────────────────────────── +# Requires a Codespace user secret named GIT_SIGNING_KEY containing a +# passphrase-free SSH private key (ed25519 recommended). +# +# To set up: +# 1. Generate a key: ssh-keygen -t ed25519 -C "codespace signing" -N "" -f ~/.ssh/signing_key +# 2. Copy the private key into a GitHub Codespace secret called GIT_SIGNING_KEY: +# github.com/settings/codespaces > Secrets > New secret +# 3. Add the *public* key to your GitHub account as a signing key (not auth key): +# github.com/settings/keys > New signing key +# ---------------------------------------------------------------------------- +if [ -n "${GIT_SIGNING_KEY:-}" ]; then + SSH_DIR="${HOME}/.ssh" + mkdir -p "${SSH_DIR}" + chmod 700 "${SSH_DIR}" + + KEY_FILE="${SSH_DIR}/git_signing_key" + printf '%s\n' "${GIT_SIGNING_KEY}" > "${KEY_FILE}" + chmod 600 "${KEY_FILE}" + + # Derive the public key from the private key so the user only stores one secret. + ssh-keygen -y -f "${KEY_FILE}" > "${KEY_FILE}.pub" + chmod 644 "${KEY_FILE}.pub" + + # Configure git to use SSH signing. + git config --global gpg.format ssh + git config --global user.signingkey "${KEY_FILE}.pub" + git config --global commit.gpgsign true + git config --global tag.gpgsign true + + # Allow this key when verifying signatures locally. + ALLOWED_SIGNERS="${SSH_DIR}/allowed_signers" + EMAIL="$(git config --global user.email 2>/dev/null || echo "you@example.com")" + echo "${EMAIL} $(cat "${KEY_FILE}.pub")" > "${ALLOWED_SIGNERS}" + git config --global gpg.ssh.allowedSignersFile "${ALLOWED_SIGNERS}" + + echo "✓ Git SSH commit signing configured (${KEY_FILE}.pub)" +fi + +echo "✓ postCreate complete" From 5deb7cfc0a1b39b2f8bcdc6ef6e361765dd6ae06 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 6 Apr 2026 19:43:17 +0000 Subject: [PATCH 36/69] chore(codespace): add Fira Code font + ligatures --- .devcontainer/devcontainer.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 30f6a2a33..5a9c48160 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -47,7 +47,10 @@ "editor.scrollBeyondLastLine": false, // Larger default fonts for retina/HiDPI iPad displays. "editor.fontSize": 14, + "editor.fontFamily": "'Fira Code', 'Cascadia Code', Menlo, monospace", + "editor.fontLigatures": true, "terminal.integrated.fontSize": 14, + "terminal.integrated.fontFamily": "'Fira Code', 'Cascadia Code', Menlo, monospace", // ── Git signing ─────────────────────────────────────────────────────── // postCreate.sh configures gpg.format and user.signingkey if @@ -63,6 +66,7 @@ "extensions": [ "GitHub.copilot", "GitHub.copilot-chat", + "tonsky.font-fira-code", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint", "vitest.explorer" From 9ac1380cde20d8a58c4a50f690eb4648957b4351 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 6 Apr 2026 19:45:50 +0000 Subject: [PATCH 37/69] chore(codespace): split onCreate/postCreate for prebuild caching --- .devcontainer/devcontainer.json | 1 + .devcontainer/onCreate.sh | 16 ++++++++++++++++ .devcontainer/postCreate.sh | 14 ++------------ 3 files changed, 19 insertions(+), 12 deletions(-) create mode 100644 .devcontainer/onCreate.sh diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 5a9c48160..94864c6fb 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -90,5 +90,6 @@ ], "postCreateCommand": "bash .devcontainer/postCreate.sh", + "onCreateCommand": "bash .devcontainer/onCreate.sh", "remoteUser": "node" } diff --git a/.devcontainer/onCreate.sh b/.devcontainer/onCreate.sh new file mode 100644 index 000000000..dcb012cd0 --- /dev/null +++ b/.devcontainer/onCreate.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# onCreate.sh — runs during prebuild AND on first Codespace creation. +# No user secrets are available here — keep this purely about dependencies. +set -euo pipefail + +# Enable corepack so the exact pnpm version from package.json#packageManager is used. +corepack enable + +# Point pnpm at the persistent named-volume store so packages survive rebuilds. +if [ -n "${PNPM_STORE_DIR:-}" ]; then + pnpm config set store-dir "${PNPM_STORE_DIR}" +fi + +pnpm install + +echo "✓ onCreate complete" diff --git a/.devcontainer/postCreate.sh b/.devcontainer/postCreate.sh index f6c539985..1d76c8e70 100644 --- a/.devcontainer/postCreate.sh +++ b/.devcontainer/postCreate.sh @@ -1,18 +1,8 @@ #!/bin/bash -# postCreate.sh — runs once after the Codespace container is created. +# postCreate.sh — runs once after the Codespace container is created (NOT during prebuild). +# Secrets (GIT_SIGNING_KEY, GIT_USER_NAME, GIT_USER_EMAIL) are available here. set -euo pipefail -# ── pnpm ────────────────────────────────────────────────────────────────────── -# Enable corepack so the exact pnpm version from package.json#packageManager is used. -corepack enable - -# Point pnpm at the persistent named-volume store so packages survive rebuilds. -if [ -n "${PNPM_STORE_DIR:-}" ]; then - pnpm config set store-dir "${PNPM_STORE_DIR}" -fi - -pnpm install - # ── Git identity ────────────────────────────────────────────────────────────── # Populate from Codespace user secrets if they aren't already set by dotfiles. if [ -n "${GIT_USER_NAME:-}" ] && [ -z "$(git config --global user.name 2>/dev/null)" ]; then From 5c0ea1fd6b9b985caa4df80ba1113625f289a4b2 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 6 Apr 2026 19:58:56 +0000 Subject: [PATCH 38/69] chore(codespace): fix image tag, install OMZ+P10k, wire dotfiles bare-repo --- .devcontainer/devcontainer.json | 13 +++++++++---- .devcontainer/onCreate.sh | 26 ++++++++++++++++++++++++++ .devcontainer/postCreate.sh | 29 +++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 4 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 94864c6fb..f5223c3cb 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -2,9 +2,12 @@ // This file intentionally targets browser-based use on iPad. { "name": "Sable", - "image": "mcr.microsoft.com/devcontainers/javascript-node:1-24-bookworm", + // Using base + node feature instead of javascript-node: to avoid + // tag availability issues on newer Node versions. + "image": "mcr.microsoft.com/devcontainers/base:bookworm", "features": { + "ghcr.io/devcontainers/features/node:1": { "version": "24" }, // Keep git up-to-date for SSH signing support (git ≥ 2.34). "ghcr.io/devcontainers/features/git:1": {}, "ghcr.io/devcontainers/features/github-cli:1": {} @@ -24,7 +27,7 @@ "remoteEnv": { // Pin the pnpm store to a known path so the volume mount works across rebuilds. - "PNPM_STORE_DIR": "/home/node/.pnpm-store" + "PNPM_STORE_DIR": "/home/vscode/.pnpm-store" }, "customizations": { @@ -51,6 +54,8 @@ "editor.fontLigatures": true, "terminal.integrated.fontSize": 14, "terminal.integrated.fontFamily": "'Fira Code', 'Cascadia Code', Menlo, monospace", + // Use zsh (installed in onCreate) as the default terminal shell. + "terminal.integrated.defaultProfile.linux": "zsh", // ── Git signing ─────────────────────────────────────────────────────── // postCreate.sh configures gpg.format and user.signingkey if @@ -86,10 +91,10 @@ // Combined with the PNPM_STORE_DIR env var above so postCreate can also // point pnpm at the same path. "mounts": [ - "source=sable-pnpm-store,target=/home/node/.pnpm-store,type=volume" + "source=sable-pnpm-store,target=/home/vscode/.pnpm-store,type=volume" ], "postCreateCommand": "bash .devcontainer/postCreate.sh", "onCreateCommand": "bash .devcontainer/onCreate.sh", - "remoteUser": "node" + "remoteUser": "vscode" } diff --git a/.devcontainer/onCreate.sh b/.devcontainer/onCreate.sh index dcb012cd0..bc2d2a967 100644 --- a/.devcontainer/onCreate.sh +++ b/.devcontainer/onCreate.sh @@ -1,8 +1,10 @@ #!/bin/bash # onCreate.sh — runs during prebuild AND on first Codespace creation. # No user secrets are available here — keep this purely about dependencies. +# Everything here is cached in the prebuild snapshot. set -euo pipefail +# ── pnpm ────────────────────────────────────────────────────────────────────── # Enable corepack so the exact pnpm version from package.json#packageManager is used. corepack enable @@ -13,4 +15,28 @@ fi pnpm install +# ── Zsh + Oh My Zsh + Powerlevel10k ────────────────────────────────────────── +# Install these during prebuild so the first Codespace start is fast. +# The dotfiles checkout in postCreate.sh will provide .zshrc / .p10k.zsh. + +# Install zsh if not already present (base:bookworm ships it, but be safe). +if ! command -v zsh &>/dev/null; then + sudo apt-get update -qq && sudo apt-get install -y -qq zsh +fi + +# Install Oh My Zsh non-interactively (KEEP_ZSHRC=yes preserves any existing .zshrc). +if [ ! -d "${HOME}/.oh-my-zsh" ]; then + KEEP_ZSHRC=yes CHSH=no RUNZSH=no \ + sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" +fi + +# Install Powerlevel10k as an OMZ custom theme. +P10K_DIR="${ZSH_CUSTOM:-${HOME}/.oh-my-zsh/custom}/themes/powerlevel10k" +if [ ! -d "${P10K_DIR}" ]; then + git clone --depth=1 https://github.com/romkatv/powerlevel10k.git "${P10K_DIR}" +fi + +# Make zsh the default shell for this user. +sudo chsh -s "$(command -v zsh)" "$(whoami)" + echo "✓ onCreate complete" diff --git a/.devcontainer/postCreate.sh b/.devcontainer/postCreate.sh index 1d76c8e70..8d9a404f7 100644 --- a/.devcontainer/postCreate.sh +++ b/.devcontainer/postCreate.sh @@ -3,6 +3,35 @@ # Secrets (GIT_SIGNING_KEY, GIT_USER_NAME, GIT_USER_EMAIL) are available here. set -euo pipefail +# ── Dotfiles (bare git repo, MacStudio branch) ──────────────────────────────── +# The dotfiles repo uses the "bare repo in $HOME" pattern. +# We clone a specific branch so we get the VS Code / Codespace-aware config +# (e.g. the P10k instant-prompt guard for $TERM_PROGRAM == "vscode"). +DOTFILES_REPO="https://github.com/Just-Insane/dotfiles.git" +DOTFILES_BRANCH="MacStudio" +DOTFILES_DIR="${HOME}/.cfg" + +if [ ! -d "${DOTFILES_DIR}" ]; then + git clone --bare --branch "${DOTFILES_BRANCH}" "${DOTFILES_REPO}" "${DOTFILES_DIR}" + + # Check out dotfiles to $HOME. Use --force to overwrite any stub files + # created by the devcontainer (e.g. a default .bashrc). + git --git-dir="${DOTFILES_DIR}" --work-tree="${HOME}" checkout --force "${DOTFILES_BRANCH}" + + # Don't show untracked files (the whole home dir) in status. + git --git-dir="${DOTFILES_DIR}" --work-tree="${HOME}" \ + config --local status.showUntrackedFiles no + + echo "✓ Dotfiles checked out from ${DOTFILES_BRANCH}" +else + # Already exists (e.g. Codespace resumed) — just pull latest. + git --git-dir="${DOTFILES_DIR}" --work-tree="${HOME}" \ + fetch origin "${DOTFILES_BRANCH}" && \ + git --git-dir="${DOTFILES_DIR}" --work-tree="${HOME}" \ + checkout --force "${DOTFILES_BRANCH}" + echo "✓ Dotfiles updated" +fi + # ── Git identity ────────────────────────────────────────────────────────────── # Populate from Codespace user secrets if they aren't already set by dotfiles. if [ -n "${GIT_USER_NAME:-}" ] && [ -z "$(git config --global user.name 2>/dev/null)" ]; then From 199168cf9bb72b3544f58d0a481d66f0d0731cc6 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 6 Apr 2026 20:12:26 +0000 Subject: [PATCH 39/69] fix(codespace): suppress corepack download prompt, source nvm in onCreate --- .devcontainer/onCreate.sh | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.devcontainer/onCreate.sh b/.devcontainer/onCreate.sh index bc2d2a967..e06e627d7 100644 --- a/.devcontainer/onCreate.sh +++ b/.devcontainer/onCreate.sh @@ -4,7 +4,19 @@ # Everything here is cached in the prebuild snapshot. set -euo pipefail +# ── Ensure the node feature's PATH additions are active ────────────────────── +# The devcontainers node feature installs via nvm; source it so `node`/`pnpm` +# resolve correctly even in non-login, non-interactive shells. +export NVM_DIR="${NVM_DIR:-/usr/local/share/nvm}" +# shellcheck source=/dev/null +[ -s "${NVM_DIR}/nvm.sh" ] && source "${NVM_DIR}/nvm.sh" --no-use +# Activate the version pinned in .nvmrc / package.json engines. +nvm use 24 2>/dev/null || nvm use node + # ── pnpm ────────────────────────────────────────────────────────────────────── +# Suppress corepack's interactive download-confirmation prompt in CI/prebuild. +export COREPACK_ENABLE_DOWNLOAD_PROMPT=0 + # Enable corepack so the exact pnpm version from package.json#packageManager is used. corepack enable From dd8124fa3075cdc3629520c825f0238738ce98f0 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 6 Apr 2026 20:17:55 +0000 Subject: [PATCH 40/69] fix(codespace): chown pnpm store volume before writing --- .devcontainer/onCreate.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.devcontainer/onCreate.sh b/.devcontainer/onCreate.sh index e06e627d7..d9ac8e0c2 100644 --- a/.devcontainer/onCreate.sh +++ b/.devcontainer/onCreate.sh @@ -13,6 +13,12 @@ export NVM_DIR="${NVM_DIR:-/usr/local/share/nvm}" # Activate the version pinned in .nvmrc / package.json engines. nvm use 24 2>/dev/null || nvm use node +# ── Fix named-volume ownership ──────────────────────────────────────────────── +# Docker mounts named volumes as root; fix ownership so the vscode user can write. +if [ -d "${PNPM_STORE_DIR:-}" ]; then + sudo chown -R "$(id -u):$(id -g)" "${PNPM_STORE_DIR}" +fi + # ── pnpm ────────────────────────────────────────────────────────────────────── # Suppress corepack's interactive download-confirmation prompt in CI/prebuild. export COREPACK_ENABLE_DOWNLOAD_PROMPT=0 From 4a620d5dac3db2a98893732e6f05a5b8540da766 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 6 Apr 2026 21:26:03 +0000 Subject: [PATCH 41/69] chore(devcontainer): add tmux, fix terminal font, add GitHub MCP server --- .devcontainer/devcontainer.json | 2 +- .devcontainer/onCreate.sh | 6 +++--- .vscode/mcp.json | 10 ++++++++++ 3 files changed, 14 insertions(+), 4 deletions(-) create mode 100644 .vscode/mcp.json diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index f5223c3cb..e3fe567e9 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -53,7 +53,7 @@ "editor.fontFamily": "'Fira Code', 'Cascadia Code', Menlo, monospace", "editor.fontLigatures": true, "terminal.integrated.fontSize": 14, - "terminal.integrated.fontFamily": "'Fira Code', 'Cascadia Code', Menlo, monospace", + "terminal.integrated.fontFamily": "'MesloLGS NF', 'Fira Code', Menlo, monospace", // Use zsh (installed in onCreate) as the default terminal shell. "terminal.integrated.defaultProfile.linux": "zsh", diff --git a/.devcontainer/onCreate.sh b/.devcontainer/onCreate.sh index d9ac8e0c2..2f2943fa9 100644 --- a/.devcontainer/onCreate.sh +++ b/.devcontainer/onCreate.sh @@ -37,9 +37,9 @@ pnpm install # Install these during prebuild so the first Codespace start is fast. # The dotfiles checkout in postCreate.sh will provide .zshrc / .p10k.zsh. -# Install zsh if not already present (base:bookworm ships it, but be safe). -if ! command -v zsh &>/dev/null; then - sudo apt-get update -qq && sudo apt-get install -y -qq zsh +# Install zsh and tmux if not already present (base:bookworm ships zsh, but be safe). +if ! command -v zsh &>/dev/null || ! command -v tmux &>/dev/null; then + sudo apt-get update -qq && sudo apt-get install -y -qq zsh tmux fi # Install Oh My Zsh non-interactively (KEEP_ZSHRC=yes preserves any existing .zshrc). diff --git a/.vscode/mcp.json b/.vscode/mcp.json new file mode 100644 index 000000000..b2bc0a4e8 --- /dev/null +++ b/.vscode/mcp.json @@ -0,0 +1,10 @@ +{ + // GitHub MCP server — uses existing Copilot auth, no token prompt needed. + // Works in browser-based Codespaces (no vscode:// redirect required). + "servers": { + "github": { + "type": "http", + "url": "https://api.githubcopilot.com/mcp/" + } + } +} From 4565b0c3d77f32cc5da29455d81b85ff3d032fd0 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 6 Apr 2026 22:14:04 +0000 Subject: [PATCH 42/69] fix(devcontainer): use browser-safe font and compatible p10k glyphs for iPad --- .devcontainer/devcontainer.json | 4 +++- .devcontainer/postCreate.sh | 10 ++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index e3fe567e9..4f00813c1 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -53,7 +53,9 @@ "editor.fontFamily": "'Fira Code', 'Cascadia Code', Menlo, monospace", "editor.fontLigatures": true, "terminal.integrated.fontSize": 14, - "terminal.integrated.fontFamily": "'MesloLGS NF', 'Fira Code', Menlo, monospace", + // MesloLGS NF is a local system font — unavailable in the browser. + // Fira Code is loaded as a web font via the tonsky.font-fira-code extension. + "terminal.integrated.fontFamily": "'Fira Code', Menlo, monospace", // Use zsh (installed in onCreate) as the default terminal shell. "terminal.integrated.defaultProfile.linux": "zsh", diff --git a/.devcontainer/postCreate.sh b/.devcontainer/postCreate.sh index 8d9a404f7..5df377989 100644 --- a/.devcontainer/postCreate.sh +++ b/.devcontainer/postCreate.sh @@ -32,6 +32,16 @@ else echo "✓ Dotfiles updated" fi +# ── Powerlevel10k — browser-compatible glyph mode ──────────────────────────── +# MesloLGS NF / Nerd Font glyphs are unavailable in browser-based Codespaces. +# Patch .p10k.zsh to use the 'compatible' Unicode symbol set instead, which +# renders correctly with any modern monospace font (e.g. Fira Code via extension). +if [ -f "${HOME}/.p10k.zsh" ]; then + sed -i "s/POWERLEVEL9K_MODE='nerdfont-v3'/POWERLEVEL9K_MODE='compatible'/" \ + "${HOME}/.p10k.zsh" + echo "✓ p10k mode set to compatible" +fi + # ── Git identity ────────────────────────────────────────────────────────────── # Populate from Codespace user secrets if they aren't already set by dotfiles. if [ -n "${GIT_USER_NAME:-}" ] && [ -z "$(git config --global user.name 2>/dev/null)" ]; then From f7ecbb2dbc339c92e8fa78919f233cf02576febc Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 6 Apr 2026 22:59:15 +0000 Subject: [PATCH 43/69] fix(devcontainer): use Menlo as terminal font for iOS compatibility --- .devcontainer/devcontainer.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 4f00813c1..1390755db 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -54,8 +54,10 @@ "editor.fontLigatures": true, "terminal.integrated.fontSize": 14, // MesloLGS NF is a local system font — unavailable in the browser. - // Fira Code is loaded as a web font via the tonsky.font-fira-code extension. - "terminal.integrated.fontFamily": "'Fira Code', Menlo, monospace", + // Fira Code (loaded via the tonsky extension) works for the editor renderer but + // not for the terminal canvas/DOM renderer on iOS — it doesn't arrive in time. + // Menlo is a native iOS/macOS system font and is always immediately available. + "terminal.integrated.fontFamily": "Menlo, 'Courier New', monospace", // Use zsh (installed in onCreate) as the default terminal shell. "terminal.integrated.defaultProfile.linux": "zsh", From 5d0f67e999353337f5b90f7ce2e5d09cece8256c Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 7 Apr 2026 14:18:28 +0000 Subject: [PATCH 44/69] update devcontainer settings --- .devcontainer/devcontainer.json | 14 +++----------- .devcontainer/postCreate.sh | 2 +- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 1390755db..21a144e1a 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -22,7 +22,7 @@ // postCreate.sh will wire up git automatically if set. // // GIT_USER_NAME — e.g. "Evie" - // GIT_USER_EMAIL — e.g. "you@example.com" + // GIT_USER_EMAIL — e.g. "evie@gauthier.id" // ─────────────────────────────────────────────────────────────────────────── "remoteEnv": { @@ -50,14 +50,8 @@ "editor.scrollBeyondLastLine": false, // Larger default fonts for retina/HiDPI iPad displays. "editor.fontSize": 14, - "editor.fontFamily": "'Fira Code', 'Cascadia Code', Menlo, monospace", - "editor.fontLigatures": true, "terminal.integrated.fontSize": 14, - // MesloLGS NF is a local system font — unavailable in the browser. - // Fira Code (loaded via the tonsky extension) works for the editor renderer but - // not for the terminal canvas/DOM renderer on iOS — it doesn't arrive in time. - // Menlo is a native iOS/macOS system font and is always immediately available. - "terminal.integrated.fontFamily": "Menlo, 'Courier New', monospace", + // Use zsh (installed in onCreate) as the default terminal shell. "terminal.integrated.defaultProfile.linux": "zsh", @@ -94,9 +88,7 @@ // Named volume keeps the pnpm content-addressable store across rebuilds. // Combined with the PNPM_STORE_DIR env var above so postCreate can also // point pnpm at the same path. - "mounts": [ - "source=sable-pnpm-store,target=/home/vscode/.pnpm-store,type=volume" - ], + "mounts": ["source=sable-pnpm-store,target=/home/vscode/.pnpm-store,type=volume"], "postCreateCommand": "bash .devcontainer/postCreate.sh", "onCreateCommand": "bash .devcontainer/onCreate.sh", diff --git a/.devcontainer/postCreate.sh b/.devcontainer/postCreate.sh index 5df377989..2e88c4e41 100644 --- a/.devcontainer/postCreate.sh +++ b/.devcontainer/postCreate.sh @@ -84,7 +84,7 @@ if [ -n "${GIT_SIGNING_KEY:-}" ]; then # Allow this key when verifying signatures locally. ALLOWED_SIGNERS="${SSH_DIR}/allowed_signers" - EMAIL="$(git config --global user.email 2>/dev/null || echo "you@example.com")" + EMAIL="$(git config --global user.email 2>/dev/null || echo "evie@gauthier.id")" echo "${EMAIL} $(cat "${KEY_FILE}.pub")" > "${ALLOWED_SIGNERS}" git config --global gpg.ssh.allowedSignersFile "${ALLOWED_SIGNERS}" From 52290bfcc1b259ee462dd4d29be69b59c3ca6bab Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 7 Apr 2026 14:48:36 +0000 Subject: [PATCH 45/69] fix(devcontainer): restore missing fontFamily settings --- .devcontainer/devcontainer.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 21a144e1a..e8adea620 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -49,8 +49,13 @@ "editor.minimap.enabled": false, "editor.scrollBeyondLastLine": false, // Larger default fonts for retina/HiDPI iPad displays. + // Fira Code is loaded as a web font by the tonsky.font-fira-code extension, + // making it available in the browser terminal (Safari on iPad included). "editor.fontSize": 14, + "editor.fontFamily": "'Fira Code', Menlo, monospace", + "editor.fontLigatures": true, "terminal.integrated.fontSize": 14, + "terminal.integrated.fontFamily": "'Fira Code', Menlo, monospace", // Use zsh (installed in onCreate) as the default terminal shell. "terminal.integrated.defaultProfile.linux": "zsh", From a3aa4c2c4f3ca6ce8ebaf2d78dc1d37388fa1b2c Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 7 Apr 2026 14:50:09 +0000 Subject: [PATCH 46/69] Update fontfamily --- .devcontainer/devcontainer.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index e8adea620..eb77e0164 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -52,10 +52,11 @@ // Fira Code is loaded as a web font by the tonsky.font-fira-code extension, // making it available in the browser terminal (Safari on iPad included). "editor.fontSize": 14, - "editor.fontFamily": "'Fira Code', Menlo, monospace", + "editor.fontFamily": "'MesloLGS NF', 'Fira Code', Meslo, Monaco, 'Courier New', monospace", "editor.fontLigatures": true, "terminal.integrated.fontSize": 14, - "terminal.integrated.fontFamily": "'Fira Code', Menlo, monospace", + "terminal.integrated.fontFamily": "'MesloLGS NF', 'Fira Code', Meslo, Monaco, 'Courier New', monospace", + "terminal.integrated.fontLigatures.enabled": true, // Use zsh (installed in onCreate) as the default terminal shell. "terminal.integrated.defaultProfile.linux": "zsh", From 0929309dfe2297d62eb7ad1ffca4219ec03f7747 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 7 Apr 2026 14:55:58 +0000 Subject: [PATCH 47/69] chore(devcontainer): sync extensions list with installed extensions --- .devcontainer/devcontainer.json | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index eb77e0164..d1e4039f2 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -73,12 +73,37 @@ "github.copilot.chat.followUps": "always" }, "extensions": [ + // ── Copilot ─────────────────────────────────────────────────────────── "GitHub.copilot", "GitHub.copilot-chat", + "GitHub.vscode-pull-request-github", + // ── Font (web font — required for terminal in browser/iPad) ─────────── "tonsky.font-fira-code", + // ── Theme ───────────────────────────────────────────────────────────── + "GitHub.github-vscode-theme", + // ── Formatting & linting ────────────────────────────────────────────── "esbenp.prettier-vscode", "dbaeumer.vscode-eslint", - "vitest.explorer" + "streetsidesoftware.code-spell-checker", + "davidanson.vscode-markdownlint", + // ── Testing ─────────────────────────────────────────────────────────── + "vitest.explorer", + // ── TypeScript / React ──────────────────────────────────────────────── + "bradlc.vscode-tailwindcss", + "styled-components.vscode-styled-components", + "dsznajder.es7-react-js-snippets", + "formulahendry.auto-rename-tag", + "wix.vscode-import-cost", + // ── Utilities ───────────────────────────────────────────────────────── + "christian-kohler.path-intellisense", + "usernamehw.errorlens", + "gruntfuggly.todo-tree", + "wayou.vscode-todo-highlight", + "webpro.vscode-knip", + "lokalise.i18n-ally", + // ── Infrastructure ──────────────────────────────────────────────────── + "hashicorp.terraform", + "zamerick.vscode-caddyfile-syntax" ] } }, From 8b51cae195464225ab6c3dc67d9617da45ba19b0 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 7 Apr 2026 15:39:38 +0000 Subject: [PATCH 48/69] Update container config --- .devcontainer/devcontainer.json | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index d1e4039f2..da8fcc009 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -49,14 +49,19 @@ "editor.minimap.enabled": false, "editor.scrollBeyondLastLine": false, // Larger default fonts for retina/HiDPI iPad displays. - // Fira Code is loaded as a web font by the tonsky.font-fira-code extension, - // making it available in the browser terminal (Safari on iPad included). + // Fira Code is loaded as a web font by the tonsky.font-fira-code extension. + // This works for the Monaco *editor* (HTML/CSS rendered), but xterm.js uses + // canvas drawing — it does NOT reliably inherit CSS @font-face on iOS Safari. + // MesloLGS NF / Monaco / Meslo are not iOS system fonts either. + // → Editor: Fira Code via extension is fine. + // → Terminal: use Menlo only (ships with iOS since iOS 7, always available). "editor.fontSize": 14, - "editor.fontFamily": "'MesloLGS NF', 'Fira Code', Meslo, Monaco, 'Courier New', monospace", + "editor.fontFamily": "'MesloLGS NF', 'Fira Code', Menlo, 'Courier New', monospace", "editor.fontLigatures": true, "terminal.integrated.fontSize": 14, - "terminal.integrated.fontFamily": "'MesloLGS NF', 'Fira Code', Meslo, Monaco, 'Courier New', monospace", - "terminal.integrated.fontLigatures.enabled": true, + "terminal.integrated.fontFamily": "Menlo, 'Courier New', monospace", + "terminal.integrated.fontLigatures.enabled": false, + "terminal.integrated.gpuAcceleration": "off", // Use zsh (installed in onCreate) as the default terminal shell. "terminal.integrated.defaultProfile.linux": "zsh", From c9c5fe04c1df9c5552698de1e05078ae18e52a80 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 7 Apr 2026 16:38:55 +0000 Subject: [PATCH 49/69] fix(devcontainer): load signing key into ssh-agent in postCreate --- .devcontainer/postCreate.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.devcontainer/postCreate.sh b/.devcontainer/postCreate.sh index 2e88c4e41..52e37fb11 100644 --- a/.devcontainer/postCreate.sh +++ b/.devcontainer/postCreate.sh @@ -88,6 +88,10 @@ if [ -n "${GIT_SIGNING_KEY:-}" ]; then echo "${EMAIL} $(cat "${KEY_FILE}.pub")" > "${ALLOWED_SIGNERS}" git config --global gpg.ssh.allowedSignersFile "${ALLOWED_SIGNERS}" + # Load the key into the ssh-agent so it's available for signing and SSH auth. + eval "$(ssh-agent -s)" &>/dev/null || true + ssh-add "${KEY_FILE}" + echo "✓ Git SSH commit signing configured (${KEY_FILE}.pub)" fi From 3337d49bb6ae323edbdb10b9b16e449abd92d8c7 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 7 Apr 2026 16:39:54 +0000 Subject: [PATCH 50/69] feat(devcontainer): add SSH_AUTH_KEY secret support for server access --- .devcontainer/devcontainer.json | 4 ++++ .devcontainer/postCreate.sh | 28 ++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index da8fcc009..c7376421c 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -21,6 +21,10 @@ // "signing key": github.com/settings/keys // postCreate.sh will wire up git automatically if set. // + // SSH_AUTH_KEY — passphrase-free SSH private key (ed25519 recommended). + // Add the matching public key to ~/.ssh/authorized_keys on + // any server you want to SSH into from the Codespace. + // // GIT_USER_NAME — e.g. "Evie" // GIT_USER_EMAIL — e.g. "evie@gauthier.id" // ─────────────────────────────────────────────────────────────────────────── diff --git a/.devcontainer/postCreate.sh b/.devcontainer/postCreate.sh index 52e37fb11..08be0b1ee 100644 --- a/.devcontainer/postCreate.sh +++ b/.devcontainer/postCreate.sh @@ -95,4 +95,32 @@ if [ -n "${GIT_SIGNING_KEY:-}" ]; then echo "✓ Git SSH commit signing configured (${KEY_FILE}.pub)" fi +# ── SSH auth key ────────────────────────────────────────────────────────────── +# Requires a Codespace user secret named SSH_AUTH_KEY containing a +# passphrase-free SSH private key (ed25519 recommended). +# +# To set up: +# 1. Generate a key: ssh-keygen -t ed25519 -C "codespace auth" -N "" -f ~/.ssh/id_ed25519 +# 2. Copy the private key into a GitHub Codespace secret called SSH_AUTH_KEY: +# github.com/settings/codespaces > Secrets > New secret +# 3. Add the *public* key to ~/.ssh/authorized_keys on your server. +# ---------------------------------------------------------------------------- +if [ -n "${SSH_AUTH_KEY:-}" ]; then + SSH_DIR="${HOME}/.ssh" + mkdir -p "${SSH_DIR}" + chmod 700 "${SSH_DIR}" + + AUTH_KEY_FILE="${SSH_DIR}/id_ed25519" + printf '%s\n' "${SSH_AUTH_KEY}" > "${AUTH_KEY_FILE}" + chmod 600 "${AUTH_KEY_FILE}" + + ssh-keygen -y -f "${AUTH_KEY_FILE}" > "${AUTH_KEY_FILE}.pub" + chmod 644 "${AUTH_KEY_FILE}.pub" + + eval "$(ssh-agent -s)" &>/dev/null || true + ssh-add "${AUTH_KEY_FILE}" + + echo "✓ SSH auth key loaded (${AUTH_KEY_FILE}.pub)" +fi + echo "✓ postCreate complete" From f557020b6221c4790691c73d9ff002d0dc2a9233 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 7 Apr 2026 17:00:56 +0000 Subject: [PATCH 51/69] fix(devcontainer): disable extension MCP auto-discovery, fix p10k sed pattern --- .devcontainer/devcontainer.json | 7 ++++++- .devcontainer/postCreate.sh | 5 ++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index c7376421c..66fddc62d 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -79,7 +79,12 @@ // ── Copilot Chat ────────────────────────────────────────────────────── // Always show follow-ups and keep chat history accessible. - "github.copilot.chat.followUps": "always" + "github.copilot.chat.followUps": "always", + // Disable auto-discovery of extension-provided MCP servers (e.g. the + // io.github.github/github-mcp-server registered by vscode-pull-request-github). + // Our explicit HTTP server in .vscode/mcp.json is unaffected and handles all + // GitHub MCP calls without requiring a token prompt. + "chat.mcp.discovery.enabled": false }, "extensions": [ // ── Copilot ─────────────────────────────────────────────────────────── diff --git a/.devcontainer/postCreate.sh b/.devcontainer/postCreate.sh index 08be0b1ee..053c91c23 100644 --- a/.devcontainer/postCreate.sh +++ b/.devcontainer/postCreate.sh @@ -36,10 +36,13 @@ fi # MesloLGS NF / Nerd Font glyphs are unavailable in browser-based Codespaces. # Patch .p10k.zsh to use the 'compatible' Unicode symbol set instead, which # renders correctly with any modern monospace font (e.g. Fira Code via extension). +# The POWERLEVEL9K_MODE line has no quotes: POWERLEVEL9K_MODE=nerdfont-complete if [ -f "${HOME}/.p10k.zsh" ]; then - sed -i "s/POWERLEVEL9K_MODE='nerdfont-v3'/POWERLEVEL9K_MODE='compatible'/" \ + sed -i "s/POWERLEVEL9K_MODE=.*/POWERLEVEL9K_MODE=compatible/" \ "${HOME}/.p10k.zsh" echo "✓ p10k mode set to compatible" +else + echo "⚠ ~/.p10k.zsh not found — skipping p10k patch (add it to your dotfiles repo)" fi # ── Git identity ────────────────────────────────────────────────────────────── From 3f196add7659f5b57c3bcb67452f536113eadc16 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 7 Apr 2026 18:27:51 +0000 Subject: [PATCH 52/69] fix(devcontainer): enable shell integration for Copilot Chat terminal --- .devcontainer/devcontainer.json | 11 +++++++++++ .devcontainer/postCreate.sh | 24 ++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 66fddc62d..f97cbc6fc 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -68,7 +68,18 @@ "terminal.integrated.gpuAcceleration": "off", // Use zsh (installed in onCreate) as the default terminal shell. + // Explicit profile with -l (login shell) ensures nvm / PATH additions + // from the devcontainer node feature are loaded inside the terminal. "terminal.integrated.defaultProfile.linux": "zsh", + "terminal.integrated.profiles.linux": { + "zsh": { "path": "/bin/zsh", "args": ["-l"] } + }, + + // Shell integration MUST be enabled for Copilot Chat to run terminal + // commands. We set it explicitly because Powerlevel10k instant prompt + // can fire before VS Code injects its integration script and suppress + // the markers — postCreate.sh patches .zshrc to guard against this. + "terminal.integrated.shellIntegration.enabled": true, // ── Git signing ─────────────────────────────────────────────────────── // postCreate.sh configures gpg.format and user.signingkey if diff --git a/.devcontainer/postCreate.sh b/.devcontainer/postCreate.sh index 053c91c23..be95325ec 100644 --- a/.devcontainer/postCreate.sh +++ b/.devcontainer/postCreate.sh @@ -45,6 +45,30 @@ else echo "⚠ ~/.p10k.zsh not found — skipping p10k patch (add it to your dotfiles repo)" fi +# ── Powerlevel10k — disable instant prompt in VS Code terminal ──────────────── +# Instant prompt outputs to the terminal before VS Code injects its shell +# integration script. This breaks the integration markers that Copilot Chat +# relies on to run commands. We prepend a one-liner to .zshrc that sets +# POWERLEVEL9K_INSTANT_PROMPT=off whenever $TERM_PROGRAM is "vscode". +# The check is idempotent — safe to run on Codespace resume. +if [ -f "${HOME}/.zshrc" ]; then + if ! grep -q 'POWERLEVEL9K_INSTANT_PROMPT=off' "${HOME}/.zshrc"; then + tmp=$(mktemp) + { + printf '# Disable P10k instant prompt in VS Code — it fires before shell\n' + printf '# integration is injected, which breaks Copilot Chat terminal access.\n' + printf '[[ "$TERM_PROGRAM" == "vscode" ]] && typeset -g POWERLEVEL9K_INSTANT_PROMPT=off\n\n' + cat "${HOME}/.zshrc" + } > "$tmp" + mv "$tmp" "${HOME}/.zshrc" + echo "✓ P10k instant prompt disabled for VS Code terminal" + else + echo "✓ P10k instant prompt VS Code guard already present" + fi +else + echo "⚠ ~/.zshrc not found — skipping instant-prompt patch (dotfiles not checked out?)" +fi + # ── Git identity ────────────────────────────────────────────────────────────── # Populate from Codespace user secrets if they aren't already set by dotfiles. if [ -n "${GIT_USER_NAME:-}" ] && [ -z "$(git config --global user.name 2>/dev/null)" ]; then From beacbfb09fcee7cf9d0afbe5933b1663e5c9c3d2 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 8 Apr 2026 18:24:20 +0000 Subject: [PATCH 53/69] chore(devcontainer): switch dotfiles branch to codespaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Linux/Codespaces-clean branch — removes macOS NVM lazy-loader, Homebrew paths, macOS-only OMZ plugins, and hardcoded macOS gitconfig paths. --- .devcontainer/postCreate.sh | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/.devcontainer/postCreate.sh b/.devcontainer/postCreate.sh index be95325ec..58dec301c 100644 --- a/.devcontainer/postCreate.sh +++ b/.devcontainer/postCreate.sh @@ -8,7 +8,7 @@ set -euo pipefail # We clone a specific branch so we get the VS Code / Codespace-aware config # (e.g. the P10k instant-prompt guard for $TERM_PROGRAM == "vscode"). DOTFILES_REPO="https://github.com/Just-Insane/dotfiles.git" -DOTFILES_BRANCH="MacStudio" +DOTFILES_BRANCH="codespaces" DOTFILES_DIR="${HOME}/.cfg" if [ ! -d "${DOTFILES_DIR}" ]; then @@ -45,25 +45,29 @@ else echo "⚠ ~/.p10k.zsh not found — skipping p10k patch (add it to your dotfiles repo)" fi -# ── Powerlevel10k — disable instant prompt in VS Code terminal ──────────────── +# ── Powerlevel10k — disable instant prompt in Codespace terminal ────────────── # Instant prompt outputs to the terminal before VS Code injects its shell # integration script. This breaks the integration markers that Copilot Chat -# relies on to run commands. We prepend a one-liner to .zshrc that sets -# POWERLEVEL9K_INSTANT_PROMPT=off whenever $TERM_PROGRAM is "vscode". +# relies on to run commands. +# We unconditionally disable it here because: +# - In a Codespace, VS Code shell integration is always needed for Copilot Chat. +# - $TERM_PROGRAM is NOT reliably set to "vscode" in browser-based Codespaces +# (e.g. iPad / vscode.dev), so a conditional guard can silently fail. # The check is idempotent — safe to run on Codespace resume. if [ -f "${HOME}/.zshrc" ]; then if ! grep -q 'POWERLEVEL9K_INSTANT_PROMPT=off' "${HOME}/.zshrc"; then tmp=$(mktemp) { - printf '# Disable P10k instant prompt in VS Code — it fires before shell\n' - printf '# integration is injected, which breaks Copilot Chat terminal access.\n' - printf '[[ "$TERM_PROGRAM" == "vscode" ]] && typeset -g POWERLEVEL9K_INSTANT_PROMPT=off\n\n' + printf '# Disable P10k instant prompt — it fires before VS Code shell\n' + printf '# integration is injected, breaking Copilot Chat terminal access.\n' + printf '# Unconditional: $TERM_PROGRAM is not reliable in browser Codespaces.\n' + printf 'typeset -g POWERLEVEL9K_INSTANT_PROMPT=off\n\n' cat "${HOME}/.zshrc" } > "$tmp" mv "$tmp" "${HOME}/.zshrc" - echo "✓ P10k instant prompt disabled for VS Code terminal" + echo "✓ P10k instant prompt unconditionally disabled" else - echo "✓ P10k instant prompt VS Code guard already present" + echo "✓ P10k instant prompt already disabled" fi else echo "⚠ ~/.zshrc not found — skipping instant-prompt patch (dotfiles not checked out?)" From c775b3d33e6b474a30cd5ed01b7efe51f28efd3b Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 15 Apr 2026 18:00:19 -0400 Subject: [PATCH 54/69] chore(prompts): add rebuild integration and review upstream PRs prompts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/prompts/rebuild integration.prompt.md | 12 ++++++++++++ .../review open PRs against `upstream`.prompt.md | 10 ++++++++++ 2 files changed, 22 insertions(+) create mode 100644 .github/prompts/rebuild integration.prompt.md create mode 100644 .github/prompts/review open PRs against `upstream`.prompt.md diff --git a/.github/prompts/rebuild integration.prompt.md b/.github/prompts/rebuild integration.prompt.md new file mode 100644 index 000000000..000673e52 --- /dev/null +++ b/.github/prompts/rebuild integration.prompt.md @@ -0,0 +1,12 @@ +--- +name: rebuild integration +description: When asked to rebuild integration, or if there are large numbers of changes to branches +--- + + + +Please rebuild the `integration` branch, by deleting `integration` and then creating a new `integration` branch from `dev`, after updating `dev` from `upstream/dev` (and push `dev` to `origin/dev`). This is needed because there are large numbers of changes to branches, and rebuilding the integration branch will help to ensure that it is up to date with the latest changes. + +Please prompt for which branches to include, and always include `personal/config`, as it is needed for the integration branch to work properly. If there are any other branches that need to be included, please prompt for those as well. + +We should also ensure that any necessary tests are run after rebuilding the integration branch, to verify that everything is working correctly. Please let me know if you have any questions or need any assistance with this process. \ No newline at end of file diff --git a/.github/prompts/review open PRs against `upstream`.prompt.md b/.github/prompts/review open PRs against `upstream`.prompt.md new file mode 100644 index 000000000..7c85531be --- /dev/null +++ b/.github/prompts/review open PRs against `upstream`.prompt.md @@ -0,0 +1,10 @@ +--- +name: review open PRs against `upstream` +description: When asked to review open PRs against `upstream` +--- + + + +Please look for all of my open/pending PRs against `upstream`, and review them for any issues, such as merge conflicts, failing checks, comments, or outdated code. If you find any problems, please provide feedback on how to resolve them. And/or implement the necessary changes to get the PRs ready for merging. + +Once done, please provide a summary of the status of each PR, including any actions taken or needed to get them merged. \ No newline at end of file From 75ac9d38d48f42b16d7948b3a7b35e372d759bb7 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 16 Apr 2026 11:47:46 -0400 Subject: [PATCH 55/69] Update personal config.json --- config.json | 87 ++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 59 insertions(+), 28 deletions(-) diff --git a/config.json b/config.json index 8f685bfd4..027ad7c15 100644 --- a/config.json +++ b/config.json @@ -1,31 +1,62 @@ { - "allowCustomHomeservers": true, - "defaultHomeserver": 0, - "elementCallUrl": "matrix.cloudhub.social", - "featuredCommunities": { - "openAsDefault": false, - "rooms": [], - "servers": [ - "matrixrooms.info", - "gitter.im", - "matrix.org" - ], - "spaces": [] - }, - "homeserverList": [ - "https://matrix.cloudhub.social" + "defaultHomeserver": 0, + "homeserverList": [ + "https://matrix.cloudhub.social", + "matrix.org", + "mozilla.org", + "unredacted.org", + "sable.moe", + "kendama.moe" + ], + "allowCustomHomeservers": true, + "elementCallUrl": "matrix.cloudhub.social", + "disableAccountSwitcher": false, + "hideUsernamePasswordFields": false, + "pushNotificationDetails": { + "pushNotifyUrl": "https://sygnal.cloudhub.social/_matrix/push/v1/notify", + "vapidPublicKey": "BEBdK6VUiqYxcOauFCM1ZB38llgiODAs6pR5EEcC7YBoUh2YvrULagwo5t-Ms0Is0lEmKDhpdUoMiy_i7ArI3oE", + "webPushAppID": "social.cloudhub.sable.web" + }, + + "settingsLinkBaseUrl": "https://app.sable.moe", + + "presenceAutoIdleTimeoutMs": 300000, + + "slidingSync": { + "enabled": true + }, + + "sessionSync": { + "phase1ForegroundResync": true, + "phase2VisibleHeartbeat": true, + "phase3AdaptiveBackoffJitter": true + }, + + "featuredCommunities": { + "openAsDefault": false, + "spaces": [ + "#sable:sable.moe", + "#community:matrix.org", + "#space:unredacted.org", + "#science-space:matrix.org", + "#libregaming-games:tchncs.de", + "#mathematics-on:matrix.org" ], - "pushNotificationDetails": { - "pushNotifyUrl": "https://sygnal.cloudhub.social/_matrix/push/v1/notify", - "vapidPublicKey": "BEBdK6VUiqYxcOauFCM1ZB38llgiODAs6pR5EEcC7YBoUh2YvrULagwo5t-Ms0Is0lEmKDhpdUoMiy_i7ArI3oE", - "webPushAppID": "social.cloudhub.sable.web" - }, - "sessionSync": { - "phase1ForegroundResync": true, - "phase2VisibleHeartbeat": true, - "phase3AdaptiveBackoffJitter": false - }, - "slidingSync": { - "enabled": "true" - } + "rooms": [ + "#announcements:sable.moe", + "#freesoftware:matrix.org", + "#pcapdroid:matrix.org", + "#gentoo:matrix.org", + "#PrivSec.dev:arcticfoxes.net", + "#disroot:aria-net.org" + ], + "servers": ["matrixrooms.info", "mozilla.org", "unredacted.org"] + }, + "hashRouter": { + "enabled": false, + "basename": "/" + }, + "features": { + "polls": true + } } From d74f7a981940949d54df291dcb7f0a110099564a Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 2 May 2026 15:10:46 -0400 Subject: [PATCH 56/69] feat(timeline): configurable message grouping threshold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a 'messageGroupingThreshold' setting (default: 2 minutes, matching upstream) that controls how long before a new sender block starts in the timeline. Exposed as a number input (1–60 min) in Messages settings. --- src/app/features/room/RoomTimeline.tsx | 2 + src/app/features/room/ThreadDrawer.tsx | 2 + src/app/features/settings/general/General.tsx | 51 +++++++++++++++++++ .../hooks/timeline/useProcessedTimeline.ts | 4 +- src/app/state/settings.ts | 2 + 5 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 8bc5d770c..d9223e6a5 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -137,6 +137,7 @@ export function RoomTimeline({ const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing'); const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents'); const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents'); + const [messageGroupingThreshold] = useSetting(settingsAtom, 'messageGroupingThreshold'); const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); const [showBundledPreview] = useSetting(settingsAtom, 'bundledPreview'); const [urlPreview] = useSetting(settingsAtom, 'urlPreview'); @@ -748,6 +749,7 @@ export function RoomTimeline({ readUptoEventId: readUptoEventIdRef.current, hideMembershipEvents, hideNickAvatarEvents, + messageGroupingThreshold, isReadOnly, hideMemberInReadOnly, }); diff --git a/src/app/features/room/ThreadDrawer.tsx b/src/app/features/room/ThreadDrawer.tsx index 3171e3ba4..a275df9ff 100644 --- a/src/app/features/room/ThreadDrawer.tsx +++ b/src/app/features/room/ThreadDrawer.tsx @@ -132,6 +132,7 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents'); const [showTombstoneEvents] = useSetting(settingsAtom, 'showTombstoneEvents'); const [hideMemberInReadOnly] = useSetting(settingsAtom, 'hideMembershipInReadOnly'); + const [messageGroupingThreshold] = useSetting(settingsAtom, 'messageGroupingThreshold'); const [showBundledPreview] = useSetting(settingsAtom, 'bundledPreview'); const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview; const showClientUrlPreview = room.hasEncryptionStateEvent() @@ -244,6 +245,7 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra readUptoEventId: undefined, hideMembershipEvents: true, hideNickAvatarEvents: true, + messageGroupingThreshold, isReadOnly, hideMemberInReadOnly, }); diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 061953d14..d81392abe 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -865,6 +865,49 @@ function Calls() { ); } +function MessageGroupingThresholdInput() { + const [threshold, setThreshold] = useSetting(settingsAtom, 'messageGroupingThreshold'); + const [inputValue, setInputValue] = useState(threshold.toString()); + + const handleChange: ChangeEventHandler = (evt) => { + const val = evt.target.value; + setInputValue(val); + + const parsed = Number.parseInt(val, 10); + if (!Number.isNaN(parsed) && parsed >= 1 && parsed <= 60) { + setThreshold(parsed); + } + }; + + const handleKeyDown: KeyboardEventHandler = (evt) => { + if (isKeyHotkey('escape', evt)) { + evt.stopPropagation(); + setInputValue(threshold.toString()); + (evt.target as HTMLInputElement).blur(); + } + + if (isKeyHotkey('enter', evt)) { + (evt.target as HTMLInputElement).blur(); + } + }; + + return ( + + ); +} + function Messages() { const [hideMembershipEvents, setHideMembershipEvents] = useSetting( settingsAtom, @@ -941,6 +984,14 @@ function Messages() { } /> + + } + /> + Date: Sat, 2 May 2026 15:16:21 -0400 Subject: [PATCH 57/69] =?UTF-8?q?feat:=20developer=20tools=20=E2=80=94=20e?= =?UTF-8?q?xperiment=20flags=20panel=20+=20rotate=20Megolm=20sessions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add INJECTED_EXPERIMENT_FLAGS build constant (from VITE_FEATURE_* env vars) - Add experiments?: Record to ClientConfig type - Add useExperimentFlag() and setExperimentOverride() to useClientConfig - ExperimentsPanel: per-flag toggles with localStorage override and Reset button - Settings-level dev tools: render ExperimentsPanel when developerTools is on - Room-level dev tools: Rotate Megolm Session tile for encrypted rooms --- .../developer-tools/DevelopTools.tsx | 50 +++++++++ .../settings/developer-tools/DevelopTools.tsx | 6 ++ .../developer-tools/ExperimentsPanel.tsx | 102 ++++++++++++++++++ src/app/hooks/useClientConfig.ts | 29 +++++ src/ext.d.ts | 1 + vite.config.ts | 7 ++ 6 files changed, 195 insertions(+) create mode 100644 src/app/features/settings/developer-tools/ExperimentsPanel.tsx diff --git a/src/app/features/common-settings/developer-tools/DevelopTools.tsx b/src/app/features/common-settings/developer-tools/DevelopTools.tsx index d35e20da7..bb03e3bd4 100644 --- a/src/app/features/common-settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/common-settings/developer-tools/DevelopTools.tsx @@ -63,6 +63,22 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) { const [expandStateType, setExpandStateType] = useState(); const [openStateEvent, setOpenStateEvent] = useState(); const [composeEvent, setComposeEvent] = useState<{ type?: string; stateKey?: string }>(); + const [rotateSessionStatus, setRotateSessionStatus] = useState< + 'idle' | 'rotating' | 'done' | 'error' + >('idle'); + + const handleRotateSessions = useCallback(async () => { + const crypto = mx.getCrypto(); + if (!crypto) return; + setRotateSessionStatus('rotating'); + try { + await crypto.forceDiscardSession(room.roomId); + crypto.prepareToEncrypt(room); + setRotateSessionStatus('done'); + } catch { + setRotateSessionStatus('error'); + } + }, [mx, room]); const [expandAccountData, setExpandAccountData] = useState(false); const [accountDataType, setAccountDataType] = useState(); @@ -233,6 +249,40 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) { /> )} + {developerTools && room.hasEncryptionStateEvent() && ( + + + + {rotateSessionStatus === 'rotating' ? 'Rotating…' : 'Rotate'} + + + } + /> + + )} {developerTools && ( diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx index 100119726..e15ae476f 100644 --- a/src/app/features/settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/settings/developer-tools/DevelopTools.tsx @@ -15,6 +15,7 @@ import { AccountData } from './AccountData'; import { SyncDiagnostics } from './SyncDiagnostics'; import { DebugLogViewer } from './DebugLogViewer'; import { SentrySettings } from './SentrySettings'; +import { ExperimentsPanel } from './ExperimentsPanel'; type DeveloperToolsProps = { requestBack?: () => void; @@ -127,6 +128,11 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp )} + {developerTools && ( + + + + )} diff --git a/src/app/features/settings/developer-tools/ExperimentsPanel.tsx b/src/app/features/settings/developer-tools/ExperimentsPanel.tsx new file mode 100644 index 000000000..86dcec3b5 --- /dev/null +++ b/src/app/features/settings/developer-tools/ExperimentsPanel.tsx @@ -0,0 +1,102 @@ +import { useState, useCallback } from 'react'; +import { Box, Text, Switch, Button } from 'folds'; +import { SequenceCard } from '$components/sequence-card'; +import { SettingTile } from '$components/setting-tile'; +import { SequenceCardStyle } from '$features/settings/styles.css'; +import { useClientConfig, setExperimentOverride } from '$hooks/useClientConfig'; + +const EXPERIMENT_OVERRIDE_PREFIX = 'sable_exp_'; + +function getActiveExperimentKeys(configExperiments?: Record): string[] { + const fromConfig = Object.keys(configExperiments ?? {}); + const fromBuild = Object.keys(INJECTED_EXPERIMENT_FLAGS); + const fromStorage = Object.keys(localStorage) + .filter((k) => k.startsWith(EXPERIMENT_OVERRIDE_PREFIX)) + .map((k) => k.slice(EXPERIMENT_OVERRIDE_PREFIX.length)); + + return Array.from(new Set([...fromConfig, ...fromBuild, ...fromStorage])).sort(); +} + +function getEffectiveValue( + key: string, + configExperiments?: Record +): { value: boolean; source: 'override' | 'config' | 'build' | 'default' } { + const lsValue = localStorage.getItem(`${EXPERIMENT_OVERRIDE_PREFIX}${key}`); + if (lsValue !== null) return { value: lsValue === 'true', source: 'override' }; + if (configExperiments && key in configExperiments) + return { value: configExperiments[key] ?? false, source: 'config' }; + if (key in INJECTED_EXPERIMENT_FLAGS) + return { value: INJECTED_EXPERIMENT_FLAGS[key] ?? false, source: 'build' }; + return { value: false, source: 'default' }; +} + +export function ExperimentsPanel() { + const config = useClientConfig(); + const [, forceUpdate] = useState(0); + const refresh = useCallback(() => forceUpdate((n) => n + 1), []); + + const keys = getActiveExperimentKeys(config.experiments); + + if (keys.length === 0) { + return ( + + Experiments + + No experiment flags are defined. Set VITE_FEATURE_* env vars at build time + or add an experiments field to config.json. + + + ); + } + + return ( + + Experiments + + Override experiment flags for this session. Changes are stored in localStorage and take + effect immediately on next render. + + + {keys.map((key) => { + const { value, source } = getEffectiveValue(key, config.experiments); + const hasOverride = source === 'override'; + return ( + + {hasOverride && ( + + )} + { + setExperimentOverride(key, v); + refresh(); + }} + /> + + } + /> + ); + })} + + + ); +} diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index 87685337d..46c8804b3 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -42,6 +42,8 @@ export type ClientConfig = { hashRouter?: HashRouterConfig; matrixToBaseUrl?: string; + + experiments?: Record; }; const ClientConfigContext = createContext(null); @@ -64,3 +66,30 @@ export const clientAllowedServer = (clientConfig: ClientConfig, server: string): return homeserverList?.includes(server) === true; }; + +const EXPERIMENT_OVERRIDE_PREFIX = 'sable_exp_'; + +/** + * Returns the value of an experiment flag. Resolution order: + * 1. localStorage override (set by developer tools panel) + * 2. config.json `experiments` field (deploy-time config) + * 3. Build-time injected flags from VITE_FEATURE_* env vars + * 4. false (default) + */ +export function useExperimentFlag(key: string): boolean { + const config = useClientConfig(); + const lsKey = `${EXPERIMENT_OVERRIDE_PREFIX}${key}`; + const lsValue = localStorage.getItem(lsKey); + if (lsValue !== null) return lsValue === 'true'; + if (config.experiments && key in config.experiments) return config.experiments[key] ?? false; + return INJECTED_EXPERIMENT_FLAGS[key] ?? false; +} + +export function setExperimentOverride(key: string, value: boolean | null): void { + const lsKey = `${EXPERIMENT_OVERRIDE_PREFIX}${key}`; + if (value === null) { + localStorage.removeItem(lsKey); + } else { + localStorage.setItem(lsKey, value ? 'true' : 'false'); + } +} diff --git a/src/ext.d.ts b/src/ext.d.ts index 7ee0a20a8..cbded3fb2 100644 --- a/src/ext.d.ts +++ b/src/ext.d.ts @@ -3,6 +3,7 @@ declare const APP_VERSION: string; declare const BUILD_HASH: string; declare const IS_RELEASE_TAG: boolean; +declare const INJECTED_EXPERIMENT_FLAGS: Record; declare module 'browser-encrypt-attachment' { export interface EncryptedAttachmentInfo { diff --git a/vite.config.ts b/vite.config.ts index 7b482ef78..52c4ae5e0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -50,6 +50,12 @@ const resolveBuildHash = (): string | undefined => { const appVersion = packageJson.version; const buildHash = resolveBuildHash(); +const injectedExperimentFlags = Object.fromEntries( + Object.entries(process.env) + .filter(([k]) => k.startsWith('VITE_FEATURE_')) + .map(([k, v]) => [k.slice('VITE_FEATURE_'.length).toLowerCase().replace(/_/g, '-'), v === 'true']) +); + const isReleaseTag = (() => { const envVal = process.env.VITE_IS_RELEASE_TAG; if (envVal !== undefined && envVal !== '') return envVal === 'true'; @@ -131,6 +137,7 @@ export default defineConfig(({ command }) => ({ APP_VERSION: JSON.stringify(appVersion), BUILD_HASH: JSON.stringify(buildHash ?? ''), IS_RELEASE_TAG: JSON.stringify(isReleaseTag), + INJECTED_EXPERIMENT_FLAGS: JSON.stringify(injectedExperimentFlags), }, resolve: { alias: { From 1f75444c8c48ad02121cf82596626b2d81a4b7a2 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 2 May 2026 15:46:07 -0400 Subject: [PATCH 58/69] =?UTF-8?q?feat:=20MSC3381=20polls=20=E2=80=94=20Pol?= =?UTF-8?q?lContent,=20PollCreator,=20timeline=20renderer,=20RoomInput=20b?= =?UTF-8?q?utton?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/features/room/PollContent.tsx | 184 +++++++++++ src/app/features/room/PollCreator.tsx | 299 ++++++++++++++++++ src/app/features/room/RoomInput.tsx | 15 + .../timeline/useTimelineEventRenderer.tsx | 9 + src/app/hooks/usePollTally.test.ts | 109 +++++++ src/app/hooks/usePollTally.ts | 78 +++++ vitest.config.ts | 1 + 7 files changed, 695 insertions(+) create mode 100644 src/app/features/room/PollContent.tsx create mode 100644 src/app/features/room/PollCreator.tsx create mode 100644 src/app/hooks/usePollTally.test.ts create mode 100644 src/app/hooks/usePollTally.ts diff --git a/src/app/features/room/PollContent.tsx b/src/app/features/room/PollContent.tsx new file mode 100644 index 000000000..1a555ba08 --- /dev/null +++ b/src/app/features/room/PollContent.tsx @@ -0,0 +1,184 @@ +import type { MatrixEvent, Relations, Room } from '$types/matrix-sdk'; +import { useCallback, useEffect, useState } from 'react'; +import { Box, Button, Text, config } from 'folds'; +import { M_POLL_KIND_DISCLOSED, M_POLL_RESPONSE, M_POLL_START } from 'matrix-js-sdk/lib/@types/polls'; +import type { PollAnswer } from 'matrix-js-sdk/lib/@types/polls'; +import { M_TEXT } from 'matrix-js-sdk/lib/@types/extensible_events'; +import { PollEvent as PollModelEvent } from 'matrix-js-sdk/lib/models/poll'; +import type { Poll } from 'matrix-js-sdk/lib/models/poll'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { tallyCounts } from '$hooks/usePollTally'; + +type PollContentProps = { + mEvent: MatrixEvent; + room: Room; +}; + +function getAnswerText(answer: PollAnswer): string { + const raw = answer as unknown as Record; + return ( + (raw[M_TEXT.name] as string | undefined) ?? + (raw[M_TEXT.altName] as string | undefined) ?? + '' + ); +} + +export function PollContent({ mEvent, room }: PollContentProps) { + const mx = useMatrixClient(); + const content = mEvent.getContent(); + + const pollRaw = ( + content[M_POLL_START.name] ?? content[M_POLL_START.altName] + ) as Record | undefined; + + const question: string = (() => { + if (!pollRaw) return '(Poll)'; + const q = pollRaw.question as Record | undefined; + if (!q) return '(Poll)'; + return ( + (q[M_TEXT.name] as string | undefined) ?? + (q[M_TEXT.altName] as string | undefined) ?? + '(Poll)' + ); + })(); + + const answers = (pollRaw?.answers as PollAnswer[] | undefined) ?? []; + const maxSelections = (pollRaw?.max_selections as number | undefined) ?? 1; + const kind = (pollRaw?.kind as string | undefined) ?? M_POLL_KIND_DISCLOSED.name; + const isDisclosed = + kind === M_POLL_KIND_DISCLOSED.name || kind === M_POLL_KIND_DISCLOSED.altName; + + const eventId = mEvent.getId() ?? ''; + const roomId = room.roomId; + const myUserId = mx.getUserId() ?? ''; + + const [relations, setRelations] = useState(undefined); + + useEffect(() => { + const roomWithPolls = room as unknown as { polls: Map }; + const poll = roomWithPolls.polls.get(eventId); + if (!poll) return; + + poll + .getResponses() + .then((rels) => setRelations(rels)) + .catch(() => {}); + + const onResponses = (rels: Relations) => setRelations(rels); + poll.on(PollModelEvent.Responses, onResponses); + return () => { + poll.off(PollModelEvent.Responses, onResponses); + }; + }, [room, eventId]); + + const tally = tallyCounts(answers, relations, myUserId, maxSelections); + + const handleVote = useCallback( + async (answerId: string) => { + const isSelected = tally.myAnswers.includes(answerId); + let newAnswers: string[]; + if (maxSelections === 1) { + newAnswers = isSelected ? [] : [answerId]; + } else { + newAnswers = isSelected + ? tally.myAnswers.filter((id) => id !== answerId) + : [...tally.myAnswers, answerId].slice(0, maxSelections); + } + + const voteContent: Record = { + [M_POLL_RESPONSE.name]: { answers: newAnswers }, + [M_POLL_RESPONSE.altName]: { answers: newAnswers }, + 'm.relates_to': { + rel_type: 'm.reference', + event_id: eventId, + }, + }; + + type SendEventContent = Parameters[3]; + await ( + mx as unknown as { + sendEvent( + roomId: string, + threadId: null, + eventType: string, + content: SendEventContent + ): Promise; + } + ).sendEvent( + roomId, + null, + M_POLL_RESPONSE.name, + voteContent as unknown as SendEventContent + ); + }, + [mx, roomId, eventId, tally.myAnswers, maxSelections] + ); + + return ( + + + {question} + + + {isDisclosed ? 'Poll · Results visible while voting' : 'Poll · Results hidden until ended'} + + + {answers.map((answer, idx) => { + const text = getAnswerText(answer); + const isSelected = tally.myAnswers.includes(answer.id); + const voteCount = tally.counts.get(answer.id) ?? 0; + const pct = + tally.totalVoters > 0 ? Math.round((voteCount / tally.totalVoters) * 100) : 0; + + return ( + + + + ); + })} + + {tally.totalVoters > 0 && ( + + {tally.totalVoters} {tally.totalVoters === 1 ? 'vote' : 'votes'} + + )} + + ); +} diff --git a/src/app/features/room/PollCreator.tsx b/src/app/features/room/PollCreator.tsx new file mode 100644 index 000000000..f26201a46 --- /dev/null +++ b/src/app/features/room/PollCreator.tsx @@ -0,0 +1,299 @@ +import { useCallback, useRef, useState } from 'react'; +import FocusTrap from 'focus-trap-react'; +import { + Box, + Button, + Dialog, + Header, + Icon, + IconButton, + Icons, + Input, + Overlay, + OverlayBackdrop, + OverlayCenter, + Scroll, + Switch, + Text, + config, +} from 'folds'; +import { PollStartEvent } from 'matrix-js-sdk/lib/extensible_events_v1/PollStartEvent'; +import { + M_POLL_KIND_DISCLOSED, + M_POLL_KIND_UNDISCLOSED, +} from 'matrix-js-sdk/lib/@types/polls'; +import type { Room } from '$types/matrix-sdk'; +import { useMatrixClient } from '$hooks/useMatrixClient'; + + +const MIN_ANSWERS = 2; +const MAX_ANSWERS = 20; + +let answerIdSeed = 0; +function newId(): string { + answerIdSeed += 1; + return `a${answerIdSeed}`; +} + +type AnswerDraft = { id: string; text: string }; + +type PollCreatorProps = { + room: Room; + onClose: () => void; +}; + +export function PollCreator({ room, onClose }: PollCreatorProps) { + const mx = useMatrixClient(); + + const [question, setQuestion] = useState(''); + const [answers, setAnswers] = useState([ + { id: newId(), text: '' }, + { id: newId(), text: '' }, + ]); + const [multiSelect, setMultiSelect] = useState(false); + const [maxSelections, setMaxSelections] = useState(2); + const [disclosed, setDisclosed] = useState(true); + const [sending, setSending] = useState(false); + const [error, setError] = useState(); + + const lastInputRef = useRef(null); + + const handleAddAnswer = useCallback(() => { + if (answers.length >= MAX_ANSWERS) return; + setAnswers((prev) => [...prev, { id: newId(), text: '' }]); + requestAnimationFrame(() => lastInputRef.current?.focus()); + }, [answers.length]); + + const handleRemoveAnswer = useCallback( + (id: string) => { + if (answers.length <= MIN_ANSWERS) return; + setAnswers((prev) => prev.filter((a) => a.id !== id)); + }, + [answers.length] + ); + + const handleAnswerChange = useCallback((id: string, text: string) => { + setAnswers((prev) => prev.map((a) => (a.id === id ? { ...a, text } : a))); + }, []); + + const handleMultiSelectToggle = useCallback((v: boolean) => { + setMultiSelect(v); + if (v) setMaxSelections(2); + }, []); + + const handleSend = useCallback(async () => { + const q = question.trim(); + if (!q) { + setError('Please enter a question.'); + return; + } + const validAnswers = answers.map((a) => a.text.trim()).filter(Boolean); + if (validAnswers.length < MIN_ANSWERS) { + setError(`Please fill in at least ${MIN_ANSWERS} answer options.`); + return; + } + + const kind = disclosed ? M_POLL_KIND_DISCLOSED : M_POLL_KIND_UNDISCLOSED; + const maxSel = multiSelect ? Math.max(2, Math.min(maxSelections, validAnswers.length)) : 1; + const pollEvent = PollStartEvent.from(q, validAnswers, kind, maxSel); + const serialized = pollEvent.serialize(); + + setSending(true); + setError(undefined); + try { + type SendEventContent = Parameters[3]; + await ( + mx as unknown as { + sendEvent( + roomId: string, + threadId: null, + eventType: string, + content: SendEventContent + ): Promise; + } + ).sendEvent( + room.roomId, + null, + serialized.type, + serialized.content as unknown as SendEventContent + ); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to send poll.'); + setSending(false); + } + }, [question, answers, multiSelect, maxSelections, disclosed, mx, room.roomId, onClose]); + + return ( + }> + + + +
+ + + Create Poll + + + + +
+ + + + + {/* Question */} + + Question + setQuestion((e.target as HTMLInputElement).value)} + maxLength={340} + /> + + + {/* Answers */} + + Options + {answers.map((ans, idx) => ( + + + handleAnswerChange(ans.id, (e.target as HTMLInputElement).value)} + maxLength={340} + /> + + handleRemoveAnswer(ans.id)} + variant="Surface" + size="300" + radii="300" + disabled={answers.length <= MIN_ANSWERS} + aria-label={`Remove option ${idx + 1}`} + > + + + + ))} + {answers.length < MAX_ANSWERS && ( + + )} + + + {/* Multi-select */} + + + + Allow multiple selections + + {multiSelect && ( + + Up to + { + const v = parseInt((e.target as HTMLInputElement).value, 10); + if (!Number.isNaN(v)) { + setMaxSelections(Math.max(2, Math.min(v, answers.length))); + } + }} + style={{ width: '4rem' }} + /> + + )} + + + {/* Disclosed toggle */} + + + + {disclosed ? 'Disclosed poll' : 'Undisclosed poll'} + + {disclosed + ? 'Results visible while voting' + : 'Results hidden until poll ends'} + + + + + {error && ( + + {error} + + )} + + + + {/* Footer */} + + + + + +
+
+
+
+ ); +} diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index db21b3544..8661c6c1b 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -144,6 +144,7 @@ import type { AudioRecordingCompletePayload, } from './AudioMessageRecorder'; import { AudioMessageRecorder } from './AudioMessageRecorder'; +import { PollCreator } from './PollCreator'; // Returns the event ID of the most recent non-reaction/non-edit event in a thread, // falling back to the thread root if no replies exist yet. @@ -362,6 +363,7 @@ export const RoomInput = forwardRef( ); const [scheduleMenuAnchor, setScheduleMenuAnchor] = useState(); const [showSchedulePicker, setShowSchedulePicker] = useState(false); + const [pollCreatorOpen, setPollCreatorOpen] = useState(false); const [silentReply, setSilentReply] = useState(!mentionInReplies); const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); const isEncrypted = room.hasEncryptionStateEvent(); @@ -1504,6 +1506,16 @@ export const RoomInput = forwardRef( /> } > + setPollCreatorOpen(true)} + variant="SurfaceVariant" + size="300" + radii="300" + title="Create poll" + aria-label="Create poll" + > + + {!hideStickerBtn && ( ( }} /> )} + {pollCreatorOpen && ( + setPollCreatorOpen(false)} /> + )} ); } diff --git a/src/app/hooks/timeline/useTimelineEventRenderer.tsx b/src/app/hooks/timeline/useTimelineEventRenderer.tsx index 3ceba3c9f..ad62934c9 100644 --- a/src/app/hooks/timeline/useTimelineEventRenderer.tsx +++ b/src/app/hooks/timeline/useTimelineEventRenderer.tsx @@ -12,6 +12,7 @@ import type { } from '$types/matrix-sdk'; import type { IImageContent } from '$types/matrix/common'; import { NotificationCountType, RoomEvent, ThreadEvent, EventType } from '$types/matrix-sdk'; +import { M_POLL_START } from 'matrix-js-sdk/lib/@types/polls'; import type { SessionMembershipData } from '$types/matrix-sdk'; import type { HTMLReactParserOptions } from 'html-react-parser'; import type { Opts as LinkifyOpts } from 'linkifyjs'; @@ -55,6 +56,7 @@ import * as customHtmlCss from '$styles/CustomHtml.css'; import { UnreadBadge, UnreadBadgeCenter } from '$components/unread-badge'; import type { ForwardedMessageProps } from '$features/room/message'; import { EncryptedContent, Event, Message, Reactions } from '$features/room/message'; +import { PollContent } from '$features/room/PollContent'; import { useSableCosmetics } from '$hooks/useSableCosmetics'; @@ -629,6 +631,11 @@ export function useTimelineEventRenderer({ )} /> ); + if ( + type === (M_POLL_START.name as string) || + type === (M_POLL_START.altName as string) + ) + return ; if (type === (EventType.RoomMessage as string)) { const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet); let editedNewContent: unknown; @@ -671,6 +678,8 @@ export function useTimelineEventRenderer({ ); }, + [M_POLL_START.name]: (_mEventId, mEvent) => , + [M_POLL_START.altName]: (_mEventId, mEvent) => , [EventType.Sticker]: (mEventId, mEvent, item, timelineSet, collapse) => { const { replyEventId: rawReplyEventId, threadRootId } = mEvent; const replyEventId = diff --git a/src/app/hooks/usePollTally.test.ts b/src/app/hooks/usePollTally.test.ts new file mode 100644 index 000000000..db2fa2a05 --- /dev/null +++ b/src/app/hooks/usePollTally.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect } from 'vitest'; +import type { Relations } from '$types/matrix-sdk'; +import type { MatrixEvent } from '$types/matrix-sdk'; +import { M_POLL_RESPONSE } from 'matrix-js-sdk/lib/@types/polls'; +import type { PollAnswer } from 'matrix-js-sdk/lib/@types/polls'; +import { tallyCounts } from '$hooks/usePollTally'; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function makeRelations(events: Partial[]): Relations { + return { + getRelations: () => events as MatrixEvent[], + } as unknown as Relations; +} + +function makeVote( + sender: string, + answerIds: string[], + ts = 1000 +): Partial { + return { + getSender: () => sender, + getTs: () => ts, + getContent: (() => ({ + [M_POLL_RESPONSE.name]: { answers: answerIds }, + })) as MatrixEvent['getContent'], + }; +} + +const ANSWERS: PollAnswer[] = [ + { id: 'a', body: 'Option A', mimetype: 'text/plain' } as unknown as PollAnswer, + { id: 'b', body: 'Option B', mimetype: 'text/plain' } as unknown as PollAnswer, + { id: 'c', body: 'Option C', mimetype: 'text/plain' } as unknown as PollAnswer, +]; + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe('tallyCounts', () => { + it('returns zero counts and no voters for empty relations', () => { + const result = tallyCounts(ANSWERS, makeRelations([]), '@me:example.com', 1); + expect(result.totalVoters).toBe(0); + expect(result.counts.get('a')).toBe(0); + expect(result.myAnswers).toEqual([]); + }); + + it('counts a single vote correctly', () => { + const rel = makeRelations([makeVote('@alice:example.com', ['a'])]); + const result = tallyCounts(ANSWERS, rel, '@me:example.com', 1); + expect(result.counts.get('a')).toBe(1); + expect(result.counts.get('b')).toBe(0); + expect(result.totalVoters).toBe(1); + }); + + it('only counts the last vote per user', () => { + const rel = makeRelations([ + makeVote('@alice:example.com', ['a'], 1000), + makeVote('@alice:example.com', ['b'], 2000), // later — should win + ]); + const result = tallyCounts(ANSWERS, rel, '@me:example.com', 1); + expect(result.counts.get('a')).toBe(0); + expect(result.counts.get('b')).toBe(1); + expect(result.totalVoters).toBe(1); + }); + + it('ignores invalid answer IDs (not in poll answers)', () => { + const rel = makeRelations([makeVote('@alice:example.com', ['z'])]); + const result = tallyCounts(ANSWERS, rel, '@me:example.com', 1); + expect(result.totalVoters).toBe(0); + expect([...result.counts.values()].every((v) => v === 0)).toBe(true); + }); + + it('tracks the current user vote in myAnswers', () => { + const rel = makeRelations([makeVote('@me:example.com', ['c'])]); + const result = tallyCounts(ANSWERS, rel, '@me:example.com', 1); + expect(result.myAnswers).toEqual(['c']); + }); + + it('supports multi-select up to max_selections', () => { + const rel = makeRelations([makeVote('@alice:example.com', ['a', 'b', 'c'])]); + // max_selections = 2 → only first 2 are kept + const result = tallyCounts(ANSWERS, rel, '@me:example.com', 2); + expect(result.counts.get('a')).toBe(1); + expect(result.counts.get('b')).toBe(1); + expect(result.counts.get('c')).toBe(0); + }); + + it('handles null/undefined relations gracefully', () => { + const result = tallyCounts(ANSWERS, null, '@me:example.com', 1); + expect(result.totalVoters).toBe(0); + }); + + it('counts multiple distinct voters independently', () => { + const rel = makeRelations([ + makeVote('@alice:example.com', ['a']), + makeVote('@bob:example.com', ['a']), + makeVote('@carol:example.com', ['b']), + ]); + const result = tallyCounts(ANSWERS, rel, '@me:example.com', 1); + expect(result.counts.get('a')).toBe(2); + expect(result.counts.get('b')).toBe(1); + expect(result.totalVoters).toBe(3); + }); + + it('treats empty answers array as a spoil (abstain) — not counted in totalVoters', () => { + const rel = makeRelations([makeVote('@alice:example.com', [])]); + const result = tallyCounts(ANSWERS, rel, '@me:example.com', 1); + expect(result.totalVoters).toBe(0); + }); +}); diff --git a/src/app/hooks/usePollTally.ts b/src/app/hooks/usePollTally.ts new file mode 100644 index 000000000..ff4e6d01d --- /dev/null +++ b/src/app/hooks/usePollTally.ts @@ -0,0 +1,78 @@ +import type { Relations } from '$types/matrix-sdk'; +import type { PollAnswer } from 'matrix-js-sdk/lib/@types/polls'; +import { M_POLL_RESPONSE } from 'matrix-js-sdk/lib/@types/polls'; + +export type PollTally = { + /** Map from answerId → vote count (deduplicated to last vote per user) */ + counts: Map; + /** Total number of users who cast at least one valid answer */ + totalVoters: number; + /** The current user's selected answer IDs (empty = not voted) */ + myAnswers: string[]; +}; + +/** + * Pure function — tallies poll votes from a Relations object. + * + * Rules per MSC3381: + * - Only the last response per user is counted. + * - Answers that don't exist in the poll's answer list are ignored (spoiled). + * - A response with an empty answers array is a deliberate spoil (abstain). + */ +export function tallyCounts( + answers: PollAnswer[], + relations: Relations | null | undefined, + myUserId: string, + maxSelections: number +): PollTally { + const validIds = new Set(answers.map((a) => a.id)); + const answerIds = answers.map((a) => a.id); + + // Map userId → their last response's answer IDs (already validated) + const lastVoteByUser = new Map(); + + const events = relations?.getRelations() ?? []; + + // Sort ascending so iterating gives chronological order; last write wins + const sorted = [...events].sort((a, b) => a.getTs() - b.getTs()); + + for (const event of sorted) { + const sender = event.getSender(); + if (!sender) continue; + + const content = event.getContent(); + // Support both stable (m.poll.response) and unstable (org.matrix.msc3381.poll.response) keys + const responsePart = + (content[M_POLL_RESPONSE.name] as { answers?: unknown } | undefined) ?? + (content[M_POLL_RESPONSE.altName] as { answers?: unknown } | undefined); + + if (!responsePart || !Array.isArray(responsePart.answers)) { + continue; + } + + const rawAnswers = responsePart.answers as unknown[]; + // Filter to only valid answer IDs; enforce max_selections limit + const validAnswers = (rawAnswers.filter((id) => typeof id === 'string' && validIds.has(id)) as string[]).slice( + 0, + Math.max(1, maxSelections) + ); + + lastVoteByUser.set(sender, validAnswers); + } + + const counts = new Map(answerIds.map((id) => [id, 0])); + let myAnswers: string[] = []; + + for (const [userId, selectedIds] of lastVoteByUser) { + for (const id of selectedIds) { + counts.set(id, (counts.get(id) ?? 0) + 1); + } + if (userId === myUserId) { + myAnswers = selectedIds; + } + } + + const totalVoters = Array.from(lastVoteByUser.values()).filter((ids) => ids.length > 0).length; + + return { counts, totalVoters, myAnswers }; +} diff --git a/vitest.config.ts b/vitest.config.ts index fedea1151..2db98111c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -26,6 +26,7 @@ export default defineConfig({ APP_VERSION: JSON.stringify('test'), BUILD_HASH: JSON.stringify(''), IS_RELEASE_TAG: JSON.stringify(false), + INJECTED_EXPERIMENT_FLAGS: JSON.stringify({}), }, test: { environment: 'jsdom', From 993956e303dae7ee896b057c6e7e7dc8457101a0 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 2 May 2026 15:54:59 -0400 Subject: [PATCH 59/69] fix: add jsdom URL + INJECTED_EXPERIMENT_FLAGS to vitest config --- vitest.config.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/vitest.config.ts b/vitest.config.ts index fedea1151..26d0f6e5d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -26,9 +26,15 @@ export default defineConfig({ APP_VERSION: JSON.stringify('test'), BUILD_HASH: JSON.stringify(''), IS_RELEASE_TAG: JSON.stringify(false), + INJECTED_EXPERIMENT_FLAGS: JSON.stringify({}), }, test: { environment: 'jsdom', + environmentOptions: { + jsdom: { + url: 'http://localhost/', + }, + }, globals: true, setupFiles: ['./src/test/setup.ts'], include: ['src/**/*.{test,spec}.{ts,tsx}'], From 4f4f959bc9db8ebb2a1d1ee3155e9c6f83b06a7e Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 2 May 2026 15:56:04 -0400 Subject: [PATCH 60/69] fix: add jsdom URL + INJECTED_EXPERIMENT_FLAGS to vitest config --- vitest.config.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/vitest.config.ts b/vitest.config.ts index fedea1151..26d0f6e5d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -26,9 +26,15 @@ export default defineConfig({ APP_VERSION: JSON.stringify('test'), BUILD_HASH: JSON.stringify(''), IS_RELEASE_TAG: JSON.stringify(false), + INJECTED_EXPERIMENT_FLAGS: JSON.stringify({}), }, test: { environment: 'jsdom', + environmentOptions: { + jsdom: { + url: 'http://localhost/', + }, + }, globals: true, setupFiles: ['./src/test/setup.ts'], include: ['src/**/*.{test,spec}.{ts,tsx}'], From 398ba13e59773ba26037f1794a304129659bb86a Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 2 May 2026 15:59:06 -0400 Subject: [PATCH 61/69] =?UTF-8?q?feat:=20presence=20auto-idle=20=E2=80=94?= =?UTF-8?q?=20set=20unavailable=20after=205=20min=20inactivity=20or=20tab?= =?UTF-8?q?=20hidden?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/features/settings/general/General.tsx | 17 ++++++ src/app/pages/client/ClientNonUIFeatures.tsx | 59 +++++++++++++++++++ src/app/state/settings.ts | 2 + vitest.config.ts | 6 ++ 4 files changed, 84 insertions(+) diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 061953d14..89e7286d4 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -419,6 +419,7 @@ function Editor({ isMobile }: Readonly<{ isMobile: boolean }>) { const [hideActivity, setHideActivity] = useSetting(settingsAtom, 'hideActivity'); const [hideReads, setHideReads] = useSetting(settingsAtom, 'hideReads'); const [sendPresence, setSendPresence] = useSetting(settingsAtom, 'sendPresence'); + const [autoIdlePresence, setAutoIdlePresence] = useSetting(settingsAtom, 'autoIdlePresence'); const [mentionInReplies, setMentionInReplies] = useSetting(settingsAtom, 'mentionInReplies'); return ( @@ -475,6 +476,22 @@ function Editor({ isMobile }: Readonly<{ isMobile: boolean }>) { after={} /> + {sendPresence && ( + + + } + /> + + )} { // Classic sync: set_presence query param on every /sync poll. @@ -853,6 +854,64 @@ function PresenceFeature() { getSlidingSyncManager(mx)?.setPresenceEnabled(sendPresence); }, [mx, sendPresence]); + // Auto-idle: set presence to unavailable after 5 minutes of inactivity or + // when the tab is hidden, and restore online on activity. + useEffect(() => { + if (!sendPresence || !autoIdlePresence) return undefined; + + const IDLE_TIMEOUT_MS = 5 * 60 * 1000; + let idleTimer: ReturnType | undefined; + let isIdle = false; + + const goOnline = () => { + if (!isIdle) return; + isIdle = false; + mx.setPresence({ presence: 'online' }).catch(() => {}); + }; + + const goIdle = () => { + if (isIdle) return; + isIdle = true; + mx.setPresence({ presence: 'unavailable' }).catch(() => {}); + }; + + const resetTimer = () => { + goOnline(); + clearTimeout(idleTimer); + idleTimer = setTimeout(goIdle, IDLE_TIMEOUT_MS); + }; + + const handleVisibilityChange = () => { + if (document.visibilityState === 'hidden') { + clearTimeout(idleTimer); + goIdle(); + } else { + resetTimer(); + } + }; + + const ACTIVITY_EVENTS: (keyof DocumentEventMap)[] = [ + 'mousemove', + 'keydown', + 'pointerdown', + 'scroll', + ]; + + ACTIVITY_EVENTS.forEach((e) => document.addEventListener(e, resetTimer, { passive: true })); + document.addEventListener('visibilitychange', handleVisibilityChange); + resetTimer(); + + return () => { + clearTimeout(idleTimer); + ACTIVITY_EVENTS.forEach((e) => document.removeEventListener(e, resetTimer)); + document.removeEventListener('visibilitychange', handleVisibilityChange); + // Restore online when feature is disabled + if (isIdle) { + mx.setPresence({ presence: 'online' }).catch(() => {}); + } + }; + }, [mx, sendPresence, autoIdlePresence]); + return null; } diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 83532c673..2e2f408fe 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -93,6 +93,7 @@ export interface Settings { // Sable features! sendPresence: boolean; + autoIdlePresence: boolean; mobileGestures: boolean; rightSwipeAction: RightSwipeAction; hideMembershipInReadOnly: boolean; @@ -194,6 +195,7 @@ const defaultSettings: Settings = { // Sable features! sendPresence: true, + autoIdlePresence: true, mobileGestures: true, rightSwipeAction: RightSwipeAction.Reply, hideMembershipInReadOnly: true, diff --git a/vitest.config.ts b/vitest.config.ts index fedea1151..26d0f6e5d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -26,9 +26,15 @@ export default defineConfig({ APP_VERSION: JSON.stringify('test'), BUILD_HASH: JSON.stringify(''), IS_RELEASE_TAG: JSON.stringify(false), + INJECTED_EXPERIMENT_FLAGS: JSON.stringify({}), }, test: { environment: 'jsdom', + environmentOptions: { + jsdom: { + url: 'http://localhost/', + }, + }, globals: true, setupFiles: ['./src/test/setup.ts'], include: ['src/**/*.{test,spec}.{ts,tsx}'], From 8972f79a23ca9b3479b71815b40ec2e6ce6b4a65 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 2 May 2026 16:03:10 -0400 Subject: [PATCH 62/69] =?UTF-8?q?feat:=20room=20message=20preview=20?= =?UTF-8?q?=E2=80=94=20last=20message=20shown=20under=20room=20name=20in?= =?UTF-8?q?=20sidebar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/features/room-nav/RoomNavItem.tsx | 19 ++++ src/app/features/settings/general/General.tsx | 15 +++ src/app/hooks/useRoomLastMessagePreview.ts | 91 +++++++++++++++++++ src/app/state/settings.ts | 2 + vitest.config.ts | 4 + 5 files changed, 131 insertions(+) create mode 100644 src/app/hooks/useRoomLastMessagePreview.ts diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index a372b975a..46a1d64b0 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -72,6 +72,7 @@ import { useAutoDiscoveryInfo } from '$hooks/useAutoDiscoveryInfo'; import { livekitSupport } from '$hooks/useLivekitSupport'; import { Presence, useUserPresence } from '$hooks/useUserPresence'; import { AvatarPresence, PresenceBadge } from '$components/presence'; +import { useRoomLastMessagePreview } from '$hooks/useRoomLastMessagePreview'; import { RoomNavUser } from './RoomNavUser'; /** @@ -292,6 +293,9 @@ export function RoomNavItem({ const getRoomTopic = useRoomTopic(room); const roomTopic = direct ? ((customDMCards && getRoomTopic) ?? presence?.status) : undefined; + const [showRoomMessagePreview] = useSetting(settingsAtom, 'showRoomMessagePreview'); + const lastMessagePreview = useRoomLastMessagePreview(room, !direct); + const { navigateRoom } = useRoomNavigate(); const navigate = useNavigate(); const screenSize = useScreenSizeContext(); @@ -453,6 +457,21 @@ export function RoomNavItem({ {roomTopic} )} + {!roomTopic && showRoomMessagePreview && lastMessagePreview && ( + + {lastMessagePreview.senderDisplayName + ? `${lastMessagePreview.senderDisplayName}: ${lastMessagePreview.body}` + : lastMessagePreview.body} + + )} {!optionsVisible && !unread && !selected && typingMember.length > 0 && ( diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 061953d14..c3479e429 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -419,6 +419,7 @@ function Editor({ isMobile }: Readonly<{ isMobile: boolean }>) { const [hideActivity, setHideActivity] = useSetting(settingsAtom, 'hideActivity'); const [hideReads, setHideReads] = useSetting(settingsAtom, 'hideReads'); const [sendPresence, setSendPresence] = useSetting(settingsAtom, 'sendPresence'); + const [showRoomMessagePreview, setShowRoomMessagePreview] = useSetting(settingsAtom, 'showRoomMessagePreview'); const [mentionInReplies, setMentionInReplies] = useSetting(settingsAtom, 'mentionInReplies'); return ( @@ -475,6 +476,20 @@ function Editor({ isMobile }: Readonly<{ isMobile: boolean }>) { after={} /> + + + } + /> + MAX_PREVIEW_LEN ? `${text.slice(0, MAX_PREVIEW_LEN)}…` : text; +} + +function getEventPreviewBody(event: MatrixEvent): string | null { + const type = event.getType(); + const content = event.getContent() as Record; + + if (type === (EventType.RoomMessage as string)) { + const msgtype = content.msgtype as string | undefined; + const body = content.body as string | undefined; + if (!body) return null; + + if (msgtype === 'm.image') return '📷 Image'; + if (msgtype === 'm.video') return '🎬 Video'; + if (msgtype === 'm.audio') return '🎵 Audio'; + if (msgtype === 'm.file') return '📎 File'; + if (msgtype === 'm.sticker' || type === (EventType.Sticker as string)) return '🎭 Sticker'; + + return truncate(body); + } + + if (type === (EventType.Sticker as string)) return '🎭 Sticker'; + if (type === (EventType.RoomMessageEncrypted as string)) return '🔒 Encrypted message'; + + return null; +} + +type RoomLastMessagePreview = { + senderId: string; + senderDisplayName: string; + body: string; + ts: number; +} | null; + +export function useRoomLastMessagePreview( + room: Room, + includeSender: boolean +): RoomLastMessagePreview { + const [preview, setPreview] = useState(() => + buildPreview(room, includeSender) + ); + + useEffect(() => { + setPreview(buildPreview(room, includeSender)); + + const update = () => setPreview(buildPreview(room, includeSender)); + room.on(RoomEvent.Timeline, update); + room.on(RoomEvent.Redaction, update); + return () => { + room.off(RoomEvent.Timeline, update); + room.off(RoomEvent.Redaction, update); + }; + }, [room, includeSender]); + + return preview; +} + +function buildPreview(room: Room, includeSender: boolean): RoomLastMessagePreview { + const events = room.getLiveTimeline().getEvents(); + + for (let i = events.length - 1; i >= 0; i -= 1) { + const event = events[i]; + if (!event) continue; + if (event.isRedacted()) continue; + + const body = getEventPreviewBody(event); + if (!body) continue; + + const senderId = event.getSender() ?? ''; + const member = room.getMember(senderId); + const senderDisplayName = includeSender + ? (member?.name ?? senderId.split(':')[0]?.slice(1) ?? senderId) + : ''; + + return { + senderId, + senderDisplayName, + body, + ts: event.getTs(), + }; + } + + return null; +} diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 83532c673..e785b7ba0 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -93,6 +93,7 @@ export interface Settings { // Sable features! sendPresence: boolean; + showRoomMessagePreview: boolean; mobileGestures: boolean; rightSwipeAction: RightSwipeAction; hideMembershipInReadOnly: boolean; @@ -194,6 +195,7 @@ const defaultSettings: Settings = { // Sable features! sendPresence: true, + showRoomMessagePreview: true, mobileGestures: true, rightSwipeAction: RightSwipeAction.Reply, hideMembershipInReadOnly: true, diff --git a/vitest.config.ts b/vitest.config.ts index fedea1151..e786534ef 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -26,9 +26,13 @@ export default defineConfig({ APP_VERSION: JSON.stringify('test'), BUILD_HASH: JSON.stringify(''), IS_RELEASE_TAG: JSON.stringify(false), + INJECTED_EXPERIMENT_FLAGS: JSON.stringify({}), }, test: { environment: 'jsdom', + environmentOptions: { + jsdom: { url: 'http://localhost/' }, + }, globals: true, setupFiles: ['./src/test/setup.ts'], include: ['src/**/*.{test,spec}.{ts,tsx}'], From 3d8927d4558fcc035b4e60df1ab3a0a7cbeb06cd Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 2 May 2026 16:10:58 -0400 Subject: [PATCH 63/69] =?UTF-8?q?feat:=20message=20bookmarks=20=E2=80=94?= =?UTF-8?q?=20bookmark=20events=20via=20moe.sable.app.bookmarks=20account?= =?UTF-8?q?=20data?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/features/bookmarks/BookmarksPanel.tsx | 193 ++++++++++++++++++ src/app/features/room/message/Message.tsx | 38 ++++ src/app/hooks/useBookmarks.ts | 59 ++++++ src/app/pages/Router.tsx | 2 + src/app/pages/client/SidebarNav.tsx | 2 + src/app/pages/client/sidebar/BookmarksTab.tsx | 20 ++ src/app/pages/client/sidebar/index.ts | 1 + src/app/state/bookmarksPanelAtom.ts | 3 + src/types/matrix-sdk-events.d.ts | 1 + src/types/matrix/accountData.ts | 1 + vitest.config.ts | 4 + 11 files changed, 324 insertions(+) create mode 100644 src/app/features/bookmarks/BookmarksPanel.tsx create mode 100644 src/app/hooks/useBookmarks.ts create mode 100644 src/app/pages/client/sidebar/BookmarksTab.tsx create mode 100644 src/app/state/bookmarksPanelAtom.ts diff --git a/src/app/features/bookmarks/BookmarksPanel.tsx b/src/app/features/bookmarks/BookmarksPanel.tsx new file mode 100644 index 000000000..e300e6762 --- /dev/null +++ b/src/app/features/bookmarks/BookmarksPanel.tsx @@ -0,0 +1,193 @@ +import { + Avatar, + Box, + Dialog, + Header, + Icon, + IconButton, + Icons, + Overlay, + OverlayBackdrop, + OverlayCenter, + Scroll, + Text, + config, +} from 'folds'; +import FocusTrap from 'focus-trap-react'; +import { useAtom } from 'jotai'; +import { useBookmarks, toggleBookmark } from '$hooks/useBookmarks'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { bookmarksPanelAtom } from '$state/bookmarksPanelAtom'; +import { useRoomNavigate } from '$hooks/useRoomNavigate'; +import { useGetRoom } from '$hooks/useGetRoom'; +import { allRoomsAtom } from '$state/room-list/roomList'; +import { useAllJoinedRoomsSet } from '$hooks/useGetRoom'; +import { getRoomAvatarUrl, getDirectRoomAvatarUrl } from '$utils/room'; +import { nameInitials } from '$utils/common'; +import { RoomAvatar } from '$components/room-avatar'; +import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; +import { stopPropagation } from '$utils/keyboard'; + +type BookmarksPanelProps = { + requestClose: () => void; +}; + +function BookmarksPanel({ requestClose }: BookmarksPanelProps) { + const mx = useMatrixClient(); + const bookmarks = useBookmarks(); + const { navigateRoom } = useRoomNavigate(); + const useAuthentication = useMediaAuthentication(); + const allRoomsSet = useAllJoinedRoomsSet(); + const getRoom = useGetRoom(allRoomsSet); + + const handleOpen = (roomId: string, eventId: string) => { + navigateRoom(roomId, eventId); + requestClose(); + }; + + const handleRemove = (roomId: string, eventId: string) => { + toggleBookmark(mx, roomId, eventId, bookmarks).catch(() => {}); + }; + + return ( + +
+ + + Bookmarks + + + + +
+ + + + {bookmarks.length === 0 && ( + + + + No bookmarks yet + + + Bookmark messages from the message menu to save them here. + + + )} + {bookmarks.map((bookmark) => { + const room = getRoom(bookmark.room_id); + const event = room?.getTimelineForEvent(bookmark.event_id) + ?.getEvents() + .find((e) => e.getId() === bookmark.event_id); + + const senderDisplayName = event + ? (room?.getMember(event.getSender() ?? '')?.name ?? event.getSender() ?? 'Unknown') + : 'Unknown'; + const body = (event?.getContent() as Record | undefined)?.body as string | undefined; + const preview = body ? (body.length > 80 ? `${body.slice(0, 80)}…` : body) : 'Encrypted or unknown message'; + + return ( + handleOpen(bookmark.room_id, bookmark.event_id)} + as="button" + > + + {room ? ( + ( + + {nameInitials(room.name)} + + )} + /> + ) : ( + + )} + + + + + {room?.name ?? bookmark.room_id} + + + {senderDisplayName} + + + + {preview} + + + { + e.stopPropagation(); + handleRemove(bookmark.room_id, bookmark.event_id); + }} + aria-label="Remove bookmark" + > + + + + ); + })} + + + +
+ ); +} + +export function BookmarksPanelRenderer() { + const [opened, setOpen] = useAtom(bookmarksPanelAtom); + + if (!opened) return null; + + const close = () => setOpen(false); + + return ( + }> + + + + + + + ); +} diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 7bc04b46e..4d131bf37 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -66,6 +66,7 @@ import { MessageSourceCodeItem } from '$components/message/modals/MessageSource' import { MessageForwardItem } from '$components/message/modals/MessageForward'; import { MessageDeleteItem } from '$components/message/modals/MessageDelete'; import { MessageReportItem } from '$components/message/modals/MessageReport'; +import { useBookmarks, isBookmarked, toggleBookmark } from '$hooks/useBookmarks'; import { filterPronounsByLanguage, getParsedPronouns } from '$utils/pronouns'; import type { PronounSet } from '$utils/pronouns'; import { useMentionClickHandler } from '$hooks/useMentionClickHandler'; @@ -193,6 +194,41 @@ export const MessagePinItem = as< ); }); +// Bookmark message item +export const MessageBookmarkItem = as< + 'button', + { + room: Room; + mEvent: MatrixEvent; + onClose?: () => void; + } +>(({ room, mEvent, onClose, ...props }, ref) => { + const mx = useMatrixClient(); + const bookmarks = useBookmarks(); + const eventId = mEvent.getId() ?? ''; + const bookmarked = isBookmarked(bookmarks, eventId); + + const handleToggle = () => { + toggleBookmark(mx, room.roomId, eventId, bookmarks).catch(() => {}); + onClose?.(); + }; + + return ( + } + radii="300" + onClick={handleToggle} + {...props} + ref={ref} + > + + {bookmarked ? 'Remove Bookmark' : 'Bookmark'} + + + ); +}); + export type ForwardedMessageProps = { originalTimestamp: number; isForwarded: boolean; @@ -1100,6 +1136,7 @@ function MessageInternal( )} + {canPinEvent && ( )} @@ -1438,6 +1475,7 @@ export const Event = as<'div', EventProps>( )} + {((!mEvent.isRedacted() && canDelete && !stateEvent) || (mEvent.getSender() !== mx.getUserId() && !stateEvent)) && ( diff --git a/src/app/hooks/useBookmarks.ts b/src/app/hooks/useBookmarks.ts new file mode 100644 index 000000000..bda021e80 --- /dev/null +++ b/src/app/hooks/useBookmarks.ts @@ -0,0 +1,59 @@ +import { useEffect, useState } from 'react'; +import type { MatrixClient, MatrixEvent } from '$types/matrix-sdk'; +import { ClientEvent } from '$types/matrix-sdk'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { CustomAccountDataEvent } from '$types/matrix/accountData'; + +export type BookmarkEntry = { + event_id: string; + room_id: string; +}; + +export type BookmarksContent = { + bookmarks: BookmarkEntry[]; +}; + +function readBookmarks(mx: MatrixClient): BookmarkEntry[] { + const event = mx.getAccountData(CustomAccountDataEvent.SableBookmarks); + if (!event) return []; + const content = event.getContent(); + return Array.isArray(content.bookmarks) ? content.bookmarks : []; +} + +export function useBookmarks(): BookmarkEntry[] { + const mx = useMatrixClient(); + const [bookmarks, setBookmarks] = useState(() => readBookmarks(mx)); + + useEffect(() => { + setBookmarks(readBookmarks(mx)); + const handler = (event: MatrixEvent) => { + if (event.getType() === (CustomAccountDataEvent.SableBookmarks as string)) { + const content = event.getContent(); + setBookmarks(Array.isArray(content.bookmarks) ? content.bookmarks : []); + } + }; + mx.on(ClientEvent.AccountData, handler); + return () => { + mx.off(ClientEvent.AccountData, handler); + }; + }, [mx]); + + return bookmarks; +} + +export function isBookmarked(bookmarks: BookmarkEntry[], eventId: string): boolean { + return bookmarks.some((b) => b.event_id === eventId); +} + +export async function toggleBookmark( + mx: MatrixClient, + roomId: string, + eventId: string, + currentBookmarks: BookmarkEntry[] +): Promise { + const exists = isBookmarked(currentBookmarks, eventId); + const updated: BookmarkEntry[] = exists + ? currentBookmarks.filter((b) => b.event_id !== eventId) + : [...currentBookmarks, { event_id: eventId, room_id: roomId }]; + await mx.setAccountData(CustomAccountDataEvent.SableBookmarks, { bookmarks: updated }); +} diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index aec62cc86..490113e6f 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -29,6 +29,7 @@ import { getFallbackSession, MATRIX_SESSIONS_KEY } from '$state/sessions'; import { getLocalStorageItem } from '$state/utils/atomWithLocalStorage'; import { NotificationJumper } from '$hooks/useNotificationJumper'; import { SearchModalRenderer } from '$features/search'; +import { BookmarksPanelRenderer } from '$features/bookmarks/BookmarksPanel'; import { GlobalKeyboardShortcuts } from '$components/GlobalKeyboardShortcuts'; import { CallEmbedProvider } from '$components/CallEmbedProvider'; import { AuthLayout, Login, Register, ResetPassword } from './auth'; @@ -191,6 +192,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) + diff --git a/src/app/pages/client/SidebarNav.tsx b/src/app/pages/client/SidebarNav.tsx index 6cf397807..e8be0f616 100644 --- a/src/app/pages/client/SidebarNav.tsx +++ b/src/app/pages/client/SidebarNav.tsx @@ -16,6 +16,7 @@ import { UnverifiedTab, SearchTab, AccountSwitcherTab, + BookmarksTab, } from './sidebar'; import { CreateTab } from './sidebar/CreateTab'; @@ -133,6 +134,7 @@ export function SidebarNav() { sticky={ + diff --git a/src/app/pages/client/sidebar/BookmarksTab.tsx b/src/app/pages/client/sidebar/BookmarksTab.tsx new file mode 100644 index 000000000..089d96966 --- /dev/null +++ b/src/app/pages/client/sidebar/BookmarksTab.tsx @@ -0,0 +1,20 @@ +import { Icon, Icons } from 'folds'; +import { useAtom } from 'jotai'; +import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '$components/sidebar'; +import { bookmarksPanelAtom } from '$state/bookmarksPanelAtom'; + +export function BookmarksTab() { + const [opened, setOpen] = useAtom(bookmarksPanelAtom); + + return ( + + + {(triggerRef) => ( + setOpen((o) => !o)}> + + + )} + + + ); +} diff --git a/src/app/pages/client/sidebar/index.ts b/src/app/pages/client/sidebar/index.ts index 08a9099c0..27bbfef75 100644 --- a/src/app/pages/client/sidebar/index.ts +++ b/src/app/pages/client/sidebar/index.ts @@ -7,3 +7,4 @@ export * from './ExploreTab'; export * from './UnverifiedTab'; export * from './SearchTab'; export * from './AccountSwitcherTab'; +export * from './BookmarksTab'; diff --git a/src/app/state/bookmarksPanelAtom.ts b/src/app/state/bookmarksPanelAtom.ts new file mode 100644 index 000000000..0981751c0 --- /dev/null +++ b/src/app/state/bookmarksPanelAtom.ts @@ -0,0 +1,3 @@ +import { atom } from 'jotai'; + +export const bookmarksPanelAtom = atom(false); diff --git a/src/types/matrix-sdk-events.d.ts b/src/types/matrix-sdk-events.d.ts index 1f6ad2f83..ae108811e 100644 --- a/src/types/matrix-sdk-events.d.ts +++ b/src/types/matrix-sdk-events.d.ts @@ -49,5 +49,6 @@ declare module 'matrix-js-sdk/lib/@types/event' { 'im.ponies.emote_rooms': EmoteRoomsContent; 'moe.sable.app.nicknames': Record; 'moe.sable.app.settings': Record; + 'moe.sable.app.bookmarks': { bookmarks: Array<{ event_id: string; room_id: string }> }; } } diff --git a/src/types/matrix/accountData.ts b/src/types/matrix/accountData.ts index 283859cd3..7a31a80af 100644 --- a/src/types/matrix/accountData.ts +++ b/src/types/matrix/accountData.ts @@ -8,6 +8,7 @@ export const CustomAccountDataEvent = { // because of a mistake hasn't been renamed in time SablePerProfileMessageProfiles: 'fyi.cisnt.permessageprofile', SableSettings: 'moe.sable.app.settings', + SableBookmarks: 'moe.sable.app.bookmarks', } as const; export type CustomAccountDataEvent = (typeof CustomAccountDataEvent)[keyof typeof CustomAccountDataEvent]; diff --git a/vitest.config.ts b/vitest.config.ts index fedea1151..e786534ef 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -26,9 +26,13 @@ export default defineConfig({ APP_VERSION: JSON.stringify('test'), BUILD_HASH: JSON.stringify(''), IS_RELEASE_TAG: JSON.stringify(false), + INJECTED_EXPERIMENT_FLAGS: JSON.stringify({}), }, test: { environment: 'jsdom', + environmentOptions: { + jsdom: { url: 'http://localhost/' }, + }, globals: true, setupFiles: ['./src/test/setup.ts'], include: ['src/**/*.{test,spec}.{ts,tsx}'], From 8a46ee13360a683f8d184b0fc54ee589bcdc5f69 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 2 May 2026 17:40:05 -0400 Subject: [PATCH 64/69] fix(tests): guard localStorage access against Node.js 22 built-in stub - debugLogger: wrap constructor localStorage.getItem in try/catch - settings: wrap getSettings/setSettings in try/catch - test/setup.ts: install in-memory localStorage polyfill before jsdom initialises so module-level singleton access resolves correctly All 60 test files (570 tests) now pass on Node.js 22. --- src/app/state/settings.ts | 40 +++++++++++++++++++++--------------- src/app/utils/debugLogger.ts | 11 ++++++++-- src/test/setup.ts | 28 +++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 18 deletions(-) diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 5f5929a7a..6d514b025 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -231,27 +231,35 @@ const defaultSettings: Settings = { }; export const getSettings = () => { - const settings = localStorage.getItem(STORAGE_KEY); - if (settings === null) return defaultSettings; + try { + const settings = localStorage.getItem(STORAGE_KEY); + if (settings === null) return defaultSettings; - // migration for old keys - // monochrome -> saturation - const parsed = JSON.parse(settings); - if (parsed.monochromeMode === true && parsed.saturationLevel === undefined) { - parsed.saturationLevel = 0; - } else if (parsed.monochromeMode === false && parsed.saturationLevel === undefined) { - parsed.saturationLevel = 100; - } - delete parsed.monochromeMode; + // migration for old keys + // monochrome -> saturation + const parsed = JSON.parse(settings); + if (parsed.monochromeMode === true && parsed.saturationLevel === undefined) { + parsed.saturationLevel = 0; + } else if (parsed.monochromeMode === false && parsed.saturationLevel === undefined) { + parsed.saturationLevel = 100; + } + delete parsed.monochromeMode; - return { - ...defaultSettings, - ...(parsed as Settings), - }; + return { + ...defaultSettings, + ...(parsed as Settings), + }; + } catch { + return defaultSettings; + } }; export const setSettings = (settings: Settings) => { - localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); + } catch { + // Storage may be unavailable (e.g. private browsing quota exceeded) + } }; const baseSettings = atom(getSettings()); diff --git a/src/app/utils/debugLogger.ts b/src/app/utils/debugLogger.ts index 88f787328..e727fa5ab 100644 --- a/src/app/utils/debugLogger.ts +++ b/src/app/utils/debugLogger.ts @@ -47,8 +47,15 @@ class DebugLoggerService { private sentryStats = { errors: 0, warnings: 0 }; constructor() { - // Check if debug logging is enabled from localStorage - this.enabled = localStorage.getItem('sable_internal_debug') === '1'; + // Check if debug logging is enabled from localStorage. + // Guarded with try/catch because this module is instantiated as a singleton + // at import time, which in Node.js 22+ can run before a jsdom environment + // is ready (Node has a built-in but non-functional localStorage stub). + try { + this.enabled = localStorage.getItem('sable_internal_debug') === '1'; + } catch { + this.enabled = false; + } // Load disabled breadcrumb categories try { const stored = localStorage.getItem(BREADCRUMB_DISABLED_KEY); diff --git a/src/test/setup.ts b/src/test/setup.ts index 7b0828bfa..28c93a12c 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -1 +1,29 @@ import '@testing-library/jest-dom'; + +// Node.js 22+ ships a built-in `localStorage` stub that throws for getItem/setItem +// unless --localstorage-file is supplied at startup. jsdom relies on being able to +// define window.localStorage, but Node's version can prevent that. We install an +// in-memory implementation unconditionally so every test environment starts with a +// working, isolated localStorage regardless of runtime version. +const _store = new Map(); +const _localStorage = { + getItem: (key: string): string | null => _store.get(key) ?? null, + setItem: (key: string, value: string): void => { + _store.set(key, value); + }, + removeItem: (key: string): void => { + _store.delete(key); + }, + clear: (): void => { + _store.clear(); + }, + get length(): number { + return _store.size; + }, + key: (index: number): string | null => [..._store.keys()][index] ?? null, +}; +Object.defineProperty(globalThis, 'localStorage', { + value: _localStorage, + writable: true, + configurable: true, +}); From 3136fbe33478ad114e834116e30b65fda83a5b23 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 2 May 2026 17:54:53 -0400 Subject: [PATCH 65/69] fix(roomToUnread): prevent infinite loop in UnreadNotifications handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RoomEvent.UnreadNotifications is emitted BY room.fixupNotifications(). The handleUnreadNotifications listener was calling getUnreadInfo with applyFixup: true, which calls room.fixupNotifications() again, producing an infinite loop: fixupNotifications → setUnreadNotificationCount → emit → handleUnreadNotifications → getUnreadInfo → fixupNotifications → … Fix: pass applyFixup: false inside the UnreadNotifications handler. The fixup has already run (it's what triggered the event); reading the updated counts directly avoids re-entering the fixup path. --- src/app/state/room/roomToUnread.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/app/state/room/roomToUnread.ts b/src/app/state/room/roomToUnread.ts index 208df289c..3e70f2870 100644 --- a/src/app/state/room/roomToUnread.ts +++ b/src/app/state/room/roomToUnread.ts @@ -311,8 +311,12 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo if (room.isSpaceRoom()) return; if (room.getMyMembership() !== (KnownMembership.Join as string)) return; + // Do NOT pass applyFixup here: RoomEvent.UnreadNotifications is itself fired *by* + // fixupNotifications(), so calling room.fixupNotifications() again from within this + // handler causes infinite recursion (fixupNotifications → setUnread → + // setUnreadNotificationCount → emit → this handler → fixupNotifications → …). const unreadInfo = getUnreadInfo(room, { - applyFixup: shouldApplyUnreadFixup(), + applyFixup: false, mDirects, }); if (unreadInfo.total === 0 && unreadInfo.highlight === 0) { From 3b281fc9c726b40c9260718fb1714ada06a49bb2 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 2 May 2026 19:01:15 -0400 Subject: [PATCH 66/69] fix(bookmarks): migrate to MSC4438 per-event format, add inbox view - useBookmarks: reads org.matrix.msc4438.bookmark. events + index - toggleBookmark: writes/removes individual events + updates index - BookmarksList: shared component for panel and inbox page - Add /inbox/bookmarks/ route with full-page bookmarks view --- src/app/features/bookmarks/BookmarksList.tsx | 137 ++++++++++++++++++ src/app/features/bookmarks/BookmarksPanel.tsx | 4 +- src/app/hooks/router/useInbox.ts | 17 ++- src/app/hooks/useBookmarks.ts | 82 ++++++++--- src/app/pages/Router.tsx | 4 +- src/app/pages/client/inbox/Bookmarks.tsx | 28 ++++ src/app/pages/client/inbox/Inbox.tsx | 29 +++- src/app/pages/client/inbox/index.ts | 1 + src/app/pages/pathUtils.ts | 2 + src/app/pages/paths.ts | 2 + src/types/matrix-sdk-events.d.ts | 5 +- src/types/matrix/accountData.ts | 4 +- 12 files changed, 290 insertions(+), 25 deletions(-) create mode 100644 src/app/features/bookmarks/BookmarksList.tsx create mode 100644 src/app/pages/client/inbox/Bookmarks.tsx diff --git a/src/app/features/bookmarks/BookmarksList.tsx b/src/app/features/bookmarks/BookmarksList.tsx new file mode 100644 index 000000000..0e18279ef --- /dev/null +++ b/src/app/features/bookmarks/BookmarksList.tsx @@ -0,0 +1,137 @@ +import { Avatar, Box, Icon, IconButton, Icons, Scroll, Text, config } from 'folds'; +import { useBookmarks, toggleBookmark } from '$hooks/useBookmarks'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { useRoomNavigate } from '$hooks/useRoomNavigate'; +import { useGetRoom, useAllJoinedRoomsSet } from '$hooks/useGetRoom'; +import { getRoomAvatarUrl } from '$utils/room'; +import { nameInitials } from '$utils/common'; +import { RoomAvatar } from '$components/room-avatar'; +import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; + +type BookmarksListProps = { + onNavigate?: () => void; +}; + +export function BookmarksList({ onNavigate }: BookmarksListProps) { + const mx = useMatrixClient(); + const bookmarks = useBookmarks(); + const { navigateRoom } = useRoomNavigate(); + const useAuthentication = useMediaAuthentication(); + const allRoomsSet = useAllJoinedRoomsSet(); + const getRoom = useGetRoom(allRoomsSet); + + const handleOpen = (roomId: string, eventId: string) => { + navigateRoom(roomId, eventId); + onNavigate?.(); + }; + + const handleRemove = (roomId: string, eventId: string) => { + toggleBookmark(mx, roomId, eventId, bookmarks).catch(() => {}); + }; + + if (bookmarks.length === 0) { + return ( + + + + No bookmarks yet + + + Bookmark messages from the message menu to save them here. + + + ); + } + + return ( + + + {bookmarks.map((bookmark) => { + const room = getRoom(bookmark.room_id); + const event = room + ?.getTimelineForEvent(bookmark.event_id) + ?.getEvents() + .find((e) => e.getId() === bookmark.event_id); + + const senderDisplayName = event + ? (room?.getMember(event.getSender() ?? '')?.name ?? event.getSender() ?? 'Unknown') + : 'Unknown'; + const body = (event?.getContent() as Record | undefined)?.body as + | string + | undefined; + const preview = body + ? body.length > 100 + ? `${body.slice(0, 100)}…` + : body + : 'Encrypted or unknown message'; + + return ( + handleOpen(bookmark.room_id, bookmark.event_id)} + as="button" + > + + {room ? ( + ( + + {nameInitials(room.name)} + + )} + /> + ) : ( + + )} + + + + + {room?.name ?? bookmark.room_id} + + + {senderDisplayName} + + + + {preview} + + + { + e.stopPropagation(); + handleRemove(bookmark.room_id, bookmark.event_id); + }} + aria-label="Remove bookmark" + > + + + + ); + })} + + + ); +} diff --git a/src/app/features/bookmarks/BookmarksPanel.tsx b/src/app/features/bookmarks/BookmarksPanel.tsx index e300e6762..d9b9c0462 100644 --- a/src/app/features/bookmarks/BookmarksPanel.tsx +++ b/src/app/features/bookmarks/BookmarksPanel.tsx @@ -22,12 +22,14 @@ import { useRoomNavigate } from '$hooks/useRoomNavigate'; import { useGetRoom } from '$hooks/useGetRoom'; import { allRoomsAtom } from '$state/room-list/roomList'; import { useAllJoinedRoomsSet } from '$hooks/useGetRoom'; -import { getRoomAvatarUrl, getDirectRoomAvatarUrl } from '$utils/room'; +import { getRoomAvatarUrl } from '$utils/room'; import { nameInitials } from '$utils/common'; import { RoomAvatar } from '$components/room-avatar'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; import { stopPropagation } from '$utils/keyboard'; +export { BookmarksList } from './BookmarksList'; + type BookmarksPanelProps = { requestClose: () => void; }; diff --git a/src/app/hooks/router/useInbox.ts b/src/app/hooks/router/useInbox.ts index 639e16dd4..c19c0cc4b 100644 --- a/src/app/hooks/router/useInbox.ts +++ b/src/app/hooks/router/useInbox.ts @@ -1,5 +1,10 @@ import { useMatch } from 'react-router-dom'; -import { getInboxInvitesPath, getInboxNotificationsPath, getInboxPath } from '$pages/pathUtils'; +import { + getInboxBookmarksPath, + getInboxInvitesPath, + getInboxNotificationsPath, + getInboxPath, +} from '$pages/pathUtils'; export const useInboxSelected = (): boolean => { const match = useMatch({ @@ -30,3 +35,13 @@ export const useInboxInvitesSelected = (): boolean => { return !!match; }; + +export const useInboxBookmarksSelected = (): boolean => { + const match = useMatch({ + path: getInboxBookmarksPath(), + caseSensitive: true, + end: false, + }); + + return !!match; +}; diff --git a/src/app/hooks/useBookmarks.ts b/src/app/hooks/useBookmarks.ts index bda021e80..ea1d0937f 100644 --- a/src/app/hooks/useBookmarks.ts +++ b/src/app/hooks/useBookmarks.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import type { MatrixClient, MatrixEvent } from '$types/matrix-sdk'; import { ClientEvent } from '$types/matrix-sdk'; import { useMatrixClient } from '$hooks/useMatrixClient'; @@ -7,40 +7,78 @@ import { CustomAccountDataEvent } from '$types/matrix/accountData'; export type BookmarkEntry = { event_id: string; room_id: string; + /** MSC4438 bookmark key suffix, e.g. "bmk_a1b2c3d4" */ + id: string; }; -export type BookmarksContent = { - bookmarks: BookmarkEntry[]; -}; +// --------------------------------------------------------------------------- +// MSC4438 helpers +// --------------------------------------------------------------------------- + +const BOOKMARK_PREFIX = CustomAccountDataEvent.MSC4438BookmarkPrefix; // 'org.matrix.msc4438.bookmark.' +const INDEX_KEY = CustomAccountDataEvent.MSC4438BookmarksIndex; // 'org.matrix.msc4438.bookmarks.index' -function readBookmarks(mx: MatrixClient): BookmarkEntry[] { - const event = mx.getAccountData(CustomAccountDataEvent.SableBookmarks); - if (!event) return []; - const content = event.getContent(); +function generateBookmarkId(): string { + // 8 random hex chars, prefixed with "bmk_" + const bytes = new Uint8Array(4); + crypto.getRandomValues(bytes); + return `bmk_${Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join('')}`; +} + +function getIndexIds(mx: MatrixClient): string[] { + const ev = mx.getAccountData(INDEX_KEY); + if (!ev) return []; + const content = ev.getContent<{ bookmarks?: string[] }>(); return Array.isArray(content.bookmarks) ? content.bookmarks : []; } +export function readBookmarks(mx: MatrixClient): BookmarkEntry[] { + const ids = getIndexIds(mx); + const entries: BookmarkEntry[] = []; + for (const id of ids) { + const ev = mx.getAccountData(`${BOOKMARK_PREFIX}${id}`); + if (!ev) continue; + const c = ev.getContent<{ room_id?: string; event_id?: string }>(); + if (c.room_id && c.event_id) { + entries.push({ id, room_id: c.room_id, event_id: c.event_id }); + } + } + return entries; +} + +// --------------------------------------------------------------------------- +// Hook +// --------------------------------------------------------------------------- + export function useBookmarks(): BookmarkEntry[] { const mx = useMatrixClient(); const [bookmarks, setBookmarks] = useState(() => readBookmarks(mx)); + const refresh = useCallback(() => setBookmarks(readBookmarks(mx)), [mx]); + useEffect(() => { - setBookmarks(readBookmarks(mx)); + refresh(); const handler = (event: MatrixEvent) => { - if (event.getType() === (CustomAccountDataEvent.SableBookmarks as string)) { - const content = event.getContent(); - setBookmarks(Array.isArray(content.bookmarks) ? content.bookmarks : []); + const type = event.getType(); + if (type === INDEX_KEY || type.startsWith(BOOKMARK_PREFIX)) { + refresh(); } }; mx.on(ClientEvent.AccountData, handler); return () => { mx.off(ClientEvent.AccountData, handler); }; - }, [mx]); + }, [mx, refresh]); return bookmarks; } +// --------------------------------------------------------------------------- +// Utilities +// --------------------------------------------------------------------------- + export function isBookmarked(bookmarks: BookmarkEntry[], eventId: string): boolean { return bookmarks.some((b) => b.event_id === eventId); } @@ -51,9 +89,17 @@ export async function toggleBookmark( eventId: string, currentBookmarks: BookmarkEntry[] ): Promise { - const exists = isBookmarked(currentBookmarks, eventId); - const updated: BookmarkEntry[] = exists - ? currentBookmarks.filter((b) => b.event_id !== eventId) - : [...currentBookmarks, { event_id: eventId, room_id: roomId }]; - await mx.setAccountData(CustomAccountDataEvent.SableBookmarks, { bookmarks: updated }); + const existing = currentBookmarks.find((b) => b.event_id === eventId); + if (existing) { + // Remove: delete individual event, then update index + await mx.setAccountData(`${BOOKMARK_PREFIX}${existing.id}`, {}); + const newIds = currentBookmarks.filter((b) => b.event_id !== eventId).map((b) => b.id); + await mx.setAccountData(INDEX_KEY, { bookmarks: newIds }); + } else { + // Add: write individual event, then update index + const id = generateBookmarkId(); + await mx.setAccountData(`${BOOKMARK_PREFIX}${id}`, { room_id: roomId, event_id: eventId }); + const newIds = [...currentBookmarks.map((b) => b.id), id]; + await mx.setAccountData(INDEX_KEY, { bookmarks: newIds }); + } } diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index 490113e6f..4a16786ec 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -45,6 +45,7 @@ import { CREATE_PATH_SEGMENT, FEATURED_PATH_SEGMENT, INVITES_PATH_SEGMENT, + BOOKMARKS_PATH_SEGMENT, JOIN_PATH_SEGMENT, LOBBY_PATH_SEGMENT, NOTIFICATIONS_PATH_SEGMENT, @@ -70,7 +71,7 @@ import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home'; import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct'; import { RouteSpaceProvider, Space, SpaceRouteRoomProvider, SpaceSearch } from './client/space'; import { Explore, FeaturedRooms, PublicRooms } from './client/explore'; -import { Notifications, Inbox, Invites } from './client/inbox'; +import { Notifications, Inbox, Invites, Bookmarks } from './client/inbox'; import { setAfterLoginRedirectPath } from './afterLoginRedirectPath'; import { WelcomePage } from './client/WelcomePage'; import { SidebarNav } from './client/SidebarNav'; @@ -371,6 +372,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) )} } /> } /> + } /> } /> diff --git a/src/app/pages/client/inbox/Bookmarks.tsx b/src/app/pages/client/inbox/Bookmarks.tsx new file mode 100644 index 000000000..1ea404c4d --- /dev/null +++ b/src/app/pages/client/inbox/Bookmarks.tsx @@ -0,0 +1,28 @@ +import { Box, Icon, Icons, Text } from 'folds'; +import { Page, PageContent, PageHeader } from '$components/page'; +import { BookmarksList } from '$features/bookmarks/BookmarksList'; + +export function Bookmarks() { + return ( + + + + + + + Bookmarks + + + + + + + + + + + ); +} diff --git a/src/app/pages/client/inbox/Inbox.tsx b/src/app/pages/client/inbox/Inbox.tsx index 661435513..fa7b901d4 100644 --- a/src/app/pages/client/inbox/Inbox.tsx +++ b/src/app/pages/client/inbox/Inbox.tsx @@ -1,8 +1,16 @@ import { Avatar, Box, Icon, Icons, Text } from 'folds'; import { useAtomValue } from 'jotai'; import { NavCategory, NavItem, NavItemContent, NavLink } from '$components/nav'; -import { getInboxInvitesPath, getInboxNotificationsPath } from '$pages/pathUtils'; -import { useInboxInvitesSelected, useInboxNotificationsSelected } from '$hooks/router/useInbox'; +import { + getInboxBookmarksPath, + getInboxInvitesPath, + getInboxNotificationsPath, +} from '$pages/pathUtils'; +import { + useInboxBookmarksSelected, + useInboxInvitesSelected, + useInboxNotificationsSelected, +} from '$hooks/router/useInbox'; import { UnreadBadge } from '$components/unread-badge'; import { allInvitesAtom } from '$state/room-list/inviteList'; import { useNavToActivePathMapper } from '$hooks/useNavToActivePathMapper'; @@ -42,6 +50,7 @@ function InvitesNavItem() { export function Inbox() { useNavToActivePathMapper('inbox'); const notificationsSelected = useInboxNotificationsSelected(); + const bookmarksSelected = useInboxBookmarksSelected(); return ( @@ -75,6 +84,22 @@ export function Inbox() { + + + + + + + + + + Bookmarks + + + + + + diff --git a/src/app/pages/client/inbox/index.ts b/src/app/pages/client/inbox/index.ts index c8036b471..dc02ccee6 100644 --- a/src/app/pages/client/inbox/index.ts +++ b/src/app/pages/client/inbox/index.ts @@ -1,3 +1,4 @@ export * from './Inbox'; export * from './Notifications'; export * from './Invites'; +export * from './Bookmarks'; diff --git a/src/app/pages/pathUtils.ts b/src/app/pages/pathUtils.ts index 4a95f47fc..f942adf0a 100644 --- a/src/app/pages/pathUtils.ts +++ b/src/app/pages/pathUtils.ts @@ -18,6 +18,7 @@ import { LOGIN_PATH, INBOX_INVITES_PATH, INBOX_NOTIFICATIONS_PATH, + INBOX_BOOKMARKS_PATH, INBOX_PATH, REGISTER_PATH, RESET_PASSWORD_PATH, @@ -158,6 +159,7 @@ export const getCreatePath = (): string => CREATE_PATH; export const getInboxPath = (): string => INBOX_PATH; export const getInboxNotificationsPath = (): string => INBOX_NOTIFICATIONS_PATH; export const getInboxInvitesPath = (): string => INBOX_INVITES_PATH; +export const getInboxBookmarksPath = (): string => INBOX_BOOKMARKS_PATH; export const getSettingsPath = (section?: string, focus?: string): string => { const path = trimTrailingSlash(generatePath(SETTINGS_PATH, { section: section ?? null })); diff --git a/src/app/pages/paths.ts b/src/app/pages/paths.ts index 1ac57b756..c36743930 100644 --- a/src/app/pages/paths.ts +++ b/src/app/pages/paths.ts @@ -82,12 +82,14 @@ export const CREATE_PATH = '/create'; export const NOTIFICATIONS_PATH_SEGMENT = 'notifications/'; export const INVITES_PATH_SEGMENT = 'invites/'; +export const BOOKMARKS_PATH_SEGMENT = 'bookmarks/'; export const INBOX_PATH = '/inbox/'; export type InboxNotificationsPathSearchParams = { only?: string; }; export const INBOX_NOTIFICATIONS_PATH = `/inbox/${NOTIFICATIONS_PATH_SEGMENT}`; export const INBOX_INVITES_PATH = `/inbox/${INVITES_PATH_SEGMENT}`; +export const INBOX_BOOKMARKS_PATH = `/inbox/${BOOKMARKS_PATH_SEGMENT}`; export const TO_PATH = '/to'; // Deep-link route used by push notification click-back URLs. diff --git a/src/types/matrix-sdk-events.d.ts b/src/types/matrix-sdk-events.d.ts index ae108811e..3c7fda13b 100644 --- a/src/types/matrix-sdk-events.d.ts +++ b/src/types/matrix-sdk-events.d.ts @@ -49,6 +49,9 @@ declare module 'matrix-js-sdk/lib/@types/event' { 'im.ponies.emote_rooms': EmoteRoomsContent; 'moe.sable.app.nicknames': Record; 'moe.sable.app.settings': Record; - 'moe.sable.app.bookmarks': { bookmarks: Array<{ event_id: string; room_id: string }> }; + // MSC4438 bookmark index — lists the per-bookmark event keys in order + 'org.matrix.msc4438.bookmarks.index': { bookmarks: string[] }; + // Individual MSC4438 bookmark events (dynamic keys handled by prefix convention) + [key: `org.matrix.msc4438.bookmark.${string}`]: { room_id: string; event_id: string }; } } diff --git a/src/types/matrix/accountData.ts b/src/types/matrix/accountData.ts index 7a31a80af..488aeff10 100644 --- a/src/types/matrix/accountData.ts +++ b/src/types/matrix/accountData.ts @@ -8,7 +8,9 @@ export const CustomAccountDataEvent = { // because of a mistake hasn't been renamed in time SablePerProfileMessageProfiles: 'fyi.cisnt.permessageprofile', SableSettings: 'moe.sable.app.settings', - SableBookmarks: 'moe.sable.app.bookmarks', + // MSC4438 bookmarks — individual bookmark events + an index + MSC4438BookmarkPrefix: 'org.matrix.msc4438.bookmark.', + MSC4438BookmarksIndex: 'org.matrix.msc4438.bookmarks.index', } as const; export type CustomAccountDataEvent = (typeof CustomAccountDataEvent)[keyof typeof CustomAccountDataEvent]; From 88d5f469411bc6dd605ec61812ba5a107933d24f Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 2 May 2026 19:27:56 -0400 Subject: [PATCH 67/69] fix(bookmarks): use bookmark_ids index key per MSC4438 - getIndexIds reads bookmark_ids instead of bookmarks - toggleBookmark writes bookmark_ids to index on add/remove - toggleBookmark marks deleted events with { deleted: true } instead of {} - readBookmarks skips events with deleted: true --- src/app/hooks/useBookmarks.ts | 25 ++++++++++++++++--------- src/types/matrix-sdk-events.d.ts | 2 +- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/app/hooks/useBookmarks.ts b/src/app/hooks/useBookmarks.ts index ea1d0937f..098dbd059 100644 --- a/src/app/hooks/useBookmarks.ts +++ b/src/app/hooks/useBookmarks.ts @@ -30,8 +30,8 @@ function generateBookmarkId(): string { function getIndexIds(mx: MatrixClient): string[] { const ev = mx.getAccountData(INDEX_KEY); if (!ev) return []; - const content = ev.getContent<{ bookmarks?: string[] }>(); - return Array.isArray(content.bookmarks) ? content.bookmarks : []; + const content = ev.getContent<{ bookmark_ids?: string[] }>(); + return Array.isArray(content.bookmark_ids) ? content.bookmark_ids : []; } export function readBookmarks(mx: MatrixClient): BookmarkEntry[] { @@ -40,8 +40,8 @@ export function readBookmarks(mx: MatrixClient): BookmarkEntry[] { for (const id of ids) { const ev = mx.getAccountData(`${BOOKMARK_PREFIX}${id}`); if (!ev) continue; - const c = ev.getContent<{ room_id?: string; event_id?: string }>(); - if (c.room_id && c.event_id) { + const c = ev.getContent<{ room_id?: string; event_id?: string; deleted?: boolean }>(); + if (!c.deleted && c.room_id && c.event_id) { entries.push({ id, room_id: c.room_id, event_id: c.event_id }); } } @@ -91,15 +91,22 @@ export async function toggleBookmark( ): Promise { const existing = currentBookmarks.find((b) => b.event_id === eventId); if (existing) { - // Remove: delete individual event, then update index - await mx.setAccountData(`${BOOKMARK_PREFIX}${existing.id}`, {}); + // Remove: update index first, then mark individual event deleted const newIds = currentBookmarks.filter((b) => b.event_id !== eventId).map((b) => b.id); - await mx.setAccountData(INDEX_KEY, { bookmarks: newIds }); + await mx.setAccountData(INDEX_KEY, { bookmark_ids: newIds }); + await mx.setAccountData(`${BOOKMARK_PREFIX}${existing.id}`, { + deleted: true, + bookmark_id: existing.id, + }); } else { // Add: write individual event, then update index const id = generateBookmarkId(); - await mx.setAccountData(`${BOOKMARK_PREFIX}${id}`, { room_id: roomId, event_id: eventId }); + await mx.setAccountData(`${BOOKMARK_PREFIX}${id}`, { + room_id: roomId, + event_id: eventId, + bookmark_id: id, + }); const newIds = [...currentBookmarks.map((b) => b.id), id]; - await mx.setAccountData(INDEX_KEY, { bookmarks: newIds }); + await mx.setAccountData(INDEX_KEY, { bookmark_ids: newIds }); } } diff --git a/src/types/matrix-sdk-events.d.ts b/src/types/matrix-sdk-events.d.ts index 3c7fda13b..5de192774 100644 --- a/src/types/matrix-sdk-events.d.ts +++ b/src/types/matrix-sdk-events.d.ts @@ -50,7 +50,7 @@ declare module 'matrix-js-sdk/lib/@types/event' { 'moe.sable.app.nicknames': Record; 'moe.sable.app.settings': Record; // MSC4438 bookmark index — lists the per-bookmark event keys in order - 'org.matrix.msc4438.bookmarks.index': { bookmarks: string[] }; + 'org.matrix.msc4438.bookmarks.index': { bookmark_ids: string[] }; // Individual MSC4438 bookmark events (dynamic keys handled by prefix convention) [key: `org.matrix.msc4438.bookmark.${string}`]: { room_id: string; event_id: string }; } From 3102fe6807157efcf57eff3a89872eff024d820f Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 2 May 2026 19:30:16 -0400 Subject: [PATCH 68/69] fix(presence): show presence dot in account switcher, DM sidebar, and members drawer - AccountSwitcherTab: wrap SidebarAvatar in AvatarPresence with current user's dot - DirectDMsList: add AvatarPresence badge on 1:1 DM icons using the DM user's presence - MembersDrawer: replace lastActiveTs !== 0 guard with presence !== Offline so online users show a dot --- src/app/features/room/MembersDrawer.tsx | 4 +-- .../client/sidebar/AccountSwitcherTab.tsx | 36 ++++++++++++------- .../pages/client/sidebar/DirectDMsList.tsx | 21 +++++++++-- 3 files changed, 44 insertions(+), 17 deletions(-) diff --git a/src/app/features/room/MembersDrawer.tsx b/src/app/features/room/MembersDrawer.tsx index f751bcf31..442c51b30 100644 --- a/src/app/features/room/MembersDrawer.tsx +++ b/src/app/features/room/MembersDrawer.tsx @@ -26,7 +26,7 @@ import { useVirtualizer } from '@tanstack/react-virtual'; import classNames from 'classnames'; import { AvatarPresence, PresenceBadge } from '$components/presence'; -import { useUserPresence } from '$hooks/useUserPresence'; +import { Presence, useUserPresence } from '$hooks/useUserPresence'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { UseStateProvider } from '$components/UseStateProvider'; import type { SearchItemStrGetter, UseAsyncSearchOptions } from '$hooks/useAsyncSearch'; @@ -151,7 +151,7 @@ function MemberItem({ > ) : undefined } diff --git a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx index a3ec48466..ed29252e5 100644 --- a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx +++ b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx @@ -45,6 +45,8 @@ import { createLogger } from '$utils/debug'; import { createDebugLogger } from '$utils/debugLogger'; import { useClientConfig } from '$hooks/useClientConfig'; import { UnreadBadge, UnreadBadgeCenter } from '$components/unread-badge'; +import { Presence, useUserPresence } from '$hooks/useUserPresence'; +import { AvatarPresence, PresenceBadge } from '$components/presence'; const log = createLogger('AccountSwitcherTab'); const debugLog = createDebugLogger('AccountSwitcherTab'); @@ -174,6 +176,7 @@ export function AccountSwitcherTab() { ? (mxcUrlToHttp(mx, activeProfile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined) : undefined; const activeDisplayName = activeProfile.displayName; + const myPresence = useUserPresence(myUserId); const sessionProfiles = useSessionProfiles(sessions); @@ -269,19 +272,28 @@ export function AccountSwitcherTab() { {(triggerRef) => ( - 1} + + ) + } > - {nameInitials(label)}} - /> - + 1} + > + {nameInitials(label)}} + /> + + )} {(totalBackgroundUnread > 0 || anyBackgroundHighlight) && ( diff --git a/src/app/pages/client/sidebar/DirectDMsList.tsx b/src/app/pages/client/sidebar/DirectDMsList.tsx index 8c3313335..31d17d68a 100644 --- a/src/app/pages/client/sidebar/DirectDMsList.tsx +++ b/src/app/pages/client/sidebar/DirectDMsList.tsx @@ -22,6 +22,8 @@ import { getCanonicalAliasOrRoomId, mxcUrlToHttp } from '$utils/matrix'; import { useSelectedRoom } from '$hooks/router/useSelectedRoom'; import { useGroupDMMembers } from '$hooks/useGroupDMMembers'; import { useSidebarDirectRoomIds } from './useSidebarDirectRoomIds'; +import { Presence, useUserPresence } from '$hooks/useUserPresence'; +import { AvatarPresence, PresenceBadge } from '$components/presence'; import * as css from './DirectDMsList.css'; const MAX_GROUP_MEMBERS = 3; @@ -44,6 +46,9 @@ function DMItem({ room, selected }: DMItemProps) { // Check if this is a group DM (more than 2 members) const isGroupDM = room.getJoinedMemberCount() > 2; + const dmUserId = !isGroupDM ? room.getAvatarFallbackMember()?.userId : undefined; + const dmPresence = useUserPresence(dmUserId ?? ''); + // Get member info for group DMs using m.direct and profile API (doesn't require full room state) // Members are sorted by who last sent messages (most recent first) const groupMembers = useGroupDMMembers(mx, room, MAX_GROUP_MEMBERS); @@ -135,9 +140,19 @@ function DMItem({ room, selected }: DMItemProps) { {(triggerRef) => ( - - {renderAvatar()} - + + ) + } + > + + {renderAvatar()} + + )} {unread && (unread.total > 0 || unread.highlight > 0) && ( From d3d3888c426e00acfd660eae2d6e23a017dc9ae5 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 2 May 2026 20:05:02 -0400 Subject: [PATCH 69/69] fix(presence): publish online on enable, update auto-idle description --- src/app/features/settings/general/General.tsx | 2 +- src/app/pages/client/ClientNonUIFeatures.tsx | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index b671f84a1..1162ab328 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -482,7 +482,7 @@ function Editor({ isMobile }: Readonly<{ isMobile: boolean }>) { {}); + } }, [mx, sendPresence]); // Auto-idle: set presence to unavailable after 5 minutes of inactivity or