From 7c28e2bda2287c74d84f1df732a7929141a5f0a2 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sat, 21 Mar 2026 17:39:43 -0400
Subject: [PATCH 001/253] 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 4b3d2fb87..edd6e8f3e 100644
--- a/knope.toml
+++ b/knope.toml
@@ -58,7 +58,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 e977b3635d14bcafc103d931eb7ad82ba8f9b733 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sat, 21 Mar 2026 17:40:40 -0400
Subject: [PATCH 002/253] 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 792fd83d50ce0f0373d8f5a8f65488dc5c59d436 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sat, 21 Mar 2026 17:45:52 -0400
Subject: [PATCH 003/253] 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 6ab811d3a590dd635e0472c2f0c07863343bd274 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sat, 21 Mar 2026 18:20:43 -0400
Subject: [PATCH 004/253] 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 a55248827a48e6690857de25494b0e19b8d6e481 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sat, 21 Mar 2026 18:30:52 -0400
Subject: [PATCH 005/253] 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 58ae0ebe1a80908c8ef5540302347eaa8d519a52 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Tue, 24 Mar 2026 20:20:08 -0400
Subject: [PATCH 006/253] chore: ignore .vscode/launch.json
---
.gitignore | 1 +
1 file changed, 1 insertion(+)
diff --git a/.gitignore b/.gitignore
index d6c83cfb1..77630efa4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,3 +23,4 @@ build.sh
# the following line was added with the "git ignore" tool by itsrye.dev, version 0.1.0
.lh
+.vscode/launch.json
From ff747a3af7cc4d516e36bee6fba765c645411263 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Tue, 24 Mar 2026 22:33:41 -0400
Subject: [PATCH 007/253] 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 024401b126cccd69bb5d5ac36328373614f0f697 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Wed, 25 Mar 2026 00:08:01 -0400
Subject: [PATCH 008/253] 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 c40d5c82a8113deeb4b8a5cf4bcbdf5a27b47cb2 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Fri, 27 Mar 2026 10:26:35 -0400
Subject: [PATCH 009/253] 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 a1aff9e0b78f39cd58f8e1dad7b3506820bfa9b9 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sat, 28 Mar 2026 12:14:10 -0400
Subject: [PATCH 010/253] 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 53a3aff2a787b9949e1edff8b7a31ad930fdd764 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sat, 28 Mar 2026 19:56:52 -0400
Subject: [PATCH 011/253] 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 83661734cf08b38b3e830cbd37bcbfc85c99d9f1 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sat, 28 Mar 2026 20:01:23 -0400
Subject: [PATCH 012/253] 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 (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 02ced602ba672aef0c312c7fa0f9eefd148c77f7 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sat, 28 Mar 2026 20:01:41 -0400
Subject: [PATCH 013/253] 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 5090322e4e47f180895536114eeedfe4e33cdf40 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sat, 28 Mar 2026 23:24:27 -0400
Subject: [PATCH 014/253] 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 bbb31fc894470c30cda8466d85b865b27fb9166b Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sat, 28 Mar 2026 23:29:30 -0400
Subject: [PATCH 015/253] 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 34b1b4de723bcc0924cc5e3f38f3d7e7b0fa8912 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sat, 28 Mar 2026 23:35:08 -0400
Subject: [PATCH 016/253] 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 3edf610f122da8762d3134e28702c33d4231aed5 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sat, 28 Mar 2026 23:56:22 -0400
Subject: [PATCH 017/253] 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 37b6af198e4de1444fc9d3a0c892107fd5f3ebce Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sun, 29 Mar 2026 00:06:13 -0400
Subject: [PATCH 018/253] 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 2ac47de3c47c5861621a6863a71da3ff4c49bf76 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sun, 29 Mar 2026 00:13:24 -0400
Subject: [PATCH 019/253] 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 68fdb85969ee82f025ee9f929e4eed52b24a266b Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sun, 29 Mar 2026 00:20:15 -0400
Subject: [PATCH 020/253] 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 6aa40cc7e4107e75cbd04e912d5460db705d9670 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sun, 29 Mar 2026 04:31:10 +0000
Subject: [PATCH 021/253] 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
pnpm-workspace.yaml | 3 ++-
6 files changed, 2 insertions(+), 1 deletion(-)
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
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index bf9aa6763..fbcf9139d 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -1,6 +1,7 @@
allowBuilds:
'@sentry/cli': true
'@swc/core': true
+ cloudflared: true
esbuild: true
sharp: true
unrs-resolver: true
@@ -13,11 +14,11 @@ minimumReleaseAgeExclude:
overrides:
brace-expansion: '>=1.1.12'
esbuild: '>=0.25.0'
+ flatted: '>=3.4.2'
lodash: '>=4.17.23'
minimatch: '>=10.2.3'
rollup: '>=4.59.0'
serialize-javascript: '>=7.0.3'
- flatted: '>=3.4.2'
undici: '>=7.24.0'
peerDependencyRules:
From 698bda09a1ee2acccafe395e1a714ae126327851 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sun, 29 Mar 2026 04:36:12 +0000
Subject: [PATCH 022/253] add/setup extensions
---
.devcontainer/devcontainer.json | 21 ++++++++++++++++++++-
.vscode/extensions.json | 28 +++++++++++++++++++++++++++-
.vscode/settings.json | 17 +++++++++++++++++
3 files changed, 64 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 a3854a859..e9829388d 100644
--- a/.vscode/extensions.json
+++ b/.vscode/extensions.json
@@ -1,3 +1,29 @@
{
- "recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode", "webpro.vscode-knip"]
+ "recommendations": [
+ // JS/TS toolchain
+ "dbaeumer.vscode-eslint",
+ "esbenp.prettier-vscode",
+ "webpro.vscode-knip",
+ "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 29e56e92a..c37ff9c79 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -7,5 +7,22 @@
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-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 2ab3bcda9cdf3269001f9b7300a44d242b69be94 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sun, 29 Mar 2026 10:18:03 -0400
Subject: [PATCH 023/253] 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 2cf9895c2197a2509738cef582c13316d863fb69 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sun, 29 Mar 2026 11:53:49 -0400
Subject: [PATCH 024/253] 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 bc6b57d361a514c0f96a5bbab1dfb0ba378c4cf2 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sun, 29 Mar 2026 12:29:40 -0400
Subject: [PATCH 025/253] 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 94fae0534d2bf5eda58c2b0578d880571b1df3ce Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sun, 29 Mar 2026 17:25:18 -0400
Subject: [PATCH 026/253] 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 f68ee8e6b3f0f70d53f6a2b628af044e0c426750 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sun, 29 Mar 2026 18:07:14 -0400
Subject: [PATCH 027/253] 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 5c4fe1caf7d014d31f8fc746feaa8ab69d2d1a36 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Mon, 30 Mar 2026 13:29:02 -0400
Subject: [PATCH 028/253] 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 25258f19f060cfc88f29178e4cde0cd6fa177519 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Mon, 30 Mar 2026 13:32:23 -0400
Subject: [PATCH 029/253] 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 bfee547a70dfe7512315643267ded2d037322dce Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Mon, 30 Mar 2026 13:41:11 -0400
Subject: [PATCH 030/253] 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 6df45a4543ac13819d57255d42343bb80e8f1819 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Mon, 30 Mar 2026 15:22:58 -0400
Subject: [PATCH 031/253] 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 a5f35e9bec7806ec302bdc6c52f4cc4f0f9be9a3 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Tue, 31 Mar 2026 10:55:38 -0400
Subject: [PATCH 032/253] 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 201e230987855e2e367e2f464f30ce5772271102 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Tue, 31 Mar 2026 11:02:00 -0400
Subject: [PATCH 033/253] 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 04d59f5d55a0914dbcd8a5968d1f5ddc440e9b49 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Tue, 31 Mar 2026 11:42:00 -0400
Subject: [PATCH 034/253] 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 73beb8c0627f134205e521311f9757260a598057 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Tue, 31 Mar 2026 10:50:22 -0400
Subject: [PATCH 035/253] feat(presence): add presence badges to sidebar and
fix sliding sync presence data
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- DirectDMsList: show PresenceBadge on DM avatar β actual presence for 1:1 DMs,
green dot when any participant is online for group DMs
- AccountSwitcherTab: show PresenceBadge on own account avatar in sidebar
- Fix AvatarPresence placement: move wrapper outside SidebarAvatar (overflow:hidden
was clipping the badge)
- useUserPresence: reset presence state when userId changes; add REST fallback for
sliding sync (Synapse MSC4186 has no presence extension so m.presence events are
never delivered via sync β GET /presence/:userId/status bootstraps the initial state)
- ClientNonUIFeatures: explicitly PUT /presence/:userId/status on visibility change
so the server records online/offline state; setSyncPresence is a no-op on MSC4186
---
src/app/hooks/useUserPresence.ts | 50 +++++++++++++++++--
src/app/pages/client/ClientNonUIFeatures.tsx | 6 +++
.../client/sidebar/AccountSwitcherTab.tsx | 35 ++++++++-----
.../pages/client/sidebar/DirectDMsList.tsx | 34 +++++++++++--
4 files changed, 105 insertions(+), 20 deletions(-)
diff --git a/src/app/hooks/useUserPresence.ts b/src/app/hooks/useUserPresence.ts
index f1b858422..a3b86ef08 100644
--- a/src/app/hooks/useUserPresence.ts
+++ b/src/app/hooks/useUserPresence.ts
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
-import { User, UserEvent, UserEventHandlerMap } from '$types/matrix-sdk';
+import { ClientEvent, MatrixEvent, User, UserEvent, UserEventHandlerMap } from '$types/matrix-sdk';
import { useMatrixClient } from './useMatrixClient';
export enum Presence {
@@ -29,20 +29,62 @@ export const useUserPresence = (userId: string): UserPresence | undefined => {
const [presence, setPresence] = useState(() => (user ? getUserPresence(user) : undefined));
useEffect(() => {
+ setPresence(user ? getUserPresence(user) : undefined);
+
+ let cancelled = false;
+
+ // Sliding sync (Synapse MSC4186) has no presence extension β m.presence events are never
+ // delivered via sync. As a result, User.presence stays at the SDK default and
+ // getLastActiveTs() stays 0. Fall back to a direct REST fetch to bootstrap presence state.
+ if (!user || user.getLastActiveTs() === 0) {
+ mx.getPresence(userId)
+ .then((resp) => {
+ if (cancelled) return;
+ setPresence({
+ presence: resp.presence as Presence,
+ status: resp.status_msg,
+ active: resp.currently_active ?? false,
+ lastActiveTs:
+ resp.last_active_ago != null ? Date.now() - resp.last_active_ago : undefined,
+ });
+ })
+ .catch(() => {
+ // Presence not available on this server (404 or not supported) β keep existing state.
+ });
+ }
+
const updatePresence: UserEventHandlerMap[UserEvent.Presence] = (event, u) => {
- if (u.userId === user?.userId) {
- setPresence(getUserPresence(user));
+ if (u.userId === userId) {
+ setPresence(getUserPresence(u));
}
};
user?.on(UserEvent.Presence, updatePresence);
user?.on(UserEvent.CurrentlyActive, updatePresence);
user?.on(UserEvent.LastPresenceTs, updatePresence);
+
+ // If the User object doesn't exist yet, subscribe at client level as a fallback.
+ // ExtensionPresence emits ClientEvent.Event after creating and updating the User object,
+ // so by the time this fires mx.getUser(userId) is guaranteed to be non-null.
+ let removeClientListener: (() => void) | undefined;
+ if (!user) {
+ const onClientEvent = (event: MatrixEvent) => {
+ if (event.getSender() !== userId || event.getType() !== 'm.presence') return;
+ const u = mx.getUser(userId);
+ if (!u) return;
+ setPresence(getUserPresence(u));
+ };
+ mx.on(ClientEvent.Event, onClientEvent);
+ removeClientListener = () => mx.removeListener(ClientEvent.Event, onClientEvent);
+ }
+
return () => {
+ cancelled = true;
user?.removeListener(UserEvent.Presence, updatePresence);
user?.removeListener(UserEvent.CurrentlyActive, updatePresence);
user?.removeListener(UserEvent.LastPresenceTs, updatePresence);
+ removeClientListener?.();
};
- }, [user]);
+ }, [mx, userId, user]);
return presence;
};
diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx
index 26ac2f431..311e31e5e 100644
--- a/src/app/pages/client/ClientNonUIFeatures.tsx
+++ b/src/app/pages/client/ClientNonUIFeatures.tsx
@@ -835,6 +835,12 @@ function PresenceFeature() {
mx.setSyncPresence(sendPresence ? undefined : SetPresence.Offline);
// Sliding sync: enable/disable the presence extension on the next poll.
getSlidingSyncManager(mx)?.setPresenceEnabled(sendPresence);
+ // Synapse MSC4186 sliding sync has no presence extension, so setSyncPresence has no
+ // effect. Explicitly PUT /presence/{userId}/status so the server knows the user's
+ // state β otherwise GET /presence returns stale offline and own presence badge is grey.
+ mx.setPresence({ presence: sendPresence ? 'online' : 'offline' }).catch(() => {
+ // Server doesn't support presence β ignore.
+ });
}, [mx, sendPresence]);
return null;
diff --git a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx
index 6e6ecc572..31d4b1a5f 100644
--- a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx
+++ b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx
@@ -40,10 +40,12 @@ import { getHomePath, getLoginPath, withSearchParam } from '$pages/pathUtils';
import { logoutClient, initClient, stopClient } from '$client/initMatrix';
import { useMatrixClient } from '$hooks/useMatrixClient';
import { useUserProfile } from '$hooks/useUserProfile';
+import { useUserPresence } from '$hooks/useUserPresence';
import { useMediaAuthentication } from '$hooks/useMediaAuthentication';
import { useSessionProfiles } from '$hooks/useSessionProfiles';
import { useOpenSettings } from '$features/settings';
import { Modal500 } from '$components/Modal500';
+import { AvatarPresence, PresenceBadge } from '$components/presence';
import { createLogger } from '$utils/debug';
import { createDebugLogger } from '$utils/debugLogger';
import { useClientConfig } from '$hooks/useClientConfig';
@@ -173,6 +175,7 @@ export function AccountSwitcherTab() {
const myUserId = mx.getUserId() ?? '';
const activeProfile = useUserProfile(myUserId);
+ const myPresence = useUserPresence(myUserId);
const activeAvatarUrl = activeProfile.avatarUrl
? (mxcUrlToHttp(mx, activeProfile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined)
: undefined;
@@ -270,19 +273,27 @@ export function AccountSwitcherTab() {
{(triggerRef) => (
- 1}
+
+ ) : undefined
+ }
>
- {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 16e829ce5..34a108a60 100644
--- a/src/app/pages/client/sidebar/DirectDMsList.tsx
+++ b/src/app/pages/client/sidebar/DirectDMsList.tsx
@@ -1,4 +1,4 @@
-import { useMemo, useRef, useEffect } from 'react';
+import { useMemo, useRef, useEffect, ReactNode } from 'react';
import * as Sentry from '@sentry/react';
import { useNavigate } from 'react-router-dom';
import { Avatar, Text, Box } from 'folds';
@@ -15,6 +15,8 @@ import {
} from '$components/sidebar';
import { RoomAvatar } from '$components/room-avatar';
import { UserAvatar } from '$components/user-avatar';
+import { AvatarPresence, PresenceBadge } from '$components/presence';
+import { useUserPresence, Presence } from '$hooks/useUserPresence';
import { getDirectRoomAvatarUrl } from '$utils/room';
import { useMediaAuthentication } from '$hooks/useMediaAuthentication';
import { nameInitials } from '$utils/common';
@@ -48,6 +50,28 @@ function DMItem({ room, selected }: DMItemProps) {
// Members are sorted by who last sent messages (most recent first)
const groupMembers = useGroupDMMembers(mx, room, MAX_GROUP_MEMBERS);
+ // Presence hooks β always called unconditionally (React rules of hooks).
+ // For single DMs: guessDMUserId() is synchronous; group slots use '' β undefined.
+ // For group DMs: singleDMUserId is '' β undefined; member slots use groupMembers.
+ const singleDMUserId = isGroupDM ? '' : room.guessDMUserId();
+ const singleDMPresence = useUserPresence(singleDMUserId);
+ const member0Presence = useUserPresence(isGroupDM ? (groupMembers[0]?.userId ?? '') : '');
+ const member1Presence = useUserPresence(isGroupDM ? (groupMembers[1]?.userId ?? '') : '');
+ const member2Presence = useUserPresence(isGroupDM ? (groupMembers[2]?.userId ?? '') : '');
+
+ const groupDMOnline =
+ isGroupDM &&
+ [member0Presence, member1Presence, member2Presence].some(
+ (p) => p && p.lastActiveTs !== 0 && p.presence === Presence.Online
+ );
+
+ let presenceBadge: ReactNode;
+ if (!isGroupDM && singleDMPresence && singleDMPresence.lastActiveTs !== 0) {
+ presenceBadge = ;
+ } else if (isGroupDM && groupDMOnline) {
+ presenceBadge = ;
+ }
+
// Get unread info for badge
const unread = roomToUnread.get(room.roomId);
@@ -132,9 +156,11 @@ function DMItem({ room, selected }: DMItemProps) {
{(triggerRef) => (
-
- {renderAvatar()}
-
+
+
+ {renderAvatar()}
+
+
)}
{unread && (unread.total > 0 || unread.highlight > 0) && (
From 4a6289e2b48e9ffc7d2c0f2c30bdcbf06209450a Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Tue, 31 Mar 2026 12:17:18 -0400
Subject: [PATCH 036/253] chore: add changeset for presence-sidebar-badges
---
.changeset/presence-sidebar-badges.md | 5 +++++
1 file changed, 5 insertions(+)
create mode 100644 .changeset/presence-sidebar-badges.md
diff --git a/.changeset/presence-sidebar-badges.md b/.changeset/presence-sidebar-badges.md
new file mode 100644
index 000000000..9d0356c48
--- /dev/null
+++ b/.changeset/presence-sidebar-badges.md
@@ -0,0 +1,5 @@
+---
+default: patch
+---
+
+Add presence status badges to sidebar DM list and account switcher
From 85a7f6d6c85064c11900fb7b18ec195014ed2cb6 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Tue, 31 Mar 2026 10:46:13 -0400
Subject: [PATCH 037/253] fix(hooks): handle unhandled rejections in
useAsyncCallback
Wrap the inner callback with a no-op .catch() so fire-and-forget call
sites (e.g. loadSrc in useEffect) do not produce 'Uncaught (in promise)'
console warnings. The promise is still returned and re-thrown for callers
that await or chain, so intentional error handling is unaffected.
---
src/app/hooks/useAsyncCallback.test.tsx | 2 +-
src/app/hooks/useAsyncCallback.ts | 17 ++++++++++++++++-
2 files changed, 17 insertions(+), 2 deletions(-)
diff --git a/src/app/hooks/useAsyncCallback.test.tsx b/src/app/hooks/useAsyncCallback.test.tsx
index c27d06478..c6e3f45ce 100644
--- a/src/app/hooks/useAsyncCallback.test.tsx
+++ b/src/app/hooks/useAsyncCallback.test.tsx
@@ -30,7 +30,7 @@ describe('useAsyncCallback', () => {
);
await act(async () => {
- await result.current[1]().catch(() => {});
+ await expect(result.current[1]()).rejects.toBe(boom);
});
expect(result.current[0]).toEqual({ status: AsyncStatus.Error, error: boom });
diff --git a/src/app/hooks/useAsyncCallback.ts b/src/app/hooks/useAsyncCallback.ts
index 70831bea1..26e6ee16b 100644
--- a/src/app/hooks/useAsyncCallback.ts
+++ b/src/app/hooks/useAsyncCallback.ts
@@ -73,6 +73,9 @@ export const useAsync = (
});
});
}
+ // Re-throw so .then()/.catch() callers see the rejection and success
+ // handlers are skipped. Fire-and-forget unhandled-rejection warnings are
+ // suppressed at the useAsyncCallback level via a no-op .catch wrapper.
throw e;
}
@@ -102,7 +105,19 @@ export const useAsyncCallback = (
status: AsyncStatus.Idle,
});
- const callback = useAsync(asyncCallback, setState);
+ const innerCallback = useAsync(asyncCallback, setState);
+
+ // Re-throw preserves rejection for callers that await/chain; the no-op .catch
+ // suppresses "Uncaught (in promise)" for fire-and-forget call sites (e.g.
+ // loadSrc() in a useEffect) without swallowing the error from intentional callers.
+ const callback = useCallback(
+ (...args: TArgs): Promise => {
+ const p = innerCallback(...args);
+ p.catch(() => {});
+ return p;
+ },
+ [innerCallback]
+ ) as AsyncCallback;
return [state, callback, setState];
};
From 918f2d7a61fd79e3b303cf83dfcee1e6f65e9654 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Tue, 31 Mar 2026 12:25:13 -0400
Subject: [PATCH 038/253] chore: add changeset for async-callback-rejections
---
.changeset/async-callback-rejections.md | 5 +++++
1 file changed, 5 insertions(+)
create mode 100644 .changeset/async-callback-rejections.md
diff --git a/.changeset/async-callback-rejections.md b/.changeset/async-callback-rejections.md
new file mode 100644
index 000000000..89297b90e
--- /dev/null
+++ b/.changeset/async-callback-rejections.md
@@ -0,0 +1,5 @@
+---
+default: patch
+---
+
+Fix unhandled promise rejections in useAsyncCallback by propagating errors to the error boundary
From bc58d6e456087910ecc52f6f44dc0396044a3ecc Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sun, 29 Mar 2026 14:14:26 -0400
Subject: [PATCH 039/253] feat(flags): inject client config from GH environment
variables at build
Add scripts/inject-client-config.js which reads HOMESERVER_LIST,
ELEMENT_CALL_URL, EXPERIMENTS and other config keys from the GH Actions
environment and merges them into config.json at build time.
CI workflows pass these through via env; the setup action prints an
injected-config summary in the job summary.
knip.json updated to include the new script as an entry point.
---
.github/actions/setup/action.yml | 30 +++++++++
.github/workflows/cloudflare-web-deploy.yml | 8 +++
.github/workflows/cloudflare-web-preview.yml | 4 ++
knip.json | 2 +-
scripts/inject-client-config.js | 71 ++++++++++++++++++++
5 files changed, 114 insertions(+), 1 deletion(-)
create mode 100644 scripts/inject-client-config.js
diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml
index 9b4c9acbb..d9a365eeb 100644
--- a/.github/actions/setup/action.yml
+++ b/.github/actions/setup/action.yml
@@ -34,6 +34,36 @@ runs:
env:
INPUTS_INSTALL_COMMAND: ${{ inputs.install-command }}
+ - name: Inject runtime config overrides
+ if: ${{ env.CLIENT_CONFIG_OVERRIDES_JSON != '' }}
+ shell: bash
+ working-directory: ${{ github.workspace }}
+ run: node scripts/inject-client-config.js
+ env:
+ CLIENT_CONFIG_OVERRIDES_JSON: ${{ env.CLIENT_CONFIG_OVERRIDES_JSON }}
+ CLIENT_CONFIG_OVERRIDES_STRICT: ${{ env.CLIENT_CONFIG_OVERRIDES_STRICT }}
+
+ - name: Display injected config
+ if: ${{ env.CLIENT_CONFIG_OVERRIDES_JSON != '' }}
+ shell: bash
+ working-directory: ${{ github.workspace }}
+ run: |
+ summary_file="${GITHUB_STEP_SUMMARY:-}"
+ echo "::group::Injected Client Config"
+ experiments_json="$(jq -c '.experiments // "No experiments configured"' config.json 2>/dev/null || echo 'config.json not readable')"
+ echo "$experiments_json"
+ echo "::endgroup::"
+
+ if [[ -n "$summary_file" ]]; then
+ {
+ echo "### Injected client config"
+ echo
+ echo "\`\`\`json"
+ echo "$experiments_json"
+ echo "\`\`\`"
+ } >> "$summary_file"
+ fi
+
- name: Build app
if: ${{ inputs.build == 'true' }}
shell: bash
diff --git a/.github/workflows/cloudflare-web-deploy.yml b/.github/workflows/cloudflare-web-deploy.yml
index d3d2c4461..e32dbf68e 100644
--- a/.github/workflows/cloudflare-web-deploy.yml
+++ b/.github/workflows/cloudflare-web-deploy.yml
@@ -40,6 +40,10 @@ jobs:
plan:
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
+ environment: preview
+ env:
+ CLIENT_CONFIG_OVERRIDES_JSON: ${{ vars.CLIENT_CONFIG_OVERRIDES_JSON }}
+ CLIENT_CONFIG_OVERRIDES_STRICT: ${{ vars.CLIENT_CONFIG_OVERRIDES_STRICT || 'false' }}
permissions:
contents: read
pull-requests: write
@@ -73,6 +77,10 @@ jobs:
apply:
if: (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
+ environment: production
+ env:
+ CLIENT_CONFIG_OVERRIDES_JSON: ${{ vars.CLIENT_CONFIG_OVERRIDES_JSON }}
+ CLIENT_CONFIG_OVERRIDES_STRICT: ${{ vars.CLIENT_CONFIG_OVERRIDES_STRICT || 'false' }}
permissions:
contents: read
defaults:
diff --git a/.github/workflows/cloudflare-web-preview.yml b/.github/workflows/cloudflare-web-preview.yml
index 8b93a4bb9..82046559c 100644
--- a/.github/workflows/cloudflare-web-preview.yml
+++ b/.github/workflows/cloudflare-web-preview.yml
@@ -32,9 +32,13 @@ jobs:
deploy:
if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'push'
runs-on: ubuntu-latest
+ environment: preview
permissions:
contents: read
pull-requests: 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
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
diff --git a/knip.json b/knip.json
index c6cca1d75..f45161f97 100644
--- a/knip.json
+++ b/knip.json
@@ -1,6 +1,6 @@
{
"$schema": "https://unpkg.com/knip@5/schema.json",
- "entry": ["src/sw.ts", "scripts/normalize-imports.js"],
+ "entry": ["src/sw.ts", "scripts/normalize-imports.js", "scripts/inject-client-config.js"],
"ignoreExportsUsedInFile": {
"interface": true,
"type": true
diff --git a/scripts/inject-client-config.js b/scripts/inject-client-config.js
new file mode 100644
index 000000000..b7c62c096
--- /dev/null
+++ b/scripts/inject-client-config.js
@@ -0,0 +1,71 @@
+import { readFile, writeFile } from 'node:fs/promises';
+import process from 'node:process';
+import { PrefixedLogger } from './utils/console-style.js';
+
+const CONFIG_PATH = 'config.json';
+const OVERRIDES_ENV = 'CLIENT_CONFIG_OVERRIDES_JSON';
+const STRICT_ENV = 'CLIENT_CONFIG_OVERRIDES_STRICT';
+const logger = new PrefixedLogger('[config-inject]');
+
+const formatError = (error) => {
+ if (error instanceof Error) return error.stack ?? error.message;
+ return String(error);
+};
+
+const isPlainObject = (value) =>
+ typeof value === 'object' && value !== null && !Array.isArray(value);
+
+const deepMerge = (target, source) => {
+ if (!isPlainObject(target) || !isPlainObject(source)) return source;
+
+ const merged = { ...target };
+ Object.entries(source).forEach(([key, value]) => {
+ const targetValue = merged[key];
+ merged[key] =
+ isPlainObject(targetValue) && isPlainObject(value) ? deepMerge(targetValue, value) : value;
+ });
+ return merged;
+};
+
+const failOnError = process.env[STRICT_ENV] === 'true';
+const overridesRaw = process.env[OVERRIDES_ENV];
+
+if (!overridesRaw) {
+ logger.info(`No ${OVERRIDES_ENV} provided; leaving ${CONFIG_PATH} unchanged.`);
+ process.exit(0);
+}
+
+let fileConfig;
+let overrides;
+
+try {
+ const file = await readFile(CONFIG_PATH, 'utf8');
+ fileConfig = JSON.parse(file);
+} catch (error) {
+ logger.error(`Failed reading ${CONFIG_PATH}: ${formatError(error)}`);
+ process.exit(1);
+}
+
+try {
+ overrides = JSON.parse(overridesRaw);
+ if (!isPlainObject(overrides)) {
+ throw new Error(`${OVERRIDES_ENV} must be a JSON object.`);
+ }
+} catch (error) {
+ const message = `[config-inject] Invalid ${OVERRIDES_ENV}; ${
+ failOnError ? 'failing build' : 'skipping overrides'
+ }.`;
+ if (failOnError) {
+ logger.error(`${message} ${formatError(error)}`);
+ process.exit(1);
+ }
+ logger.info(`[warning] ${message} ${formatError(error)}`);
+ process.exit(0);
+}
+
+const mergedConfig = deepMerge(fileConfig, overrides);
+
+await writeFile(CONFIG_PATH, `${JSON.stringify(mergedConfig, null, 2)}\n`, 'utf8');
+logger.info(
+ `Applied overrides to ${CONFIG_PATH}. Top-level keys: ${Object.keys(overrides).join(', ')}`
+);
From add3987f2d26c43e561b88eb5ce02a5205ed6aef Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sun, 29 Mar 2026 14:14:51 -0400
Subject: [PATCH 040/253] feat(flags): add typed experiment bucketing helper
with rollout percentages
useClientConfig.ts gains getExperimentVariant() which deterministically
buckets a userId into a variant using a hash of userId+experimentName, then
checks it against rolloutPercentage. Experiment defaults shape is typed so
all callers get compile-time checking of known experiment names.
---
src/app/hooks/useClientConfig.ts | 85 ++++++++++++++++++++++++++++++++
1 file changed, 85 insertions(+)
diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts
index e523f15a7..98e8e601f 100644
--- a/src/app/hooks/useClientConfig.ts
+++ b/src/app/hooks/useClientConfig.ts
@@ -5,6 +5,21 @@ export type HashRouterConfig = {
basename?: string;
};
+export type ExperimentConfig = {
+ enabled?: boolean;
+ rolloutPercentage?: number;
+ variants?: string[];
+ controlVariant?: string;
+};
+
+export type ExperimentSelection = {
+ key: string;
+ enabled: boolean;
+ rolloutPercentage: number;
+ variant: string;
+ inExperiment: boolean;
+};
+
export type ClientConfig = {
defaultHomeserver?: number;
homeserverList?: string[];
@@ -14,6 +29,8 @@ export type ClientConfig = {
disableAccountSwitcher?: boolean;
hideUsernamePasswordFields?: boolean;
+ experiments?: Record;
+
pushNotificationDetails?: {
pushNotifyUrl?: string;
vapidPublicKey?: string;
@@ -55,6 +72,74 @@ export function useClientConfig(): ClientConfig {
return config;
}
+const DEFAULT_CONTROL_VARIANT = 'control';
+
+const normalizeRolloutPercentage = (value?: number): number => {
+ if (typeof value !== 'number' || Number.isNaN(value)) return 100;
+ if (value < 0) return 0;
+ if (value > 100) return 100;
+ return value;
+};
+
+const hashToUInt32 = (input: string): number => {
+ let hash = 0;
+ for (let index = 0; index < input.length; index += 1) {
+ hash = (hash * 131 + input.charCodeAt(index)) % 4294967291;
+ }
+ return hash;
+};
+
+export const selectExperimentVariant = (
+ key: string,
+ experiment: ExperimentConfig | undefined,
+ subjectId: string | undefined
+): ExperimentSelection => {
+ const controlVariant = experiment?.controlVariant ?? DEFAULT_CONTROL_VARIANT;
+ const variants = (experiment?.variants?.filter((variant) => variant.length > 0) ?? []).filter(
+ (variant) => variant !== controlVariant
+ );
+
+ const enabled = Boolean(experiment?.enabled);
+ const rolloutPercentage = normalizeRolloutPercentage(experiment?.rolloutPercentage);
+
+ if (!enabled || !subjectId || variants.length === 0 || rolloutPercentage === 0) {
+ return {
+ key,
+ enabled,
+ rolloutPercentage,
+ variant: controlVariant,
+ inExperiment: false,
+ };
+ }
+
+ // Two independent hashes keep rollout and variant assignment stable but decorrelated.
+ const rolloutBucket = hashToUInt32(`${key}:rollout:${subjectId}`) % 10000;
+ const rolloutCutoff = Math.floor(rolloutPercentage * 100);
+ if (rolloutBucket >= rolloutCutoff) {
+ return {
+ key,
+ enabled,
+ rolloutPercentage,
+ variant: controlVariant,
+ inExperiment: false,
+ };
+ }
+
+ const variantIndex = hashToUInt32(`${key}:variant:${subjectId}`) % variants.length;
+ return {
+ key,
+ enabled,
+ rolloutPercentage,
+ variant: variants[variantIndex],
+ inExperiment: true,
+ };
+};
+
+export const useExperimentVariant = (key: string, subjectId?: string): ExperimentSelection => {
+ const clientConfig = useClientConfig();
+ return selectExperimentVariant(key, clientConfig.experiments?.[key], subjectId);
+};
+
export const clientDefaultServer = (clientConfig: ClientConfig): string =>
clientConfig.homeserverList?.[clientConfig.defaultHomeserver ?? 0] ?? 'matrix.org';
From 718210ff278276c2e9366dfdc79ad49f527df64a Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sun, 29 Mar 2026 14:14:59 -0400
Subject: [PATCH 041/253] feat(devtools): add Experiments panel to developer
tools settings
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
ExperimentsPanel shows every experiment name, current variant, rollout
percentage, and whether the user is enrolled β readable without opening
the console. DevelopTools.tsx wires it into the developer settings tab.
---
.../settings/developer-tools/DevelopTools.tsx | 2 +
.../developer-tools/ExperimentsPanel.tsx | 102 ++++++++++++++++++
2 files changed, 104 insertions(+)
create mode 100644 src/app/features/settings/developer-tools/ExperimentsPanel.tsx
diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx
index c8ffeb12d..e3b04ea3d 100644
--- a/src/app/features/settings/developer-tools/DevelopTools.tsx
+++ b/src/app/features/settings/developer-tools/DevelopTools.tsx
@@ -12,6 +12,7 @@ import { SequenceCardStyle } from '$features/settings/styles.css';
import { SettingsSectionPage } from '../SettingsSectionPage';
import { AccountData } from './AccountData';
import { SyncDiagnostics } from './SyncDiagnostics';
+import { ExperimentsPanel } from './ExperimentsPanel';
import { DebugLogViewer } from './DebugLogViewer';
import { SentrySettings } from './SentrySettings';
@@ -109,6 +110,7 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp
)}
{developerTools && }
+ {developerTools && }
{developerTools && (
{
+ if (!config.experiments) return [];
+ return Object.entries(config.experiments).map(([key, experimentConfig]) => ({
+ key,
+ config: experimentConfig,
+ selection: selectExperimentVariant(key, experimentConfig, userId),
+ }));
+ }, [config.experiments, userId]);
+
+ if (experiments.length === 0) {
+ return (
+
+ Features & Experiments
+
+ No experiments configured
+
+
+ );
+ }
+
+ return (
+
+ Features & Experiments
+
+ {experiments.map(({ key, config: experimentConfig, selection }) => (
+
+
+
+
+ Enabled:
+
+
+ {selection.enabled ? 'Yes' : 'No'}
+
+
+ {selection.enabled && (
+ <>
+
+
+ Rollout:
+
+ {selection.rolloutPercentage}%
+
+
+
+ Your Variant:
+
+
+ {selection.variant}
+ {selection.inExperiment && ' (in experiment)'}
+ {!selection.inExperiment && ' (control)'}
+
+
+ {experimentConfig.variants && experimentConfig.variants.length > 0 && (
+
+
+ Treatment Variants:
+
+
+ {experimentConfig.variants
+ .filter((v) => v !== experimentConfig.controlVariant)
+ .join(', ')}
+
+
+ )}
+ >
+ )}
+
+
+ ))}
+
+
+ );
+}
From dfebd090af5684a7bd61c1696505ad1b8b7aed53 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sun, 29 Mar 2026 14:15:24 -0400
Subject: [PATCH 042/253] test(flags): cover experiment bucketing and add
changeset
---
.changeset/feature-flag-env-vars.md | 5 ++
src/app/hooks/useClientConfig.test.ts | 101 ++++++++++++++++++++++++++
2 files changed, 106 insertions(+)
create mode 100644 .changeset/feature-flag-env-vars.md
create mode 100644 src/app/hooks/useClientConfig.test.ts
diff --git a/.changeset/feature-flag-env-vars.md b/.changeset/feature-flag-env-vars.md
new file mode 100644
index 000000000..25d7d7d01
--- /dev/null
+++ b/.changeset/feature-flag-env-vars.md
@@ -0,0 +1,5 @@
+---
+default: minor
+---
+
+Add build-time client config overrides via environment variables, with typed deterministic experiment bucketing helpers for progressive feature rollout and A/B testing.
diff --git a/src/app/hooks/useClientConfig.test.ts b/src/app/hooks/useClientConfig.test.ts
new file mode 100644
index 000000000..5071c5f7c
--- /dev/null
+++ b/src/app/hooks/useClientConfig.test.ts
@@ -0,0 +1,101 @@
+import { describe, it, expect } from 'vitest';
+import { selectExperimentVariant, type ExperimentConfig } from './useClientConfig';
+
+const baseExperiment: ExperimentConfig = {
+ enabled: true,
+ rolloutPercentage: 100,
+ controlVariant: 'control',
+ variants: ['alpha', 'beta'],
+};
+
+describe('selectExperimentVariant', () => {
+ it('returns control when experiment is disabled', () => {
+ const result = selectExperimentVariant(
+ 'threadUI',
+ { ...baseExperiment, enabled: false },
+ '@alice:example.org'
+ );
+
+ expect(result.inExperiment).toBe(false);
+ expect(result.variant).toBe('control');
+ });
+
+ it('returns control when subject id is missing', () => {
+ const result = selectExperimentVariant('threadUI', baseExperiment, undefined);
+
+ expect(result.inExperiment).toBe(false);
+ expect(result.variant).toBe('control');
+ });
+
+ it('returns control when rollout is 0', () => {
+ const result = selectExperimentVariant(
+ 'threadUI',
+ { ...baseExperiment, rolloutPercentage: 0 },
+ '@alice:example.org'
+ );
+
+ expect(result.inExperiment).toBe(false);
+ expect(result.variant).toBe('control');
+ expect(result.rolloutPercentage).toBe(0);
+ });
+
+ it('normalizes rollout less than 0 to 0', () => {
+ const result = selectExperimentVariant(
+ 'threadUI',
+ { ...baseExperiment, rolloutPercentage: -10 },
+ '@alice:example.org'
+ );
+
+ expect(result.inExperiment).toBe(false);
+ expect(result.variant).toBe('control');
+ expect(result.rolloutPercentage).toBe(0);
+ });
+
+ it('normalizes rollout greater than 100 to 100', () => {
+ const result = selectExperimentVariant(
+ 'threadUI',
+ { ...baseExperiment, rolloutPercentage: 999 },
+ '@alice:example.org'
+ );
+
+ expect(result.inExperiment).toBe(true);
+ expect(result.rolloutPercentage).toBe(100);
+ expect(['alpha', 'beta']).toContain(result.variant);
+ });
+
+ it('falls back to control when variants are missing after filtering', () => {
+ const result = selectExperimentVariant(
+ 'threadUI',
+ {
+ ...baseExperiment,
+ variants: ['', 'control'],
+ },
+ '@alice:example.org'
+ );
+
+ expect(result.inExperiment).toBe(false);
+ expect(result.variant).toBe('control');
+ });
+
+ it('is deterministic for the same key and subject', () => {
+ const first = selectExperimentVariant('threadUI', baseExperiment, '@alice:example.org');
+ const second = selectExperimentVariant('threadUI', baseExperiment, '@alice:example.org');
+
+ expect(second).toEqual(first);
+ });
+
+ it('uses default control variant when none is provided', () => {
+ const result = selectExperimentVariant(
+ 'threadUI',
+ {
+ enabled: true,
+ rolloutPercentage: 100,
+ variants: ['alpha'],
+ },
+ '@alice:example.org'
+ );
+
+ expect(result.inExperiment).toBe(true);
+ expect(result.variant).toBe('alpha');
+ });
+});
From a53112f50dfec7950f36348fb02fd02021dfb4b2 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Mon, 6 Apr 2026 12:43:30 -0400
Subject: [PATCH 043/253] feat(polls): implement MSC3381 polls with creator
dialog and timeline renderer
---
config.json | 7 +-
src/app/features/room/RoomInput.tsx | 67 ++-
.../room/poll/PollCreatorDialog.css.ts | 49 ++
.../features/room/poll/PollCreatorDialog.tsx | 383 ++++++++++++++
src/app/features/room/poll/PollEvent.css.ts | 40 ++
src/app/features/room/poll/PollEvent.tsx | 479 ++++++++++++++++++
src/app/features/room/poll/index.ts | 3 +
.../hooks/timeline/useProcessedTimeline.ts | 10 +
.../timeline/useTimelineEventRenderer.tsx | 76 +++
src/app/hooks/useClientConfig.ts | 4 +
src/app/hooks/useCommands.ts | 7 +
src/types/matrix-sdk.ts | 15 +
src/types/matrix/room.ts | 4 +
13 files changed, 1130 insertions(+), 14 deletions(-)
create mode 100644 src/app/features/room/poll/PollCreatorDialog.css.ts
create mode 100644 src/app/features/room/poll/PollCreatorDialog.tsx
create mode 100644 src/app/features/room/poll/PollEvent.css.ts
create mode 100644 src/app/features/room/poll/PollEvent.tsx
create mode 100644 src/app/features/room/poll/index.ts
diff --git a/config.json b/config.json
index f0c3c8b61..59b754931 100644
--- a/config.json
+++ b/config.json
@@ -3,10 +3,8 @@
"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",
@@ -18,7 +16,6 @@
"slidingSync": {
"enabled": true
},
-
"featuredCommunities": {
"openAsDefault": false,
"spaces": [
@@ -39,9 +36,11 @@
],
"servers": ["matrixrooms.info", "mozilla.org", "unredacted.org"]
},
-
"hashRouter": {
"enabled": false,
"basename": "/"
+ },
+ "features": {
+ "polls": false
}
}
diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx
index bc30145c8..38ae184c3 100644
--- a/src/app/features/room/RoomInput.tsx
+++ b/src/app/features/room/RoomInput.tsx
@@ -137,6 +137,7 @@ import { MessageEvent } from '$types/matrix/room';
import { usePowerLevelsContext } from '$hooks/usePowerLevels';
import { useRoomCreators } from '$hooks/useRoomCreators';
import { useRoomPermissions } from '$hooks/useRoomPermissions';
+import { useClientConfig } from '$hooks/useClientConfig';
import { AutocompleteNotice } from '$components/editor/autocomplete/AutocompleteNotice';
import {
convertPerMessageProfileToBeeperFormat,
@@ -149,6 +150,8 @@ import { PKitCommandMessageHandler } from '$plugins/pluralkit-handler/PKitComman
import { PKitProxyMessageHandler } from '$plugins/pluralkit-handler/PKitProxyMessageHandler';
import { SchedulePickerDialog } from './schedule-send';
import * as css from './schedule-send/SchedulePickerDialog.css';
+import { PollCreatorDialog } from './poll';
+import type { PollCreatorContent } from './poll';
import {
getAudioMsgContent,
getFileMsgContent,
@@ -364,6 +367,9 @@ export const RoomInput = forwardRef(
);
const [scheduleMenuAnchor, setScheduleMenuAnchor] = useState();
const [showSchedulePicker, setShowSchedulePicker] = useState(false);
+ const [showPollCreator, setShowPollCreator] = useState(false);
+ const clientConfig = useClientConfig();
+ const pollsEnabled = clientConfig.features?.polls ?? false;
const [silentReply, setSilentReply] = useState(!mentionInReplies);
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const isEncrypted = room.hasEncryptionStateEvent();
@@ -766,6 +772,12 @@ export const RoomInput = forwardRef(
} else if (commandName === Command.UnFlip) {
plainText = `${UNFLIP} ${plainText}`;
customHtml = `${UNFLIP} ${customHtml}`;
+ } else if (commandName === Command.Poll) {
+ if (pollsEnabled) setShowPollCreator(true);
+ resetEditor(editor);
+ resetEditorHistory(editor);
+ sendTypingStatus(false);
+ return;
} else if (commandName) {
const commandContent = commands[commandName as Command];
if (commandContent) {
@@ -964,6 +976,8 @@ export const RoomInput = forwardRef(
isEncrypted,
setEditingScheduledDelayId,
setScheduledTime,
+ pollsEnabled,
+ setShowPollCreator,
]);
const handleKeyDown: KeyboardEventHandler = useCallback(
@@ -1366,16 +1380,18 @@ export const RoomInput = forwardRef(
>
}
before={
- pickFile('*')}
- variant="SurfaceVariant"
- size="300"
- radii="300"
- title="Upload File"
- aria-label="Upload and attach a File"
- >
-
-
+
+ pickFile('*')}
+ variant="SurfaceVariant"
+ size="300"
+ radii="300"
+ title="Upload File"
+ aria-label="Upload and attach a File"
+ >
+
+
+
}
after={
<>
@@ -1634,6 +1650,37 @@ export const RoomInput = forwardRef(
}}
/>
)}
+ {showPollCreator && (
+ setShowPollCreator(false)}
+ onSubmit={(content: PollCreatorContent) => {
+ setShowPollCreator(false);
+ const pollKindKey = content.kind;
+ const eventContent: Record = {
+ 'org.matrix.msc1767.text': content.question,
+ 'org.matrix.msc3381.poll.start': {
+ question: {
+ 'org.matrix.msc1767.text': content.question,
+ },
+ kind: pollKindKey,
+ max_selections: content.maxSelections,
+ answers: content.answers.map((a) => ({
+ id: a.id,
+ 'org.matrix.msc1767.text': a.text,
+ })),
+ show_voter_names: content.showVoterNames,
+ ...(content.closesAt !== undefined ? { closes_at: content.closesAt } : {}),
+ },
+ };
+ (mx as any).sendEvent(roomId, 'org.matrix.msc3381.poll.start', eventContent).catch(
+ // unstable MSC3381 type
+ (err: unknown) => {
+ console.error('Failed to send poll:', err);
+ }
+ );
+ }}
+ />
+ )}
);
}
diff --git a/src/app/features/room/poll/PollCreatorDialog.css.ts b/src/app/features/room/poll/PollCreatorDialog.css.ts
new file mode 100644
index 000000000..7bae11054
--- /dev/null
+++ b/src/app/features/room/poll/PollCreatorDialog.css.ts
@@ -0,0 +1,49 @@
+import { style } from '@vanilla-extract/css';
+import { color, config, toRem } from 'folds';
+
+export const DialogContent = style({
+ padding: config.space.S400,
+ minWidth: toRem(340),
+ maxWidth: toRem(500),
+ display: 'flex',
+ flexDirection: 'column',
+ gap: config.space.S300,
+ maxHeight: `min(80vh, ${toRem(600)})`,
+ overflowY: 'auto',
+});
+
+export const AnswerRow = style({
+ display: 'flex',
+ alignItems: 'center',
+ gap: config.space.S200,
+});
+
+export const AnswerInput = style({
+ flex: 1,
+});
+
+export const KindSelector = style({
+ display: 'flex',
+ gap: config.space.S200,
+});
+
+export const ExpirySelector = style({
+ display: 'flex',
+ flexWrap: 'wrap',
+ gap: config.space.S100,
+});
+
+export const DatetimeInput = style({
+ padding: `${config.space.S100} ${config.space.S200}`,
+ borderRadius: config.radii.R300,
+ border: `1px solid ${color.SurfaceVariant.ContainerLine}`,
+ background: color.SurfaceVariant.Container,
+ color: 'inherit',
+ fontSize: config.fontSize.T300,
+ outline: 'none',
+ selectors: {
+ '&:focus': {
+ borderColor: color.Primary.Main,
+ },
+ },
+});
diff --git a/src/app/features/room/poll/PollCreatorDialog.tsx b/src/app/features/room/poll/PollCreatorDialog.tsx
new file mode 100644
index 000000000..e9a1b8ba8
--- /dev/null
+++ b/src/app/features/room/poll/PollCreatorDialog.tsx
@@ -0,0 +1,383 @@
+import { FormEventHandler, useId, useMemo, useRef, useState } from 'react';
+import FocusTrap from 'focus-trap-react';
+import {
+ Box,
+ Button,
+ Chip,
+ Dialog,
+ Header,
+ Icon,
+ IconButton,
+ Icons,
+ Input,
+ Overlay,
+ OverlayBackdrop,
+ OverlayCenter,
+ Text,
+ config,
+} from 'folds';
+import { stopPropagation } from '$utils/keyboard';
+import { M_POLL_KIND_DISCLOSED, M_POLL_KIND_UNDISCLOSED } from '$types/matrix-sdk';
+import * as css from './PollCreatorDialog.css';
+
+const MAX_ANSWERS = 20;
+const MIN_ANSWERS = 2;
+
+type ExpiryPreset = 'none' | '1h' | '12h' | '24h' | '48h' | '1w' | 'custom';
+
+const EXPIRY_PRESETS: { value: ExpiryPreset; label: string }[] = [
+ { value: 'none', label: 'No limit' },
+ { value: '1h', label: '1 hour' },
+ { value: '12h', label: '12 hours' },
+ { value: '24h', label: '24 hours' },
+ { value: '48h', label: '48 hours' },
+ { value: '1w', label: '1 week' },
+ { value: 'custom', label: 'Customβ¦' },
+];
+
+const HOUR_MS = 3_600_000;
+
+export type PollCreatorContent = {
+ question: string;
+ answers: Array<{ id: string; text: string }>;
+ kind: string;
+ maxSelections: number;
+ showVoterNames: boolean;
+ closesAt?: number;
+};
+
+type PollCreatorDialogProps = {
+ onCancel: () => void;
+ onSubmit: (content: PollCreatorContent) => void;
+};
+
+export function PollCreatorDialog({ onCancel, onSubmit }: PollCreatorDialogProps) {
+ const questionId = useId();
+ const [question, setQuestion] = useState('');
+ const [answers, setAnswers] = useState<{ id: string; text: string }[]>(() => [
+ { id: crypto.randomUUID(), text: '' },
+ { id: crypto.randomUUID(), text: '' },
+ ]);
+ const [kind, setKind] = useState(
+ M_POLL_KIND_DISCLOSED.altName ?? 'org.matrix.msc3381.poll.disclosed'
+ );
+ const [showVoterNames, setShowVoterNames] = useState(true);
+ const [expiryPreset, setExpiryPreset] = useState('none');
+ const [customExpiry, setCustomExpiry] = useState('');
+ const [error, setError] = useState();
+ const lastInputRef = useRef(null);
+
+ const minDatetime = useMemo(
+ () => new Date(Date.now() + 60_000).toISOString().slice(0, 16),
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [expiryPreset]
+ );
+
+ const computeClosesAt = (): number | undefined => {
+ const now = Date.now();
+ switch (expiryPreset) {
+ case '1h':
+ return now + HOUR_MS;
+ case '12h':
+ return now + 12 * HOUR_MS;
+ case '24h':
+ return now + 24 * HOUR_MS;
+ case '48h':
+ return now + 48 * HOUR_MS;
+ case '1w':
+ return now + 7 * 24 * HOUR_MS;
+ case 'custom': {
+ const ts = customExpiry ? new Date(customExpiry).getTime() : NaN;
+ return Number.isFinite(ts) && ts > Date.now() ? ts : undefined;
+ }
+ default:
+ return undefined;
+ }
+ };
+
+ const handleAddAnswer = () => {
+ if (answers.length >= MAX_ANSWERS) return;
+ setAnswers((prev) => [...prev, { id: crypto.randomUUID(), text: '' }]);
+ // Focus the new answer field on next render
+ setTimeout(() => lastInputRef.current?.focus(), 0);
+ };
+
+ const handleRemoveAnswer = (id: string) => {
+ setAnswers((prev) => prev.filter((a) => a.id !== id));
+ };
+
+ const handleAnswerChange = (id: string, value: string) => {
+ setAnswers((prev) => prev.map((a) => (a.id === id ? { ...a, text: value } : a)));
+ };
+
+ const handleSubmit: FormEventHandler = (evt) => {
+ evt.preventDefault();
+ const trimmedQuestion = question.trim();
+ if (!trimmedQuestion) {
+ setError('Please enter a question.');
+ return;
+ }
+ const validAnswers = answers.map((a) => ({ ...a, text: a.text.trim() })).filter((a) => a.text);
+ if (validAnswers.length < MIN_ANSWERS) {
+ setError(`Please add at least ${MIN_ANSWERS} answers.`);
+ return;
+ }
+ if (expiryPreset === 'custom') {
+ const ts = customExpiry ? new Date(customExpiry).getTime() : NaN;
+ if (!Number.isFinite(ts) || ts <= Date.now()) {
+ setError('Please choose a future date and time for the custom expiry.');
+ return;
+ }
+ }
+ setError(undefined);
+ onSubmit({
+ question: trimmedQuestion,
+ answers: validAnswers,
+ kind,
+ maxSelections: 1,
+ showVoterNames,
+ closesAt: computeClosesAt(),
+ });
+ };
+
+ return (
+ }>
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/features/room/poll/PollEvent.css.ts b/src/app/features/room/poll/PollEvent.css.ts
new file mode 100644
index 000000000..26e5433f9
--- /dev/null
+++ b/src/app/features/room/poll/PollEvent.css.ts
@@ -0,0 +1,40 @@
+import { style } from '@vanilla-extract/css';
+import { config } from 'folds';
+
+// Vote button wrapping just the radio circle - minimal touch target
+export const RadioZone = style({
+ all: 'unset',
+ cursor: 'pointer',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ flexShrink: 0,
+ padding: `${config.space.S100} 0`,
+ selectors: {
+ '&:disabled': {
+ cursor: 'default',
+ },
+ },
+});
+
+// Text + percent area - clickable to reveal voters
+export const AnswerTextButton = style({
+ all: 'unset',
+ cursor: 'pointer',
+ display: 'flex',
+ flex: 1,
+ alignItems: 'center',
+ gap: config.space.S200,
+ minWidth: 0,
+ padding: `${config.space.S100} 0`,
+});
+
+// Non-interactive version of the text area
+export const AnswerTextRow = style({
+ display: 'flex',
+ flex: 1,
+ alignItems: 'center',
+ gap: config.space.S200,
+ minWidth: 0,
+ padding: `${config.space.S100} 0`,
+});
diff --git a/src/app/features/room/poll/PollEvent.tsx b/src/app/features/room/poll/PollEvent.tsx
new file mode 100644
index 000000000..0ebfe58b1
--- /dev/null
+++ b/src/app/features/room/poll/PollEvent.tsx
@@ -0,0 +1,479 @@
+import { type ReactNode, useCallback, useEffect, useMemo, useReducer, useState } from 'react';
+import FocusTrap from 'focus-trap-react';
+import {
+ Box,
+ Button,
+ config,
+ Icon,
+ Icons,
+ Line,
+ Menu,
+ PopOut,
+ ProgressBar,
+ RadioButton,
+ Scroll,
+ Text,
+ toRem,
+} from 'folds';
+import {
+ M_POLL_END,
+ M_POLL_KIND_DISCLOSED,
+ M_POLL_RESPONSE,
+ M_POLL_START,
+ MatrixEvent,
+ Room,
+ RoomEvent,
+} from '$types/matrix-sdk';
+import { useMatrixClient } from '$hooks/useMatrixClient';
+import { stopPropagation } from '$utils/keyboard';
+import {
+ Attachment,
+ AttachmentBox,
+ AttachmentContent,
+ AttachmentHeader,
+} from '$components/message/attachment/Attachment';
+import { MessageEvent } from '$types/matrix/room';
+import * as css from './PollEvent.css';
+
+type PollAnswer = { id: string; text: string };
+
+function extractPollData(mEvent: MatrixEvent): {
+ question: string;
+ answers: PollAnswer[];
+ maxSelections: number;
+ isDisclosed: boolean;
+ showVoterNames: boolean;
+ closesAt: number | undefined;
+} | null {
+ const content = mEvent.getContent();
+ const pollStartKey = M_POLL_START.altName ?? 'org.matrix.msc3381.poll.start';
+ const pollData = content[M_POLL_START.name] ?? content[pollStartKey];
+ if (!pollData) return null;
+
+ const questionText =
+ (pollData.question?.['m.text'] as { body: string }[] | undefined)?.[0]?.body ??
+ (pollData.question?.['org.matrix.msc1767.text'] as string | undefined) ??
+ '';
+ const rawAnswers: {
+ id?: string;
+ 'm.id'?: string;
+ 'org.matrix.msc1767.text'?: string;
+ 'm.text'?: { body: string }[];
+ }[] = pollData.answers ?? [];
+ const answers: PollAnswer[] = rawAnswers.slice(0, 20).map((a) => ({
+ id: String(a['m.id'] ?? a.id ?? ''),
+ text:
+ (a['m.text'] as { body: string }[] | undefined)?.[0]?.body ??
+ a['org.matrix.msc1767.text'] ??
+ '',
+ }));
+ const maxSelections =
+ typeof pollData.max_selections === 'number' && pollData.max_selections >= 1
+ ? pollData.max_selections
+ : 1;
+ const kind = pollData.kind ?? '';
+ const isDisclosed =
+ kind === M_POLL_KIND_DISCLOSED.name ||
+ kind === (M_POLL_KIND_DISCLOSED.altName ?? 'org.matrix.msc3381.poll.disclosed');
+ const showVoterNames = pollData.show_voter_names !== false;
+ const rawClosesAt = pollData.closes_at;
+ const closesAt = typeof rawClosesAt === 'number' && rawClosesAt > 0 ? rawClosesAt : undefined;
+ return { question: questionText, answers, maxSelections, isDisclosed, showVoterNames, closesAt };
+}
+
+function extractVoteSelections(responseEvent: MatrixEvent): string[] {
+ const content = responseEvent.getContent();
+ const unstablePayload = content['org.matrix.msc3381.poll.response'];
+ const selections: unknown =
+ content['m.selections'] ??
+ (typeof unstablePayload === 'object' && unstablePayload !== null
+ ? (unstablePayload as { answers?: unknown }).answers
+ : undefined);
+ if (!Array.isArray(selections)) return [];
+ return selections.filter((s): s is string => typeof s === 'string');
+}
+
+type TallyResult = {
+ tally: Map>;
+ myVote: string[];
+ isEnded: boolean;
+};
+
+function computeTally(
+ room: Room,
+ pollEventId: string,
+ pollStartEvent: MatrixEvent,
+ answers: PollAnswer[],
+ maxSelections: number,
+ myUserId: string
+): TallyResult {
+ const childEvents = room
+ .getUnfilteredTimelineSet()
+ .relations.getAllChildEventsForEvent(pollEventId);
+
+ const userVotes = new Map();
+ const validAnswerIds = new Set(answers.map((a) => a.id));
+ const pollCreator = pollStartEvent.getSender();
+ let isEnded = false;
+ let endTs: number | undefined;
+
+ childEvents.forEach((event) => {
+ if (M_POLL_END.matches(event.getType())) {
+ const sender = event.getSender();
+ if (!sender) return;
+ const ts = event.getTs();
+ if (
+ sender !== pollCreator &&
+ !room.currentState.maySendRedactionForEvent(pollStartEvent, sender)
+ )
+ return;
+ if (endTs !== undefined && endTs <= ts) return;
+ endTs = ts;
+ isEnded = true;
+ }
+ if (M_POLL_RESPONSE.matches(event.getType())) {
+ if (event.isDecryptionFailure()) return;
+ const sender = event.getSender();
+ if (!sender) return;
+ const ts = event.getTs();
+ const existing = userVotes.get(sender);
+ if (existing && existing.ts >= ts) return;
+ userVotes.set(sender, { ts, selections: extractVoteSelections(event) });
+ }
+ });
+
+ const cutoff = endTs ?? Number.MAX_SAFE_INTEGER;
+ const tally = new Map>(answers.map((a) => [a.id, new Set()]));
+ userVotes.forEach(({ ts, selections }, userId) => {
+ if (ts > cutoff) return;
+ const valid = selections.slice(0, maxSelections);
+ if (!valid.every((s) => validAnswerIds.has(s))) return;
+ valid.forEach((sel) => tally.get(sel)?.add(userId));
+ });
+
+ const myEntry = userVotes.get(myUserId);
+ let myVote: string[] = [];
+ if (myEntry && myEntry.ts <= cutoff) {
+ const myValid = myEntry.selections.slice(0, maxSelections);
+ if (myValid.every((s) => validAnswerIds.has(s))) myVote = myValid;
+ }
+
+ return { tally, myVote, isEnded };
+}
+
+function formatExpiry(ts: number): string {
+ const diff = ts - Date.now();
+ if (diff <= 0) return 'now';
+ const hours = diff / 3_600_000;
+ if (hours < 1) return `in ${Math.round(diff / 60_000)} min`;
+ if (hours < 24) return `in ${Math.round(hours)} hr`;
+ const days = hours / 24;
+ if (days < 7) return `in ${Math.round(days)} day${Math.round(days) === 1 ? '' : 's'}`;
+ return new Date(ts).toLocaleDateString();
+}
+
+type PollEventProps = {
+ room: Room;
+ mEvent: MatrixEvent;
+ canEnd: boolean;
+ outlined?: boolean;
+};
+
+export function PollEvent({ room, mEvent, canEnd, outlined }: PollEventProps) {
+ const mx = useMatrixClient();
+ const myUserId = mx.getUserId() ?? '';
+ const pollEventId = mEvent.getId() ?? '';
+ const [tick, incrementTick] = useReducer((n: number) => n + 1, 0);
+ const [, forceExpiry] = useReducer((n: number) => n + 1, 0);
+
+ const pollData = useMemo(() => extractPollData(mEvent), [mEvent]);
+
+ // Re-compute tally whenever a new response/end event lands
+ useEffect(() => {
+ const onTimeline = (event: MatrixEvent) => {
+ const relTo = event.getContent()?.['m.relates_to']?.event_id;
+ if (relTo === pollEventId) incrementTick();
+ };
+ room.on(RoomEvent.Timeline, onTimeline);
+ return () => {
+ room.off(RoomEvent.Timeline, onTimeline);
+ };
+ }, [room, pollEventId]);
+
+ // Re-render when the expiry countdown reaches zero
+ useEffect(() => {
+ if (!pollData?.closesAt) return undefined;
+ const remaining = pollData.closesAt - Date.now();
+ if (remaining <= 0) return undefined;
+ const timer = setTimeout(forceExpiry, remaining);
+ return () => clearTimeout(timer);
+ }, [pollData?.closesAt]);
+
+ const { tally, myVote, isEnded } = useMemo(
+ () =>
+ pollData
+ ? computeTally(
+ room,
+ pollEventId,
+ mEvent,
+ pollData.answers,
+ pollData.maxSelections,
+ myUserId
+ )
+ : { tally: new Map>(), myVote: [] as string[], isEnded: false },
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [room, pollEventId, mEvent, pollData, myUserId, tick]
+ );
+
+ const isExpiredByTime = pollData?.closesAt !== undefined && Date.now() >= pollData.closesAt;
+ const effectivelyEnded = isEnded || isExpiredByTime;
+ const showResults = effectivelyEnded || (pollData?.isDisclosed ?? false);
+
+ const totalVoters = useMemo(
+ () => new Set([...tally.values()].flatMap((s) => [...s])).size,
+ [tally]
+ );
+
+ const handleAnswerClick = useCallback(
+ (answerId: string) => {
+ if (effectivelyEnded || !pollData) return;
+ const { maxSelections } = pollData;
+ let next: string[];
+ if (maxSelections === 1) {
+ next = myVote[0] === answerId ? [] : [answerId];
+ } else if (myVote.includes(answerId)) {
+ next = myVote.filter((id) => id !== answerId);
+ } else {
+ next = [...myVote, answerId].slice(0, maxSelections);
+ }
+ const selections: Record = { 'm.selections': next };
+ mx.sendEvent(room.roomId, MessageEvent.PollResponse as any, {
+ 'm.relates_to': { rel_type: 'm.reference', event_id: pollEventId },
+ ...selections,
+ 'org.matrix.msc3381.poll.response': { answers: next },
+ }).catch(() => undefined);
+ },
+ [effectivelyEnded, pollData, myVote, mx, room.roomId, pollEventId]
+ );
+
+ const endPoll = useCallback(() => {
+ mx.sendEvent(room.roomId, MessageEvent.PollEnd as any, {
+ 'm.relates_to': { rel_type: 'm.reference', event_id: pollEventId },
+ 'org.matrix.msc3381.poll.end': {},
+ body: 'The poll has ended',
+ }).catch(() => undefined);
+ }, [mx, room.roomId, pollEventId]);
+
+ const [expandedVoters, setExpandedVoters] = useState<{ id: string; anchor: DOMRect } | null>(
+ null
+ );
+ const toggleVoters = useCallback(
+ (id: string, anchor: DOMRect) =>
+ setExpandedVoters((prev) => (prev?.id === id ? null : { id, anchor })),
+ []
+ );
+ const canShowVoters = (pollData?.showVoterNames ?? false) && showResults;
+
+ if (!pollData) return null;
+
+ const { question, answers, isDisclosed, closesAt } = pollData;
+ const voterLabel = `${totalVoters} ${totalVoters === 1 ? 'voter' : 'voters'}`;
+
+ let statusText: string;
+ if (isEnded) statusText = `Poll ended Β· ${voterLabel}`;
+ else if (isExpiredByTime) statusText = `Poll expired Β· ${voterLabel}`;
+ else if (closesAt !== undefined && !isDisclosed)
+ statusText = `${voterLabel} Β· Results hidden until closed Β· Closes ${formatExpiry(closesAt)}`;
+ else if (closesAt !== undefined) statusText = `${voterLabel} Β· Closes ${formatExpiry(closesAt)}`;
+ else if (!isDisclosed) statusText = `${voterLabel} Β· Results hidden until closed`;
+ else statusText = voterLabel;
+
+ return (
+
+
+
+
+ {isDisclosed ? 'Poll' : 'Undisclosed Poll'}
+
+
+
+ {voterLabel}
+
+
+
+
+
+ {question || '(no question)'}
+
+
+ {answers.map((answer) => {
+ const voteCount = tally.get(answer.id)?.size ?? 0;
+ const percent = totalVoters > 0 ? Math.round((voteCount / totalVoters) * 100) : 0;
+ const isSelected = myVote.includes(answer.id);
+
+ let textZone: ReactNode;
+ if (canShowVoters && voteCount > 0) {
+ textZone = (
+
+ );
+ } else if (!effectivelyEnded) {
+ textZone = (
+
+ );
+ } else {
+ textZone = (
+
+
+ {answer.text}
+
+ {showResults && (
+
+ {percent}%
+
+ )}
+
+ );
+ }
+
+ return (
+
+
+
+ {textZone}
+
+ {showResults && (
+
+ )}
+
+ );
+ })}
+
+
+
+
+ {statusText}
+
+ {!effectivelyEnded && canEnd && (
+ }
+ onClick={endPoll}
+ >
+ End Poll
+
+ )}
+
+
+
+
+ {expandedVoters && canShowVoters && (
+ setExpandedVoters(null),
+ clickOutsideDeactivates: true,
+ escapeDeactivates: stopPropagation,
+ }}
+ >
+
+
+ }
+ />
+ )}
+
+ );
+}
diff --git a/src/app/features/room/poll/index.ts b/src/app/features/room/poll/index.ts
new file mode 100644
index 000000000..3bcd8f5df
--- /dev/null
+++ b/src/app/features/room/poll/index.ts
@@ -0,0 +1,3 @@
+export { PollEvent } from './PollEvent';
+export { PollCreatorDialog } from './PollCreatorDialog';
+export type { PollCreatorContent } from './PollCreatorDialog';
diff --git a/src/app/hooks/timeline/useProcessedTimeline.ts b/src/app/hooks/timeline/useProcessedTimeline.ts
index f1f799308..f44719e88 100644
--- a/src/app/hooks/timeline/useProcessedTimeline.ts
+++ b/src/app/hooks/timeline/useProcessedTimeline.ts
@@ -97,6 +97,16 @@ export function useProcessedTimeline({
if (!membershipChanged && hideNickAvatarEvents) return acc;
}
+ // Poll response and end events are always filtered β they update the poll tally
+ // via RoomEvent.Timeline listeners in PollEvent and must never render as timeline items.
+ if (
+ type === 'org.matrix.msc3381.poll.response' ||
+ type === 'org.matrix.msc3381.poll.end' ||
+ type === 'm.poll.response' ||
+ type === 'm.poll.end'
+ )
+ return acc;
+
if (!showHiddenEvents) {
const isStandardRendered = [
'm.room.message',
diff --git a/src/app/hooks/timeline/useTimelineEventRenderer.tsx b/src/app/hooks/timeline/useTimelineEventRenderer.tsx
index f7dca411a..dbfa13721 100644
--- a/src/app/hooks/timeline/useTimelineEventRenderer.tsx
+++ b/src/app/hooks/timeline/useTimelineEventRenderer.tsx
@@ -54,6 +54,7 @@ import {
Message,
Reactions,
} from '$features/room/message';
+import { PollEvent } from '$features/room/poll';
import { useSableCosmetics } from '$hooks/useSableCosmetics';
@@ -1079,6 +1080,81 @@ export function useTimelineEventRenderer({
);
},
+ [MessageEvent.PollStart]: (mEventId, mEvent, item, timelineSet, collapse) => {
+ const { getSender, getAssociatedStatus, isRedacted, getUnsigned } = mEvent;
+ const reactionRelations = getEventReactions(timelineSet, mEventId);
+ const reactions = reactionRelations?.getSortedAnnotationsByKey();
+ const hasReactions = reactions && reactions.length > 0;
+ const highlighted = focusItem?.index === item && focusItem.highlight;
+ const senderId = getSender.call(mEvent) ?? '';
+ const senderDisplayName =
+ getMemberDisplayName(room, senderId, nicknames) ?? getMxIdLocalPart(senderId) ?? senderId;
+ const myUserId = mx.getUserId() ?? '';
+ const canEnd = myUserId === senderId || canRedact;
+
+ return (
+
+ ) : undefined
+ }
+ hideReadReceipts={hideReads}
+ showDeveloperTools={showDeveloperTools}
+ memberPowerTag={getMemberPowerTag(senderId)}
+ hour24Clock={hour24Clock}
+ dateFormatString={dateFormatString}
+ >
+ {isRedacted.call(mEvent) ? (
+
+ ) : (
+
+ )}
+
+ );
+ },
+ // Poll response and end events are not rendered individually β
+ // they update the poll via RoomEvent.Timeline listeners in PollEvent.
+ [MessageEvent.PollResponse]: () => null,
+ [MessageEvent.PollEnd]: () => null,
},
(mEventId, mEvent, item, timelineSet, collapse) => {
const { getSender, getTs, getType } = mEvent;
diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts
index e523f15a7..4cb135a16 100644
--- a/src/app/hooks/useClientConfig.ts
+++ b/src/app/hooks/useClientConfig.ts
@@ -43,6 +43,10 @@ export type ClientConfig = {
matrixToBaseUrl?: string;
settingsLinkBaseUrl?: string;
+
+ features?: {
+ polls?: boolean;
+ };
};
const ClientConfigContext = createContext(null);
diff --git a/src/app/hooks/useCommands.ts b/src/app/hooks/useCommands.ts
index 8d803d4f7..cc084b34c 100644
--- a/src/app/hooks/useCommands.ts
+++ b/src/app/hooks/useCommands.ts
@@ -272,6 +272,8 @@ export enum Command {
// Spec missing from cinny
Location = 'location',
ShareMyLocation = 'sharemylocation',
+ // Polls
+ Poll = 'poll',
}
export type CommandContent = {
@@ -1569,6 +1571,11 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
navigator.geolocation.getCurrentPosition(success, error, options);
},
},
+ [Command.Poll]: {
+ name: Command.Poll,
+ description: 'Create a poll',
+ exe: async () => undefined,
+ },
}),
[
mx,
diff --git a/src/types/matrix-sdk.ts b/src/types/matrix-sdk.ts
index 06a47368b..95d584a3e 100644
--- a/src/types/matrix-sdk.ts
+++ b/src/types/matrix-sdk.ts
@@ -54,3 +54,18 @@ export * from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession';
export { ThreadEvent } from 'matrix-js-sdk/lib/models/thread';
export type { Thread } from 'matrix-js-sdk/lib/models/thread';
+
+export {
+ M_POLL_START,
+ M_POLL_RESPONSE,
+ M_POLL_END,
+ M_POLL_KIND_DISCLOSED,
+ M_POLL_KIND_UNDISCLOSED,
+} from 'matrix-js-sdk/lib/@types/polls';
+export type {
+ PollStartEventContent,
+ PollResponseEventContent,
+ PollEndEventContent,
+ PollAnswer,
+ PollKind,
+} from 'matrix-js-sdk/lib/@types/polls';
diff --git a/src/types/matrix/room.ts b/src/types/matrix/room.ts
index f851b9e42..3c252b7f0 100644
--- a/src/types/matrix/room.ts
+++ b/src/types/matrix/room.ts
@@ -58,6 +58,10 @@ export enum MessageEvent {
Sticker = 'm.sticker',
RoomRedaction = 'm.room.redaction',
Reaction = 'm.reaction',
+ // MSC3381 Polls β unstable prefix (stable types not yet in a shipped room version)
+ PollStart = 'org.matrix.msc3381.poll.start',
+ PollResponse = 'org.matrix.msc3381.poll.response',
+ PollEnd = 'org.matrix.msc3381.poll.end',
}
export enum RoomType {
From d9a66566e39927289516dd7601df0438f62764f9 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Mon, 6 Apr 2026 12:43:31 -0400
Subject: [PATCH 044/253] chore: add changeset for feat-polls
---
.changeset/feat-polls.md | 5 +++++
1 file changed, 5 insertions(+)
create mode 100644 .changeset/feat-polls.md
diff --git a/.changeset/feat-polls.md b/.changeset/feat-polls.md
new file mode 100644
index 000000000..6e7522702
--- /dev/null
+++ b/.changeset/feat-polls.md
@@ -0,0 +1,5 @@
+---
+default: minor
+---
+
+Add MSC3381 polls: create, vote on, and end polls directly in rooms (opt-in via `features.polls` in config.json).
From 117949f9175aa2d235547b1238457d55c694a9c4 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Mon, 6 Apr 2026 12:46:46 -0400
Subject: [PATCH 045/253] feat(bookmarks): add message bookmarks (MSC4438)
---
config.json | 9 +
src/app/features/bookmarks/bookmarkDomain.ts | 171 ++++++
.../features/bookmarks/bookmarkRepository.ts | 147 +++++
src/app/features/bookmarks/useBookmarks.ts | 78 +++
.../features/bookmarks/useInitBookmarks.ts | 70 +++
src/app/features/room/message/Message.tsx | 55 ++
.../settings/experimental/Experimental.tsx | 2 +
.../experimental/MSC4438MessageBookmarks.tsx | 56 ++
src/app/hooks/router/useHomeSelected.ts | 11 +
src/app/hooks/router/useInbox.ts | 17 +-
src/app/hooks/useClientConfig.ts | 67 +++
src/app/pages/Router.tsx | 4 +
src/app/pages/client/ClientNonUIFeatures.tsx | 7 +
.../pages/client/bookmarks/BookmarksList.tsx | 560 ++++++++++++++++++
src/app/pages/client/bookmarks/index.ts | 1 +
src/app/pages/client/home/Home.tsx | 162 ++---
src/app/pages/client/inbox/Inbox.tsx | 44 +-
src/app/pages/pathUtils.ts | 4 +
src/app/pages/paths.ts | 3 +
src/app/state/bookmarks.ts | 20 +
src/app/state/settings.ts | 6 +
src/types/matrix/accountData.ts | 5 +
22 files changed, 1429 insertions(+), 70 deletions(-)
create mode 100644 src/app/features/bookmarks/bookmarkDomain.ts
create mode 100644 src/app/features/bookmarks/bookmarkRepository.ts
create mode 100644 src/app/features/bookmarks/useBookmarks.ts
create mode 100644 src/app/features/bookmarks/useInitBookmarks.ts
create mode 100644 src/app/features/settings/experimental/MSC4438MessageBookmarks.tsx
create mode 100644 src/app/pages/client/bookmarks/BookmarksList.tsx
create mode 100644 src/app/pages/client/bookmarks/index.ts
create mode 100644 src/app/state/bookmarks.ts
diff --git a/config.json b/config.json
index f0c3c8b61..4f3dae570 100644
--- a/config.json
+++ b/config.json
@@ -19,6 +19,15 @@
"enabled": true
},
+ "experiments": {
+ "messageBookmarks": {
+ "enabled": false,
+ "rolloutPercentage": 0,
+ "controlVariant": "control",
+ "variants": ["enabled"]
+ }
+ },
+
"featuredCommunities": {
"openAsDefault": false,
"spaces": [
diff --git a/src/app/features/bookmarks/bookmarkDomain.ts b/src/app/features/bookmarks/bookmarkDomain.ts
new file mode 100644
index 000000000..15da412c9
--- /dev/null
+++ b/src/app/features/bookmarks/bookmarkDomain.ts
@@ -0,0 +1,171 @@
+/**
+ * MSC4438: Message bookmarks via account data
+ * https://github.com/matrix-org/matrix-spec-proposals/pull/4438
+ *
+ * Unstable event type names in use (will migrate to stable names once MSC is accepted):
+ * m.bookmarks.index β org.matrix.msc4438.bookmarks.index
+ * m.bookmark. β org.matrix.msc4438.bookmark.
+ *
+ * Bookmark ID algorithm: djb2-like 32-bit hash over "|", prefixed with "bmk_".
+ * This matches the reference implementation in smokku/cinny commit 6363e441 and is used here for
+ * cross-client interoperability. If the algorithm ever changes, a migration must be provided so
+ * that existing bookmarks can have their IDs recomputed (the ID is stored in the item event, so
+ * old items remain accessible).
+ */
+
+import { MatrixEvent, Room } from '$types/matrix-sdk';
+import { AccountDataEvent } from '$types/matrix/accountData';
+
+export type BookmarkIndexContent = {
+ version: 1;
+ revision: number;
+ updated_ts: number;
+ bookmark_ids: string[];
+};
+
+export type BookmarkItemContent = {
+ version: 1;
+ bookmark_id: string;
+ uri: string;
+ room_id: string;
+ event_id: string;
+ event_ts: number;
+ bookmarked_ts: number;
+ sender?: string;
+ room_name?: string;
+ body_preview?: string;
+ msgtype?: string;
+ deleted?: boolean;
+};
+
+/**
+ * Compute a bookmark ID for a (roomId, eventId) pair using the reference
+ * djb2-style algorithm agreed upon with the Cinny proof-of-concept.
+ *
+ * Input string: "|"
+ * Algorithm: For each UTF-16 code unit ch, hash = ((hash << 5) - hash + ch) | 0
+ * Output: "bmk_" + unsigned 32-bit hex, zero-padded to 8 chars
+ *
+ * NOTE: If this algorithm is ever changed, a migration helper must be written
+ * so that existing bookmarked items (whose IDs are stored on the server as
+ * account data event-type suffixes) can still be resolved. The bookmark_id
+ * field inside each item event is the canonical reference.
+ */
+export function computeBookmarkId(roomId: string, eventId: string): string {
+ const input = `${roomId}|${eventId}`;
+ let hash = 0;
+ for (let i = 0; i < input.length; i += 1) {
+ const ch = input.charCodeAt(i);
+ // eslint-disable-next-line no-bitwise
+ hash = ((hash << 5) - hash + ch) | 0;
+ }
+ // Convert to unsigned 32-bit integer and encode as 8-char lowercase hex
+ // eslint-disable-next-line no-bitwise
+ const hex = (hash >>> 0).toString(16).padStart(8, '0');
+ return `bmk_${hex}`;
+}
+
+/** Construct the account data event type for a bookmark item. */
+export function bookmarkItemEventType(bookmarkId: string): string {
+ return `${AccountDataEvent.BookmarkItemPrefix}${bookmarkId}`;
+}
+
+/**
+ * Build a matrix: URI for a room event.
+ * Canonical form: matrix:roomid//e/
+ * (MSC4438 Β§Matrix URI)
+ */
+export function buildMatrixURI(roomId: string, eventId: string): string {
+ return `matrix:roomid/${encodeURIComponent(roomId)}/e/${encodeURIComponent(eventId)}`;
+}
+
+const BODY_PREVIEW_MAX_LENGTH = 120;
+
+/**
+ * Extract a short preview of the event body for display in the bookmark list.
+ * Truncated to 120 chars with an ellipsis (MSC4438 Β§Body preview).
+ *
+ * Security: preview is only used as plain text in the UI, never parsed as HTML.
+ * Encrypted-room callers may choose to pass an empty string to avoid leaking
+ * plaintext into unencrypted account data (MSC4438 Β§Security considerations).
+ */
+export function extractBodyPreview(
+ mEvent: MatrixEvent,
+ maxLength = BODY_PREVIEW_MAX_LENGTH
+): string {
+ const content = mEvent.getContent();
+ const body = content?.body;
+ if (typeof body !== 'string' || body.length === 0) return '';
+ if (body.length <= maxLength) return body;
+ return `${body.slice(0, maxLength)}\u2026`;
+}
+
+/**
+ * Build a BookmarkItemContent from a room and event.
+ *
+ * Security: optional metadata (sender, room_name, body_preview) is copied into
+ * unencrypted account data. For encrypted rooms the caller may choose to omit
+ * these fields, storing only the required fields (room_id, event_id, uri).
+ * Currently we always populate them for usability; future work could honour a
+ * "privacy mode" setting.
+ */
+export function createBookmarkItem(
+ room: Room,
+ mEvent: MatrixEvent
+): BookmarkItemContent | undefined {
+ const eventId = mEvent.getId();
+ const { roomId } = room;
+ if (!eventId) return undefined;
+
+ const bookmarkId = computeBookmarkId(roomId, eventId);
+
+ return {
+ version: 1,
+ bookmark_id: bookmarkId,
+ uri: buildMatrixURI(roomId, eventId),
+ room_id: roomId,
+ event_id: eventId,
+ event_ts: mEvent.getTs(),
+ bookmarked_ts: Date.now(),
+ sender: mEvent.getSender() ?? undefined,
+ room_name: room.name,
+ body_preview: extractBodyPreview(mEvent),
+ msgtype: mEvent.getContent()?.msgtype,
+ };
+}
+
+// Validators (MSC4438: clients must validate before use)
+export function isValidIndexContent(content: unknown): content is BookmarkIndexContent {
+ if (typeof content !== 'object' || content === null) return false;
+ const c = content as Record;
+ return (
+ c.version === 1 &&
+ typeof c.revision === 'number' &&
+ typeof c.updated_ts === 'number' &&
+ Array.isArray(c.bookmark_ids) &&
+ (c.bookmark_ids as unknown[]).every((id) => typeof id === 'string')
+ );
+}
+
+export function isValidBookmarkItem(content: unknown): content is BookmarkItemContent {
+ if (typeof content !== 'object' || content === null) return false;
+ const c = content as Record;
+ return (
+ c.version === 1 &&
+ typeof c.bookmark_id === 'string' &&
+ typeof c.uri === 'string' &&
+ typeof c.room_id === 'string' &&
+ typeof c.event_id === 'string' &&
+ typeof c.event_ts === 'number' &&
+ typeof c.bookmarked_ts === 'number'
+ );
+}
+
+export function emptyIndex(): BookmarkIndexContent {
+ return {
+ version: 1,
+ revision: 0,
+ updated_ts: Date.now(),
+ bookmark_ids: [],
+ };
+}
diff --git a/src/app/features/bookmarks/bookmarkRepository.ts b/src/app/features/bookmarks/bookmarkRepository.ts
new file mode 100644
index 000000000..d6703777f
--- /dev/null
+++ b/src/app/features/bookmarks/bookmarkRepository.ts
@@ -0,0 +1,147 @@
+/**
+ * Bookmark repository: low-level read/write operations against Matrix account data.
+ *
+ * All writes follow the MSC4438 ordering guarantee:
+ * item is written first β index is updated second
+ * This ensures that when other devices receive the updated index via /sync, the
+ * referenced item event is already available.
+ */
+
+import { MatrixClient } from '$types/matrix-sdk';
+import { AccountDataEvent } from '$types/matrix/accountData';
+import {
+ BookmarkIndexContent,
+ BookmarkItemContent,
+ bookmarkItemEventType,
+ emptyIndex,
+ isValidBookmarkItem,
+ isValidIndexContent,
+} from './bookmarkDomain';
+
+// Internal helpers
+function readIndex(mx: MatrixClient): BookmarkIndexContent {
+ const evt = mx.getAccountData(AccountDataEvent.BookmarksIndex as any);
+ const content = evt?.getContent();
+ if (isValidIndexContent(content)) return content;
+ return emptyIndex();
+}
+
+function readItem(mx: MatrixClient, bookmarkId: string): BookmarkItemContent | undefined {
+ const evt = mx.getAccountData(bookmarkItemEventType(bookmarkId) as any);
+ const content = evt?.getContent();
+ // Must be valid and not tombstoned (MSC4438 Β§Listing bookmarks)
+ if (isValidBookmarkItem(content) && !content.deleted) return content;
+ return undefined;
+}
+
+async function writeIndex(mx: MatrixClient, index: BookmarkIndexContent): Promise {
+ await mx.setAccountData(AccountDataEvent.BookmarksIndex as any, index as any);
+}
+
+async function writeItem(mx: MatrixClient, item: BookmarkItemContent): Promise {
+ await mx.setAccountData(bookmarkItemEventType(item.bookmark_id) as any, item as any);
+}
+
+// Public API
+/**
+ * Add a bookmark.
+ *
+ * MSC4438 Β§Adding a bookmark:
+ * 1. Write the item event first.
+ * 2. Prepend the ID to bookmark_ids (if not already present).
+ * 3. Increment revision and update timestamp.
+ * 4. Write the updated index.
+ */
+export async function addBookmark(mx: MatrixClient, item: BookmarkItemContent): Promise {
+ // Write item before updating index (cross-device consistency)
+ await writeItem(mx, item);
+
+ const index = readIndex(mx);
+ if (!index.bookmark_ids.includes(item.bookmark_id)) {
+ index.bookmark_ids.unshift(item.bookmark_id);
+ }
+ index.revision += 1;
+ index.updated_ts = Date.now();
+ await writeIndex(mx, index);
+}
+
+/**
+ * Remove a bookmark.
+ *
+ * MSC4438 Β§Removing a bookmark:
+ * 1. Remove the ID from the index.
+ * 2. Soft-delete the item (set deleted: true).
+ *
+ * Account data events cannot be deleted from the server, so soft-deletion is
+ * used. Other clients that encounter the item event can see it is explicitly
+ * removed.
+ */
+export async function removeBookmark(mx: MatrixClient, bookmarkId: string): Promise {
+ const index = readIndex(mx);
+ index.bookmark_ids = index.bookmark_ids.filter((id) => id !== bookmarkId);
+ index.revision += 1;
+ index.updated_ts = Date.now();
+ await writeIndex(mx, index);
+
+ // Soft-delete the item event
+ const existing = readItem(mx, bookmarkId);
+ if (existing) {
+ await writeItem(mx, { ...existing, deleted: true });
+ }
+}
+
+/**
+ * List all active bookmarks in index order, with orphan recovery.
+ *
+ * MSC4438 Β§Listing bookmarks:
+ * - Iterates bookmark_ids in order.
+ * - Skips missing, malformed, or tombstoned items.
+ * - Deduplicates by first occurrence.
+ *
+ * Orphan recovery: also scans the in-memory account data store for bookmark
+ * item events that exist but are absent from the index. These arise when two
+ * devices concurrently write the index (last-write-wins drops the other
+ * device's new bookmark_id while the item event itself persists). Orphaned
+ * items are appended after the index-ordered items.
+ */
+export function listBookmarks(mx: MatrixClient): BookmarkItemContent[] {
+ const index = readIndex(mx);
+ const seen = new Set();
+
+ const items = index.bookmark_ids
+ .filter((id) => {
+ if (seen.has(id)) return false;
+ seen.add(id);
+ return true;
+ })
+ .map((id) => readItem(mx, id))
+ .filter((item): item is BookmarkItemContent => item != null);
+
+ // Walk the in-memory account data store for orphaned item events.
+ const prefix = AccountDataEvent.BookmarkItemPrefix as string;
+ Array.from(mx.store.accountData.keys()).forEach((key) => {
+ if (!key.startsWith(prefix)) return;
+ const bookmarkId = key.slice(prefix.length);
+ if (seen.has(bookmarkId)) return;
+ const item = readItem(mx, bookmarkId);
+ if (item) {
+ seen.add(bookmarkId);
+ items.push(item);
+ }
+ });
+
+ return items;
+}
+
+/**
+ * Check whether a specific bookmark ID is in the index.
+ *
+ * NOTE: Do not rely on the bookmark ID being deterministically derivable from
+ * (roomId, eventId) for this check β different clients may use different
+ * algorithms. Use the bookmarkIdSet atom (derived from the live list) for
+ * O(1) per-message checks instead.
+ */
+export function isBookmarked(mx: MatrixClient, bookmarkId: string): boolean {
+ const index = readIndex(mx);
+ return index.bookmark_ids.includes(bookmarkId);
+}
diff --git a/src/app/features/bookmarks/useBookmarks.ts b/src/app/features/bookmarks/useBookmarks.ts
new file mode 100644
index 000000000..aadd0bb8f
--- /dev/null
+++ b/src/app/features/bookmarks/useBookmarks.ts
@@ -0,0 +1,78 @@
+import { useAtomValue, useSetAtom } from 'jotai';
+import { useCallback } from 'react';
+import { useMatrixClient } from '$hooks/useMatrixClient';
+import { bookmarkIdSetAtom, bookmarkListAtom, bookmarkLoadingAtom } from '$state/bookmarks';
+import { BookmarkItemContent, computeBookmarkId } from './bookmarkDomain';
+import { addBookmark, removeBookmark, listBookmarks, isBookmarked } from './bookmarkRepository';
+
+/** Returns the current ordered bookmark list. */
+export function useBookmarkList(): BookmarkItemContent[] {
+ return useAtomValue(bookmarkListAtom);
+}
+
+/** Returns true while a bookmark refresh is in progress. */
+export function useBookmarkLoading(): boolean {
+ return useAtomValue(bookmarkLoadingAtom);
+}
+
+/**
+ * Returns true if the given (roomId, eventId) is currently bookmarked.
+ *
+ * Uses the locally cached bookmarkIdSetAtom for O(1) lookup.
+ * MSC4438 Β§Checking if a message is bookmarked.
+ */
+export function useIsBookmarked(roomId: string, eventId: string): boolean {
+ const idSet = useAtomValue(bookmarkIdSetAtom);
+ return idSet.has(computeBookmarkId(roomId, eventId));
+}
+
+/**
+ * Returns bookmark action callbacks: refresh, add, remove, checkIsBookmarked.
+ *
+ * `refresh` re-reads all bookmark items from the locally cached account data.
+ * `add` / `remove` optimistically update the local atom before writing to the server.
+ */
+export function useBookmarkActions() {
+ const mx = useMatrixClient();
+ const setList = useSetAtom(bookmarkListAtom);
+ const setLoading = useSetAtom(bookmarkLoadingAtom);
+
+ const refresh = useCallback(async () => {
+ setLoading(true);
+ try {
+ const items = listBookmarks(mx);
+ setList(items);
+ } finally {
+ setLoading(false);
+ }
+ }, [mx, setList, setLoading]);
+
+ const add = useCallback(
+ async (item: BookmarkItemContent) => {
+ // Optimistic update
+ setList((prev) => {
+ if (prev.some((b) => b.bookmark_id === item.bookmark_id)) return prev;
+ return [item, ...prev];
+ });
+ await addBookmark(mx, item);
+ },
+ [mx, setList]
+ );
+
+ const remove = useCallback(
+ async (bookmarkId: string) => {
+ // Optimistic update
+ setList((prev) => prev.filter((b) => b.bookmark_id !== bookmarkId));
+ await removeBookmark(mx, bookmarkId);
+ },
+ [mx, setList]
+ );
+
+ const checkIsBookmarked = useCallback(
+ (roomId: string, eventId: string): boolean =>
+ isBookmarked(mx, computeBookmarkId(roomId, eventId)),
+ [mx]
+ );
+
+ return { refresh, add, remove, checkIsBookmarked };
+}
diff --git a/src/app/features/bookmarks/useInitBookmarks.ts b/src/app/features/bookmarks/useInitBookmarks.ts
new file mode 100644
index 000000000..40175ce58
--- /dev/null
+++ b/src/app/features/bookmarks/useInitBookmarks.ts
@@ -0,0 +1,70 @@
+import { MatrixEvent, SyncState } from '$types/matrix-sdk';
+import { useCallback, useEffect } from 'react';
+import { useSetAtom } from 'jotai';
+import { useMatrixClient } from '$hooks/useMatrixClient';
+import { useSyncState } from '$hooks/useSyncState';
+import { useAccountDataCallback } from '$hooks/useAccountDataCallback';
+import { bookmarkListAtom, bookmarkLoadingAtom } from '$state/bookmarks';
+import { AccountDataEvent } from '$types/matrix/accountData';
+import { listBookmarks } from './bookmarkRepository';
+
+/**
+ * Top-level hook that keeps `bookmarkListAtom` in sync with account data.
+ *
+ * Must be called from an always-mounted component (e.g. ClientNonUIFeatures),
+ * NOT from a page component. Page components should simply read from the atom.
+ *
+ * Three triggers keep the atom current:
+ * 1. `useEffect` on mount β covers the case where `ClientNonUIFeatures` mounts
+ * after the initial sync transition has already fired (the common case).
+ * 2. `SyncState.Syncing` transition β refreshes on every reconnect.
+ * 3. `ClientEvent.AccountData` for the index event type β reacts immediately
+ * to index updates pushed by other devices mid-session.
+ */
+export function useInitBookmarks(): void {
+ const mx = useMatrixClient();
+ const setList = useSetAtom(bookmarkListAtom);
+ const setLoading = useSetAtom(bookmarkLoadingAtom);
+
+ const loadBookmarks = useCallback(() => {
+ setLoading(true);
+ try {
+ setList(listBookmarks(mx));
+ } finally {
+ setLoading(false);
+ }
+ }, [mx, setList, setLoading]);
+
+ // Immediate load: fires once on mount to cover the case where ClientNonUIFeatures
+ // mounts after the initial SyncState.Syncing transition has already fired.
+ // loadBookmarks is stable (memoized with stable deps), so this fires exactly once.
+ useEffect(() => {
+ loadBookmarks();
+ }, [loadBookmarks]);
+
+ // Trigger on reconnect (SyncState.Syncing transition after a disconnect).
+ useSyncState(
+ mx,
+ useCallback(
+ (state, prevState) => {
+ if (state === SyncState.Syncing && prevState !== SyncState.Syncing) {
+ loadBookmarks();
+ }
+ },
+ [loadBookmarks]
+ )
+ );
+
+ // React to index updates pushed by other devices mid-session.
+ useAccountDataCallback(
+ mx,
+ useCallback(
+ (event: MatrixEvent) => {
+ if (event.getType() === (AccountDataEvent.BookmarksIndex as string)) {
+ loadBookmarks();
+ }
+ },
+ [loadBookmarks]
+ )
+ );
+}
diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx
index 98e7e927a..6652e1844 100644
--- a/src/app/features/room/message/Message.tsx
+++ b/src/app/features/room/message/Message.tsx
@@ -80,6 +80,9 @@ import { MessageEditHistoryItem } from '$components/message/modals/MessageEditHi
import { MessageSourceCodeItem } from '$components/message/modals/MessageSource';
import { MessageForwardItem } from '$components/message/modals/MessageForward';
import { MessageDeleteItem } from '$components/message/modals/MessageDelete';
+import { computeBookmarkId, createBookmarkItem } from '$features/bookmarks/bookmarkDomain';
+import { useIsBookmarked, useBookmarkActions } from '$features/bookmarks/useBookmarks';
+import { useExperimentVariant } from '$hooks/useClientConfig';
import { MessageReportItem } from '$components/message/modals/MessageReport';
import { filterPronounsByLanguage, getParsedPronouns } from '$utils/pronouns';
import { useMentionClickHandler } from '$hooks/useMentionClickHandler';
@@ -209,6 +212,50 @@ export const MessagePinItem = as<
);
});
+// message bookmarking
+export const MessageBookmarkItem = as<
+ 'button',
+ {
+ room: Room;
+ mEvent: MatrixEvent;
+ onClose?: () => void;
+ }
+>(({ room, mEvent, onClose, ...props }, ref) => {
+ const mx = useMatrixClient();
+ const bookmarksExperiment = useExperimentVariant('messageBookmarks', mx.getUserId() ?? undefined);
+ const [enableMessageBookmarks] = useSetting(settingsAtom, 'enableMessageBookmarks');
+ const eventId = mEvent.getId() ?? '';
+ const isBookmarked = useIsBookmarked(room.roomId, eventId);
+ const { add, remove } = useBookmarkActions();
+
+ if (!bookmarksExperiment.inExperiment && !enableMessageBookmarks) return null;
+
+ const handleClick = async () => {
+ if (isBookmarked) {
+ await remove(computeBookmarkId(room.roomId, eventId));
+ } else {
+ const item = createBookmarkItem(room, mEvent);
+ if (item) await add(item);
+ }
+ onClose?.();
+ };
+
+ return (
+ }
+ radii="300"
+ onClick={handleClick}
+ {...props}
+ ref={ref}
+ >
+
+ {isBookmarked ? 'Remove Bookmark' : 'Bookmark Message'}
+
+
+ );
+});
+
export type ForwardedMessageProps = {
originalTimestamp: number;
isForwarded: boolean;
@@ -1097,6 +1144,7 @@ function MessageInternal(
)}
+
{canPinEvent && (
)}
@@ -1430,6 +1478,13 @@ export const Event = as<'div', EventProps>(
)}
+ {!stateEvent && (
+
+ )}
{((!mEvent.isRedacted() && canDelete && !stateEvent) ||
(mEvent.getSender() !== mx.getUserId() && !stateEvent)) && (
diff --git a/src/app/features/settings/experimental/Experimental.tsx b/src/app/features/settings/experimental/Experimental.tsx
index 330412185..e048ed281 100644
--- a/src/app/features/settings/experimental/Experimental.tsx
+++ b/src/app/features/settings/experimental/Experimental.tsx
@@ -10,6 +10,7 @@ import { Sync } from '../general';
import { SettingsSectionPage } from '../SettingsSectionPage';
import { BandwidthSavingEmojis } from './BandwithSavingEmojis';
import { MSC4268HistoryShare } from './MSC4268HistoryShare';
+import { MSC4438MessageBookmarks } from './MSC4438MessageBookmarks';
function PersonaToggle() {
const [showPersonaSetting, setShowPersonaSetting] = useSetting(
@@ -59,6 +60,7 @@ export function Experimental({ requestBack, requestClose }: Readonly
+
diff --git a/src/app/features/settings/experimental/MSC4438MessageBookmarks.tsx b/src/app/features/settings/experimental/MSC4438MessageBookmarks.tsx
new file mode 100644
index 000000000..45e429b18
--- /dev/null
+++ b/src/app/features/settings/experimental/MSC4438MessageBookmarks.tsx
@@ -0,0 +1,56 @@
+import { SequenceCard } from '$components/sequence-card';
+import { SettingTile } from '$components/setting-tile';
+import { useSetting } from '$state/hooks/settings';
+import { settingsAtom } from '$state/settings';
+import { Box, Switch, Text } from 'folds';
+import { SequenceCardStyle } from '../styles.css';
+
+export function MSC4438MessageBookmarks() {
+ const [enableMessageBookmarks, setEnableMessageBookmarks] = useSetting(
+ settingsAtom,
+ 'enableMessageBookmarks'
+ );
+
+ return (
+
+ Message Bookmarks
+
+
+ Save individual messages for later. Bookmarks are synced across all your devices via
+ account data.{' '}
+
+ MSC4438
+
+ .{' '}
+
+ Known issues (Sable GitHub)
+
+ .
+ >
+ }
+ after={
+
+ }
+ />
+
+
+ );
+}
diff --git a/src/app/hooks/router/useHomeSelected.ts b/src/app/hooks/router/useHomeSelected.ts
index 2a16511aa..fcc439196 100644
--- a/src/app/hooks/router/useHomeSelected.ts
+++ b/src/app/hooks/router/useHomeSelected.ts
@@ -4,6 +4,7 @@ import {
getHomeJoinPath,
getHomePath,
getHomeSearchPath,
+ getHomeBookmarksPath,
} from '$pages/pathUtils';
export const useHomeSelected = (): boolean => {
@@ -45,3 +46,13 @@ export const useHomeSearchSelected = (): boolean => {
return !!match;
};
+
+export const useHomeBookmarksSelected = (): boolean => {
+ const match = useMatch({
+ path: getHomeBookmarksPath(),
+ caseSensitive: true,
+ end: false,
+ });
+
+ return !!match;
+};
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/useClientConfig.ts b/src/app/hooks/useClientConfig.ts
index e523f15a7..d3e85df09 100644
--- a/src/app/hooks/useClientConfig.ts
+++ b/src/app/hooks/useClientConfig.ts
@@ -5,6 +5,21 @@ export type HashRouterConfig = {
basename?: string;
};
+export type ExperimentConfig = {
+ enabled?: boolean;
+ rolloutPercentage?: number;
+ variants?: string[];
+ controlVariant?: string;
+};
+
+export type ExperimentSelection = {
+ key: string;
+ enabled: boolean;
+ rolloutPercentage: number;
+ variant: string;
+ inExperiment: boolean;
+};
+
export type ClientConfig = {
defaultHomeserver?: number;
homeserverList?: string[];
@@ -14,6 +29,8 @@ export type ClientConfig = {
disableAccountSwitcher?: boolean;
hideUsernamePasswordFields?: boolean;
+ experiments?: Record;
+
pushNotificationDetails?: {
pushNotifyUrl?: string;
vapidPublicKey?: string;
@@ -55,6 +72,56 @@ export function useClientConfig(): ClientConfig {
return config;
}
+const DEFAULT_CONTROL_VARIANT = 'control';
+
+const normalizeRolloutPercentage = (value?: number): number => {
+ if (typeof value !== 'number' || Number.isNaN(value)) return 100;
+ if (value < 0) return 0;
+ if (value > 100) return 100;
+ return value;
+};
+
+const hashToUInt32 = (input: string): number => {
+ let hash = 0;
+ for (let index = 0; index < input.length; index += 1) {
+ hash = (hash * 131 + input.charCodeAt(index)) % 4294967291;
+ }
+ return hash;
+};
+
+export const selectExperimentVariant = (
+ key: string,
+ experiment: ExperimentConfig | undefined,
+ subjectId: string | undefined
+): ExperimentSelection => {
+ const controlVariant = experiment?.controlVariant ?? DEFAULT_CONTROL_VARIANT;
+ const variants = (experiment?.variants?.filter((v) => v.length > 0) ?? []).filter(
+ (v) => v !== controlVariant
+ );
+
+ const enabled = Boolean(experiment?.enabled);
+ const rolloutPercentage = normalizeRolloutPercentage(experiment?.rolloutPercentage);
+
+ if (!enabled || !subjectId || variants.length === 0 || rolloutPercentage === 0) {
+ return { key, enabled, rolloutPercentage, variant: controlVariant, inExperiment: false };
+ }
+
+ // Two independent hashes keep rollout and variant assignment stable but decorrelated.
+ const rolloutBucket = hashToUInt32(`${key}:rollout:${subjectId}`) % 10000;
+ const rolloutCutoff = Math.floor(rolloutPercentage * 100);
+ if (rolloutBucket >= rolloutCutoff) {
+ return { key, enabled, rolloutPercentage, variant: controlVariant, inExperiment: false };
+ }
+
+ const variantIndex = hashToUInt32(`${key}:variant:${subjectId}`) % variants.length;
+ return { key, enabled, rolloutPercentage, variant: variants[variantIndex], inExperiment: true };
+};
+
+export const useExperimentVariant = (key: string, subjectId?: string): ExperimentSelection => {
+ const clientConfig = useClientConfig();
+ return selectExperimentVariant(key, clientConfig.experiments?.[key], subjectId);
+};
+
export const clientDefaultServer = (clientConfig: ClientConfig): string =>
clientConfig.homeserverList?.[clientConfig.defaultHomeserver ?? 0] ?? 'matrix.org';
diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx
index 28f8c7efb..fd8bc462e 100644
--- a/src/app/pages/Router.tsx
+++ b/src/app/pages/Router.tsx
@@ -48,6 +48,7 @@ import {
NOTIFICATIONS_PATH_SEGMENT,
ROOM_PATH_SEGMENT,
SEARCH_PATH_SEGMENT,
+ BOOKMARKS_PATH_SEGMENT,
SERVER_PATH_SEGMENT,
CREATE_PATH,
TO_ROOM_EVENT_PATH,
@@ -65,6 +66,7 @@ import {
import { ClientBindAtoms, ClientLayout, ClientRoot, ClientRouteOutlet } from './client';
import { HandleNotificationClick, ClientNonUIFeatures } from './client/ClientNonUIFeatures';
import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home';
+import { BookmarksList } from './client/bookmarks';
import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct';
import { RouteSpaceProvider, Space, SpaceRouteRoomProvider, SpaceSearch } from './client/space';
import { Explore, FeaturedRooms, PublicRooms } from './client/explore';
@@ -242,6 +244,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
} />
join
} />
} />
+ } />
} />
} />
+ } />
} />
diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx
index 26ac2f431..caebe459a 100644
--- a/src/app/pages/client/ClientNonUIFeatures.tsx
+++ b/src/app/pages/client/ClientNonUIFeatures.tsx
@@ -56,6 +56,7 @@ import { useCallSignaling } from '$hooks/useCallSignaling';
import { getBlobCacheStats } from '$hooks/useBlobCache';
import { lastVisitedRoomIdAtom } from '$state/room/lastRoom';
import { useSettingsSyncEffect } from '$hooks/useSettingsSync';
+import { useInitBookmarks } from '$features/bookmarks/useInitBookmarks';
import { getInboxInvitesPath } from '../pathUtils';
import { BackgroundNotifications } from './BackgroundNotifications';
@@ -845,11 +846,17 @@ function SettingsSyncFeature() {
return null;
}
+function BookmarksFeature() {
+ useInitBookmarks();
+ return null;
+}
+
export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
useCallSignaling();
return (
<>
+
diff --git a/src/app/pages/client/bookmarks/BookmarksList.tsx b/src/app/pages/client/bookmarks/BookmarksList.tsx
new file mode 100644
index 000000000..6dae4fb5c
--- /dev/null
+++ b/src/app/pages/client/bookmarks/BookmarksList.tsx
@@ -0,0 +1,560 @@
+import { FormEventHandler, useCallback, useMemo, useRef, useState } from 'react';
+import {
+ Avatar,
+ Box,
+ Button,
+ Dialog,
+ Header,
+ Icon,
+ IconButton,
+ Icons,
+ Input,
+ Line,
+ Overlay,
+ OverlayBackdrop,
+ OverlayCenter,
+ Scroll,
+ Spinner,
+ Text,
+ Chip,
+ config,
+ color,
+} from 'folds';
+import FocusTrap from 'focus-trap-react';
+import { JoinRule } from '$types/matrix-sdk';
+import {
+ Page,
+ PageContent,
+ PageContentCenter,
+ PageHeader,
+ PageHero,
+ PageHeroSection,
+} from '$components/page';
+import { SequenceCard } from '$components/sequence-card';
+import { AvatarBase, ModernLayout, Time, Username, UsernameBold } from '$components/message';
+import { RoomAvatar, RoomIcon } from '$components/room-avatar';
+import { UserAvatar } from '$components/user-avatar';
+import { BackRouteHandler } from '$components/BackRouteHandler';
+import { useMatrixClient } from '$hooks/useMatrixClient';
+import { useMediaAuthentication } from '$hooks/useMediaAuthentication';
+import { useRoomNavigate } from '$hooks/useRoomNavigate';
+import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize';
+import { useSetting } from '$state/hooks/settings';
+import { settingsAtom } from '$state/settings';
+import { getMemberAvatarMxc, getMemberDisplayName, getRoomAvatarUrl } from '$utils/room';
+import { getMxIdLocalPart, mxcUrlToHttp } from '$utils/matrix';
+import colorMXID from '$utils/colorMXID';
+import { stopPropagation } from '$utils/keyboard';
+import { BookmarkItemContent } from '$features/bookmarks/bookmarkDomain';
+import {
+ useBookmarkActions,
+ useBookmarkList,
+ useBookmarkLoading,
+} from '$features/bookmarks/useBookmarks';
+
+// ---------------------------------------------------------------------------
+// RemoveBookmarkDialog
+// ---------------------------------------------------------------------------
+
+type RemoveBookmarkDialogProps = {
+ item: BookmarkItemContent;
+ onConfirm: () => void;
+ onClose: () => void;
+};
+
+function RemoveBookmarkDialog({ item, onConfirm, onClose }: RemoveBookmarkDialogProps) {
+ return (
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// BookmarkItemRow
+// ---------------------------------------------------------------------------
+
+type BookmarkItemRowProps = {
+ item: BookmarkItemContent;
+ highlight?: string;
+ onJump: (roomId: string, eventId: string) => void;
+ onRemove: (item: BookmarkItemContent) => void;
+ hour24Clock: boolean;
+ dateFormatString: string;
+};
+
+function BookmarkItemRow({
+ item,
+ highlight,
+ onJump,
+ onRemove,
+ hour24Clock,
+ dateFormatString,
+}: BookmarkItemRowProps) {
+ const mx = useMatrixClient();
+ const useAuthentication = useMediaAuthentication();
+
+ // Try to resolve live room/member data; fall back to stored metadata
+ const room = mx.getRoom(item.room_id) ?? undefined;
+ const senderId = item.sender ?? '';
+
+ const displayName = room
+ ? (getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId)
+ : (getMxIdLocalPart(senderId) ?? senderId);
+
+ const senderAvatarMxc = room ? getMemberAvatarMxc(room, senderId) : undefined;
+ const avatarUrl = senderAvatarMxc
+ ? (mxcUrlToHttp(mx, senderAvatarMxc, useAuthentication, 48, 48, 'crop') ?? undefined)
+ : undefined;
+
+ const usernameColor = colorMXID(senderId);
+
+ // Highlight matching substring in body_preview
+ const preview = item.body_preview ?? '';
+ const highlightedPreview = useMemo(() => {
+ if (!highlight || !preview) return <>{preview}>;
+ const idx = preview.toLowerCase().indexOf(highlight.toLowerCase());
+ if (idx === -1) return <>{preview}>;
+ return (
+ <>
+ {preview.slice(0, idx)}
+
+ {preview.slice(idx, idx + highlight.length)}
+
+ {preview.slice(idx + highlight.length)}
+ >
+ );
+ }, [preview, highlight]);
+
+ return (
+
+
+
+ }
+ />
+
+
+ }
+ >
+
+
+
+
+ {displayName}
+
+
+
+
+
+ onJump(item.room_id, item.event_id)}
+ variant="Secondary"
+ radii="400"
+ >
+ Jump
+
+ onRemove(item)}
+ aria-label="Remove bookmark"
+ >
+
+
+
+
+ {preview && (
+
+ {highlightedPreview}
+
+ )}
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// BookmarkResultGroup
+// ---------------------------------------------------------------------------
+
+type BookmarkResultGroupProps = {
+ roomId: string;
+ roomName: string;
+ items: BookmarkItemContent[];
+ highlight?: string;
+ onJump: (roomId: string, eventId: string) => void;
+ onRemove: (item: BookmarkItemContent) => void;
+ hour24Clock: boolean;
+ dateFormatString: string;
+};
+
+function BookmarkResultGroup({
+ roomId,
+ roomName,
+ items,
+ highlight,
+ onJump,
+ onRemove,
+ hour24Clock,
+ dateFormatString,
+}: BookmarkResultGroupProps) {
+ const mx = useMatrixClient();
+ const useAuthentication = useMediaAuthentication();
+ const room = mx.getRoom(roomId) ?? undefined;
+ const avatarUrl = room ? getRoomAvatarUrl(mx, room, 96, useAuthentication) : undefined;
+ const displayRoomName = room?.name ?? roomName;
+
+ return (
+
+
+
+
+ (
+
+ )}
+ />
+
+
+ {displayRoomName}
+
+
+
+
+ {items.map((item) => (
+
+ ))}
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// BookmarkFilterInput
+// ---------------------------------------------------------------------------
+
+type BookmarkFilterInputProps = {
+ inputRef: React.RefObject;
+ active?: boolean;
+ loading?: boolean;
+ onFilter: (term: string) => void;
+ onReset: () => void;
+};
+
+function BookmarkFilterInput({
+ inputRef,
+ active,
+ loading,
+ onFilter,
+ onReset,
+}: BookmarkFilterInputProps) {
+ const handleSubmit: FormEventHandler = (evt) => {
+ evt.preventDefault();
+ const { filterInput } = evt.target as HTMLFormElement & {
+ filterInput: HTMLInputElement;
+ };
+ const term = filterInput.value.trim();
+ if (term) onFilter(term);
+ };
+
+ return (
+
+
+ Filter
+
+ ) : (
+
+ {active && (
+
+
+ Clear
+
+ )}
+
+ Filter
+
+
+ )
+ }
+ />
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// BookmarksList (main export)
+// ---------------------------------------------------------------------------
+
+export function BookmarksList() {
+ const mx = useMatrixClient();
+ const screenSize = useScreenSizeContext();
+ const scrollRef = useRef(null);
+ const filterInputRef = useRef(null);
+ const { navigateRoom } = useRoomNavigate();
+
+ const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
+ const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
+
+ const bookmarks = useBookmarkList();
+ const loading = useBookmarkLoading();
+ const { remove } = useBookmarkActions();
+
+ const [filterTerm, setFilterTerm] = useState();
+ const [removingItem, setRemovingItem] = useState();
+
+ // Filter and group bookmarks
+ const filteredBookmarks = useMemo(() => {
+ if (!filterTerm) return bookmarks;
+ const lower = filterTerm.toLowerCase();
+ return bookmarks.filter(
+ (b) =>
+ b.body_preview?.toLowerCase().includes(lower) ||
+ b.room_name?.toLowerCase().includes(lower) ||
+ (b.sender && getMxIdLocalPart(b.sender)?.toLowerCase().includes(lower))
+ );
+ }, [bookmarks, filterTerm]);
+
+ // Group by room_id, preserving order
+ const groupedByRoom = useMemo(() => {
+ const map = new Map<
+ string,
+ { roomId: string; roomName: string; items: BookmarkItemContent[] }
+ >();
+ filteredBookmarks.forEach((item) => {
+ let group = map.get(item.room_id);
+ if (!group) {
+ const room = mx.getRoom(item.room_id);
+ group = {
+ roomId: item.room_id,
+ roomName: room?.name ?? item.room_name ?? item.room_id,
+ items: [],
+ };
+ map.set(item.room_id, group);
+ }
+ group.items.push(item);
+ });
+ return Array.from(map.values());
+ }, [filteredBookmarks, mx]);
+
+ const handleJump = useCallback(
+ (roomId: string, eventId: string) => {
+ navigateRoom(roomId, eventId);
+ },
+ [navigateRoom]
+ );
+
+ const handleRemoveConfirm = useCallback(async () => {
+ if (!removingItem) return;
+ await remove(removingItem.bookmark_id);
+ setRemovingItem(undefined);
+ }, [removingItem, remove]);
+
+ const handleFilter = useCallback((term: string) => {
+ setFilterTerm(term);
+ }, []);
+
+ const handleReset = useCallback(() => {
+ setFilterTerm(undefined);
+ if (filterInputRef.current) {
+ filterInputRef.current.value = '';
+ }
+ }, []);
+
+ return (
+
+
+
+
+ {screenSize === ScreenSize.Mobile && (
+
+ {(onBack) => (
+
+
+
+ )}
+
+ )}
+
+
+ {screenSize !== ScreenSize.Mobile && }
+
+ Bookmarks
+
+
+
+
+
+
+
+
+
+
+
+
+ {loading && bookmarks.length === 0 && (
+
+
+
+ )}
+
+ {!loading && bookmarks.length === 0 && (
+
+ }
+ title="No Bookmarks Yet"
+ subTitle="Bookmark messages to find them again easily. Right-click a message and choose Bookmark."
+ />
+
+ )}
+
+ {!loading && bookmarks.length > 0 && filteredBookmarks.length === 0 && (
+
+
+
+ No bookmarks match your filter.
+
+
+ )}
+
+ {groupedByRoom.length > 0 && (
+
+ {groupedByRoom.map((group, i) => (
+ <>
+ {i > 0 && }
+
+ >
+ ))}
+
+ )}
+
+
+
+
+
+ {removingItem && (
+ }>
+
+ setRemovingItem(undefined),
+ clickOutsideDeactivates: true,
+ escapeDeactivates: stopPropagation,
+ }}
+ >
+ setRemovingItem(undefined)}
+ />
+
+
+
+ )}
+
+ );
+}
diff --git a/src/app/pages/client/bookmarks/index.ts b/src/app/pages/client/bookmarks/index.ts
new file mode 100644
index 000000000..cdd211f71
--- /dev/null
+++ b/src/app/pages/client/bookmarks/index.ts
@@ -0,0 +1 @@
+export * from './BookmarksList';
diff --git a/src/app/pages/client/home/Home.tsx b/src/app/pages/client/home/Home.tsx
index c25d99e30..175b48f9d 100644
--- a/src/app/pages/client/home/Home.tsx
+++ b/src/app/pages/client/home/Home.tsx
@@ -35,11 +35,16 @@ import {
getHomeCreatePath,
getHomeRoomPath,
getHomeSearchPath,
+ getHomeBookmarksPath,
withSearchParam,
} from '$pages/pathUtils';
import { getCanonicalAliasOrRoomId } from '$utils/matrix';
import { useSelectedRoom } from '$hooks/router/useSelectedRoom';
-import { useHomeCreateSelected, useHomeSearchSelected } from '$hooks/router/useHomeSelected';
+import {
+ useHomeCreateSelected,
+ useHomeSearchSelected,
+ useHomeBookmarksSelected,
+} from '$hooks/router/useHomeSelected';
import { useMatrixClient } from '$hooks/useMatrixClient';
import { VirtualTile } from '$components/virtualizer';
import { RoomNavCategoryButton, RoomNavItem } from '$features/room-nav';
@@ -54,6 +59,7 @@ import { useClosedNavCategoriesAtom } from '$state/hooks/closedNavCategories';
import { stopPropagation } from '$utils/keyboard';
import { useSetting } from '$state/hooks/settings';
import { settingsAtom } from '$state/settings';
+import { useExperimentVariant } from '$hooks/useClientConfig';
import {
getRoomNotificationMode,
useRoomsNotificationPreferencesContext,
@@ -203,6 +209,10 @@ export function Home() {
const selectedRoomId = useSelectedRoom();
const createRoomSelected = useHomeCreateSelected();
const searchSelected = useHomeSearchSelected();
+ const bookmarksSelected = useHomeBookmarksSelected();
+ const bookmarksExperiment = useExperimentVariant('messageBookmarks', mx.getUserId() ?? undefined);
+ const [enableMessageBookmarks] = useSetting(settingsAtom, 'enableMessageBookmarks');
+ const showBookmarks = bookmarksExperiment.inExperiment || enableMessageBookmarks;
const noRoomToDisplay = rooms.length === 0;
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
@@ -236,83 +246,101 @@ export function Home() {
return (
- {noRoomToDisplay ? (
-
- ) : (
-
-
-
-
- navigate(getHomeCreatePath())}>
-
-
-
-
-
-
-
- Create Room
-
-
+
+
+
+
+ navigate(getHomeCreatePath())}>
+
+
+
+
+
+
+
+ Create Room
+
-
-
-
-
- {(open, setOpen) => (
- <>
-
- setOpen(true)}>
-
-
-
-
-
-
-
- Join with Address
-
-
+
+
+
+
+
+ {(open, setOpen) => (
+ <>
+
+ setOpen(true)}>
+
+
+
+
+
+
+
+ Join with Address
+
-
-
-
- {open && (
- setOpen(false)}
- onOpen={(roomIdOrAlias, viaServers, eventId) => {
- setOpen(false);
- const path = getHomeRoomPath(roomIdOrAlias, eventId);
- navigate(
- viaServers
- ? withSearchParam(path, {
- viaServers: encodeSearchParamValueArray(viaServers),
- })
- : path
- );
- }}
- />
- )}
- >
- )}
-
-
-
+
+
+
+
+ {open && (
+ setOpen(false)}
+ onOpen={(roomIdOrAlias, viaServers, eventId) => {
+ setOpen(false);
+ const path = getHomeRoomPath(roomIdOrAlias, eventId);
+ navigate(
+ viaServers
+ ? withSearchParam(path, {
+ viaServers: encodeSearchParamValueArray(viaServers),
+ })
+ : path
+ );
+ }}
+ />
+ )}
+ >
+ )}
+
+
+
+
+
+
+
+
+
+
+ Message Search
+
+
+
+
+
+
+ {showBookmarks && (
+
+
-
+
- Message Search
+ Bookmarks
-
+ )}
+
+ {noRoomToDisplay ? (
+
+ ) : (
-
-
- )}
+ )}
+
+
);
}
diff --git a/src/app/pages/client/inbox/Inbox.tsx b/src/app/pages/client/inbox/Inbox.tsx
index 661435513..d594a928e 100644
--- a/src/app/pages/client/inbox/Inbox.tsx
+++ b/src/app/pages/client/inbox/Inbox.tsx
@@ -1,12 +1,24 @@
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';
import { PageNav, PageNavContent, PageNavHeader } from '$components/page';
+import { useMatrixClient } from '$hooks/useMatrixClient';
+import { useSetting } from '$state/hooks/settings';
+import { settingsAtom } from '$state/settings';
+import { useExperimentVariant } from '$hooks/useClientConfig';
function InvitesNavItem() {
const invitesSelected = useInboxInvitesSelected();
@@ -39,9 +51,36 @@ function InvitesNavItem() {
);
}
+function BookmarksNavItem() {
+ const bookmarksSelected = useInboxBookmarksSelected();
+
+ return (
+
+
+
+
+
+
+
+
+
+ Bookmarks
+
+
+
+
+
+
+ );
+}
+
export function Inbox() {
useNavToActivePathMapper('inbox');
+ const mx = useMatrixClient();
const notificationsSelected = useInboxNotificationsSelected();
+ const bookmarksExperiment = useExperimentVariant('messageBookmarks', mx.getUserId() ?? undefined);
+ const [enableMessageBookmarks] = useSetting(settingsAtom, 'enableMessageBookmarks');
+ const showBookmarks = bookmarksExperiment.inExperiment || enableMessageBookmarks;
return (
@@ -75,6 +114,7 @@ export function Inbox() {
+ {showBookmarks && }
diff --git a/src/app/pages/pathUtils.ts b/src/app/pages/pathUtils.ts
index 120a1a70c..7e5cb8d84 100644
--- a/src/app/pages/pathUtils.ts
+++ b/src/app/pages/pathUtils.ts
@@ -13,7 +13,9 @@ import {
HOME_PATH,
HOME_ROOM_PATH,
HOME_SEARCH_PATH,
+ HOME_BOOKMARKS_PATH,
LOGIN_PATH,
+ INBOX_BOOKMARKS_PATH,
INBOX_INVITES_PATH,
INBOX_NOTIFICATIONS_PATH,
INBOX_PATH,
@@ -93,6 +95,7 @@ export const getHomePath = (): string => HOME_PATH;
export const getHomeCreatePath = (): string => HOME_CREATE_PATH;
export const getHomeJoinPath = (): string => HOME_JOIN_PATH;
export const getHomeSearchPath = (): string => HOME_SEARCH_PATH;
+export const getHomeBookmarksPath = (): string => HOME_BOOKMARKS_PATH;
export const getHomeRoomPath = (roomIdOrAlias: string, eventId?: string): string => {
const params = {
roomIdOrAlias: encodeURIComponent(roomIdOrAlias),
@@ -160,6 +163,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..2e686d109 100644
--- a/src/app/pages/paths.ts
+++ b/src/app/pages/paths.ts
@@ -39,6 +39,7 @@ export type SearchPathSearchParams = {
senders?: string;
};
export const SEARCH_PATH_SEGMENT = 'search/';
+export const BOOKMARKS_PATH_SEGMENT = 'bookmarks/';
export type RoomSearchParams = {
/* comma separated string of servers */
@@ -50,6 +51,7 @@ export const HOME_PATH = '/home/';
export const HOME_CREATE_PATH = `/home/${CREATE_PATH_SEGMENT}`;
export const HOME_JOIN_PATH = `/home/${JOIN_PATH_SEGMENT}`;
export const HOME_SEARCH_PATH = `/home/${SEARCH_PATH_SEGMENT}`;
+export const HOME_BOOKMARKS_PATH = `/home/${BOOKMARKS_PATH_SEGMENT}`;
export const HOME_ROOM_PATH = `/home/${ROOM_PATH_SEGMENT}`;
export const DIRECT_PATH = '/direct/';
@@ -88,6 +90,7 @@ export type InboxNotificationsPathSearchParams = {
};
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/app/state/bookmarks.ts b/src/app/state/bookmarks.ts
new file mode 100644
index 000000000..7a2375691
--- /dev/null
+++ b/src/app/state/bookmarks.ts
@@ -0,0 +1,20 @@
+import { atom } from 'jotai';
+import { BookmarkItemContent } from '../features/bookmarks/bookmarkDomain';
+
+/** Ordered list of active bookmark items (mirrors the server index order). */
+export const bookmarkListAtom = atom([]);
+
+/** True while a refresh from account data is in progress. */
+export const bookmarkLoadingAtom = atom(false);
+
+/**
+ * Derived set of active bookmark IDs β used for O(1) per-message
+ * "is this message bookmarked?" checks.
+ *
+ * MSC4438 Β§Checking if a message is bookmarked: use a local reverse lookup
+ * rather than issuing server requests.
+ */
+export const bookmarkIdSetAtom = atom>((get) => {
+ const list = get(bookmarkListAtom);
+ return new Set(list.map((b) => b.bookmark_id));
+});
diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts
index 3dcf1b1fb..68318ae5c 100644
--- a/src/app/state/settings.ts
+++ b/src/app/state/settings.ts
@@ -117,6 +117,9 @@ export interface Settings {
showPersonaSetting: boolean;
closeFoldersByDefault: boolean;
+ // experimental
+ enableMessageBookmarks: boolean;
+
// furry stuff
renderAnimals: boolean;
}
@@ -216,6 +219,9 @@ const defaultSettings: Settings = {
showPersonaSetting: false,
closeFoldersByDefault: false,
+ // experimental
+ enableMessageBookmarks: false,
+
// furry stuff
renderAnimals: true,
};
diff --git a/src/types/matrix/accountData.ts b/src/types/matrix/accountData.ts
index 25b13cbc4..f89958d9f 100644
--- a/src/types/matrix/accountData.ts
+++ b/src/types/matrix/accountData.ts
@@ -18,6 +18,11 @@ export enum AccountDataEvent {
CrossSigningUser = 'm.cross_signing.user',
MegolmBackupV1 = 'm.megolm_backup.v1',
+ // MSC4438 Message Bookmarks (unstable prefix)
+ BookmarksIndex = 'org.matrix.msc4438.bookmarks.index',
+ /** Prefix for per-bookmark item events; append the bookmark ID to get the full event type. */
+ BookmarkItemPrefix = 'org.matrix.msc4438.bookmark.',
+
// Sable account data
SableNicknames = 'moe.sable.app.nicknames',
SablePinStatus = 'moe.sable.app.pins_read_marker',
From f7268334108cacaa2b9ec47493f7fde922aecf32 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Mon, 6 Apr 2026 12:46:47 -0400
Subject: [PATCH 046/253] test(bookmarks): add unit tests for MSC4438 bookmark
domain and repository
---
.../features/bookmarks/bookmarkDomain.test.ts | 375 ++++++++++++++++++
.../bookmarks/bookmarkRepository.test.ts | 334 ++++++++++++++++
src/app/state/bookmarks.test.ts | 69 ++++
3 files changed, 778 insertions(+)
create mode 100644 src/app/features/bookmarks/bookmarkDomain.test.ts
create mode 100644 src/app/features/bookmarks/bookmarkRepository.test.ts
create mode 100644 src/app/state/bookmarks.test.ts
diff --git a/src/app/features/bookmarks/bookmarkDomain.test.ts b/src/app/features/bookmarks/bookmarkDomain.test.ts
new file mode 100644
index 000000000..2f70879da
--- /dev/null
+++ b/src/app/features/bookmarks/bookmarkDomain.test.ts
@@ -0,0 +1,375 @@
+/**
+ * Unit tests for MSC4438 bookmark domain logic.
+ * All functions in bookmarkDomain.ts are pure / side-effect-free.
+ */
+import { describe, it, expect } from 'vitest';
+import type { MatrixEvent, Room } from '$types/matrix-sdk';
+import { AccountDataEvent } from '$types/matrix/accountData';
+import {
+ bookmarkItemEventType,
+ buildMatrixURI,
+ computeBookmarkId,
+ createBookmarkItem,
+ emptyIndex,
+ extractBodyPreview,
+ isValidBookmarkItem,
+ isValidIndexContent,
+} from './bookmarkDomain';
+
+// ---------------------------------------------------------------------------
+// Helpers: minimal Matrix object stubs
+// ---------------------------------------------------------------------------
+
+function makeEvent(
+ opts: {
+ id?: string | null;
+ body?: unknown;
+ msgtype?: string;
+ sender?: string;
+ ts?: number;
+ } = {}
+): MatrixEvent {
+ return {
+ getId: () => (opts.id === null ? undefined : (opts.id ?? '$event:server.tld')),
+ getTs: () => opts.ts ?? 1_000_000,
+ getSender: () => opts.sender ?? '@alice:server.tld',
+ getContent: () => ({
+ body: opts.body,
+ msgtype: opts.msgtype ?? 'm.text',
+ }),
+ } as unknown as MatrixEvent;
+}
+
+function makeRoom(opts: { roomId?: string; name?: string } = {}): Room {
+ return {
+ roomId: opts.roomId ?? '!room:server.tld',
+ name: opts.name ?? 'Test Room',
+ } as unknown as Room;
+}
+
+// ---------------------------------------------------------------------------
+// computeBookmarkId
+// ---------------------------------------------------------------------------
+
+describe('computeBookmarkId', () => {
+ it('returns a string prefixed with "bmk_"', () => {
+ expect(computeBookmarkId('!room:s', '$event:s')).toMatch(/^bmk_/);
+ });
+
+ it('is exactly 12 characters long ("bmk_" + 8 hex digits)', () => {
+ expect(computeBookmarkId('!room:s', '$event:s')).toHaveLength(12);
+ });
+
+ it('only contains hex digits after the prefix', () => {
+ const id = computeBookmarkId('!room:server.tld', '$event:server.tld');
+ expect(id.slice(4)).toMatch(/^[0-9a-f]{8}$/);
+ });
+
+ it('is deterministic β same inputs always yield the same ID', () => {
+ const a = computeBookmarkId('!room:server.tld', '$event:server.tld');
+ const b = computeBookmarkId('!room:server.tld', '$event:server.tld');
+ expect(a).toBe(b);
+ });
+
+ it('differs when roomId changes', () => {
+ const a = computeBookmarkId('!roomA:s', '$event:s');
+ const b = computeBookmarkId('!roomB:s', '$event:s');
+ expect(a).not.toBe(b);
+ });
+
+ it('differs when eventId changes', () => {
+ const a = computeBookmarkId('!room:s', '$eventA:s');
+ const b = computeBookmarkId('!room:s', '$eventB:s');
+ expect(a).not.toBe(b);
+ });
+
+ it('separator prevents (roomId + eventId) collisions', () => {
+ // Without "|" separator, ("ab", "c") and ("a", "bc") would hash the same
+ const a = computeBookmarkId('ab', 'c');
+ const b = computeBookmarkId('a', 'bc');
+ expect(a).not.toBe(b);
+ });
+
+ // Known vector β computed from the reference djb2-like algorithm:
+ // input = "a|b", each char's code units: 97, 124, 98
+ // hash trace: 0 β 97 β 3131 β 97159 (0x17b87)
+ it('produces the known reference vector for ("a", "b")', () => {
+ expect(computeBookmarkId('a', 'b')).toBe('bmk_00017b87');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// bookmarkItemEventType
+// ---------------------------------------------------------------------------
+
+describe('bookmarkItemEventType', () => {
+ it('returns the MSC4438 unstable event type for a given bookmark ID', () => {
+ expect(bookmarkItemEventType('bmk_abcd1234')).toBe(
+ `${AccountDataEvent.BookmarkItemPrefix}bmk_abcd1234`
+ );
+ });
+
+ it('uses BookmarkItemPrefix as the base', () => {
+ const id = 'bmk_00000001';
+ expect(bookmarkItemEventType(id)).toContain(AccountDataEvent.BookmarkItemPrefix);
+ });
+
+ it('has BookmarksIndex enum value defined correctly', () => {
+ expect(AccountDataEvent.BookmarksIndex).toBe('org.matrix.msc4438.bookmarks.index');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// buildMatrixURI
+// ---------------------------------------------------------------------------
+
+describe('buildMatrixURI', () => {
+ it.each([
+ [
+ '!room:server.tld',
+ '$event:server.tld',
+ // encodeURIComponent does not encode '!' β only ':' and '$' are encoded here
+ 'matrix:roomid/!room%3Aserver.tld/e/%24event%3Aserver.tld',
+ ],
+ ['simple', 'id', 'matrix:roomid/simple/e/id'],
+ ['a b', 'c d', 'matrix:roomid/a%20b/e/c%20d'],
+ ])('buildMatrixURI(%s, %s) β %s', (roomId, eventId, expected) => {
+ expect(buildMatrixURI(roomId, eventId)).toBe(expected);
+ });
+
+ it('starts with "matrix:roomid/"', () => {
+ expect(buildMatrixURI('!r:s', '$e:s')).toMatch(/^matrix:roomid\//);
+ });
+
+ it('contains "/e/" separator between roomId and eventId', () => {
+ expect(buildMatrixURI('!r:s', '$e:s')).toContain('/e/');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// extractBodyPreview
+// ---------------------------------------------------------------------------
+
+describe('extractBodyPreview', () => {
+ it('returns the body unchanged when it is within the default limit', () => {
+ const event = makeEvent({ body: 'Hello, world!' });
+ expect(extractBodyPreview(event)).toBe('Hello, world!');
+ });
+
+ it('returns an empty string when body is undefined', () => {
+ const event = makeEvent({ body: undefined });
+ expect(extractBodyPreview(event)).toBe('');
+ });
+
+ it('returns an empty string when body is a non-string type', () => {
+ const event = makeEvent({ body: 42 });
+ expect(extractBodyPreview(event)).toBe('');
+ });
+
+ it('returns an empty string when body is an empty string', () => {
+ const event = makeEvent({ body: '' });
+ expect(extractBodyPreview(event)).toBe('');
+ });
+
+ it('truncates to 120 chars and appends "β¦" when body exceeds the default limit', () => {
+ const long = 'x'.repeat(200);
+ const result = extractBodyPreview(makeEvent({ body: long }));
+ expect(result).toHaveLength(121); // 120 + ellipsis char
+ expect(result.endsWith('\u2026')).toBe(true);
+ expect(result.slice(0, 120)).toBe('x'.repeat(120));
+ });
+
+ it('does not truncate when body is exactly 120 chars', () => {
+ const exact = 'y'.repeat(120);
+ expect(extractBodyPreview(makeEvent({ body: exact }))).toBe(exact);
+ });
+
+ it('respects a custom maxLength', () => {
+ const event = makeEvent({ body: 'abcdefghij' });
+ const result = extractBodyPreview(event, 5);
+ expect(result).toBe('abcde\u2026');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// isValidIndexContent
+// ---------------------------------------------------------------------------
+
+describe('isValidIndexContent', () => {
+ const valid = {
+ version: 1 as const,
+ revision: 0,
+ updated_ts: Date.now(),
+ bookmark_ids: [],
+ };
+
+ it('accepts a well-formed index', () => {
+ expect(isValidIndexContent(valid)).toBe(true);
+ });
+
+ it('accepts an index with string IDs in bookmark_ids', () => {
+ expect(isValidIndexContent({ ...valid, bookmark_ids: ['bmk_aabbccdd'] })).toBe(true);
+ });
+
+ it('rejects null', () => {
+ expect(isValidIndexContent(null)).toBe(false);
+ });
+
+ it('rejects a non-object', () => {
+ expect(isValidIndexContent('string')).toBe(false);
+ expect(isValidIndexContent(42)).toBe(false);
+ });
+
+ it('rejects version !== 1', () => {
+ expect(isValidIndexContent({ ...valid, version: 2 })).toBe(false);
+ });
+
+ it('rejects missing revision', () => {
+ const { revision, ...rest } = valid;
+ expect(isValidIndexContent(rest)).toBe(false);
+ });
+
+ it('rejects missing updated_ts', () => {
+ const { updated_ts: updatedTs, ...rest } = valid;
+ expect(isValidIndexContent(rest)).toBe(false);
+ });
+
+ it('rejects missing bookmark_ids', () => {
+ const { bookmark_ids: bookmarkIds, ...rest } = valid;
+ expect(isValidIndexContent(rest)).toBe(false);
+ });
+
+ it('rejects bookmark_ids containing a non-string', () => {
+ expect(isValidIndexContent({ ...valid, bookmark_ids: [1, 2, 3] })).toBe(false);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// isValidBookmarkItem
+// ---------------------------------------------------------------------------
+
+describe('isValidBookmarkItem', () => {
+ const valid = {
+ version: 1 as const,
+ bookmark_id: 'bmk_abcd1234',
+ uri: 'matrix:roomid/foo/e/bar',
+ room_id: '!room:s',
+ event_id: '$event:s',
+ event_ts: 1_000_000,
+ bookmarked_ts: 2_000_000,
+ };
+
+ it('accepts a complete, well-formed item', () => {
+ expect(isValidBookmarkItem(valid)).toBe(true);
+ });
+
+ it('accepts an item with optional fields set', () => {
+ expect(
+ isValidBookmarkItem({ ...valid, sender: '@alice:s', room_name: 'Room', deleted: false })
+ ).toBe(true);
+ });
+
+ it('rejects null', () => {
+ expect(isValidBookmarkItem(null)).toBe(false);
+ });
+
+ it('rejects a non-object', () => {
+ expect(isValidBookmarkItem('string')).toBe(false);
+ });
+
+ it('rejects version !== 1', () => {
+ expect(isValidBookmarkItem({ ...valid, version: 2 })).toBe(false);
+ });
+
+ it.each(['bookmark_id', 'uri', 'room_id', 'event_id'] as const)(
+ 'rejects item missing string field "%s"',
+ (field) => {
+ const copy = { ...valid } as Record;
+ delete copy[field];
+ expect(isValidBookmarkItem(copy)).toBe(false);
+ }
+ );
+
+ it.each(['event_ts', 'bookmarked_ts'] as const)(
+ 'rejects item missing numeric field "%s"',
+ (field) => {
+ const copy = { ...valid } as Record;
+ delete copy[field];
+ expect(isValidBookmarkItem(copy)).toBe(false);
+ }
+ );
+});
+
+// ---------------------------------------------------------------------------
+// createBookmarkItem
+// ---------------------------------------------------------------------------
+
+describe('createBookmarkItem', () => {
+ it('returns undefined when the event has no ID', () => {
+ const room = makeRoom();
+ const event = makeEvent({ id: null });
+ expect(createBookmarkItem(room, event)).toBeUndefined();
+ });
+
+ it('returns a valid BookmarkItemContent for a normal event', () => {
+ const room = makeRoom({ roomId: '!r:s', name: 'My Room' });
+ const event = makeEvent({
+ id: '$e:s',
+ body: 'Hello',
+ msgtype: 'm.text',
+ sender: '@bob:s',
+ ts: 123456,
+ });
+ const item = createBookmarkItem(room, event);
+ expect(item).toBeDefined();
+ expect(item!.version).toBe(1);
+ expect(item!.room_id).toBe('!r:s');
+ expect(item!.event_id).toBe('$e:s');
+ expect(item!.bookmark_id).toBe(computeBookmarkId('!r:s', '$e:s'));
+ expect(item!.uri).toBe(buildMatrixURI('!r:s', '$e:s'));
+ expect(item!.event_ts).toBe(123456);
+ expect(item!.sender).toBe('@bob:s');
+ expect(item!.room_name).toBe('My Room');
+ expect(item!.body_preview).toBe('Hello');
+ expect(item!.msgtype).toBe('m.text');
+ });
+
+ it('omits body_preview when body is missing', () => {
+ const room = makeRoom();
+ const event = makeEvent({ body: undefined });
+ const item = createBookmarkItem(room, event);
+ expect(item!.body_preview).toBe('');
+ });
+
+ it('passes isValidBookmarkItem on the returned content', () => {
+ const room = makeRoom();
+ const event = makeEvent();
+ const item = createBookmarkItem(room, event);
+ expect(isValidBookmarkItem(item)).toBe(true);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// emptyIndex
+// ---------------------------------------------------------------------------
+
+describe('emptyIndex', () => {
+ it('returns a valid index with version 1', () => {
+ const idx = emptyIndex();
+ expect(isValidIndexContent(idx)).toBe(true);
+ expect(idx.version).toBe(1);
+ });
+
+ it('starts with revision 0 and empty bookmark_ids', () => {
+ const idx = emptyIndex();
+ expect(idx.revision).toBe(0);
+ expect(idx.bookmark_ids).toEqual([]);
+ });
+
+ it('returns a fresh object on each call (no shared reference)', () => {
+ const a = emptyIndex();
+ const b = emptyIndex();
+ a.bookmark_ids.push('bmk_aabbccdd');
+ expect(b.bookmark_ids).toHaveLength(0);
+ });
+});
diff --git a/src/app/features/bookmarks/bookmarkRepository.test.ts b/src/app/features/bookmarks/bookmarkRepository.test.ts
new file mode 100644
index 000000000..7b928b50e
--- /dev/null
+++ b/src/app/features/bookmarks/bookmarkRepository.test.ts
@@ -0,0 +1,334 @@
+/**
+ * Unit tests for MSC4438 bookmark repository layer.
+ *
+ * The repository functions are pure in the sense that they read and write
+ * synchronously from a MatrixClient mock that returns predictable account data.
+ * No network calls are made.
+ */
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import type { MatrixClient } from '$types/matrix-sdk';
+import { AccountDataEvent } from '$types/matrix/accountData';
+import { addBookmark, removeBookmark, listBookmarks, isBookmarked } from './bookmarkRepository';
+import {
+ bookmarkItemEventType,
+ emptyIndex,
+ type BookmarkIndexContent,
+ type BookmarkItemContent,
+} from './bookmarkDomain';
+
+// ---------------------------------------------------------------------------
+// Stub MatrixClient
+// ---------------------------------------------------------------------------
+
+/**
+ * Build a minimal MatrixClient stub backed by an in-memory store.
+ * `getAccountData` returns a fake MatrixEvent whose `getContent()` reads
+ * from the store; `setAccountData` writes to the store.
+ */
+function makeClient(initialData: Record = {}): MatrixClient {
+ const store: Record = { ...initialData };
+ const accountData = new Map(Object.entries(store));
+
+ return {
+ getAccountData: vi.fn((eventType: string) => {
+ const content = store[eventType];
+ if (content === undefined) return undefined;
+ return { getContent: () => content };
+ }),
+ setAccountData: vi.fn(async (eventType: string, content: unknown) => {
+ store[eventType] = content;
+ accountData.set(eventType, content);
+ }),
+ store: { accountData },
+ _store: store, // exposed for inspection in tests
+ } as unknown as MatrixClient;
+}
+
+// ---------------------------------------------------------------------------
+// Test data helpers
+// ---------------------------------------------------------------------------
+
+function makeItem(overrides: Partial = {}): BookmarkItemContent {
+ return {
+ version: 1,
+ bookmark_id: 'bmk_aabbccdd',
+ uri: 'matrix:roomid/foo/e/bar',
+ room_id: '!room:s',
+ event_id: '$event:s',
+ event_ts: 1_000_000,
+ bookmarked_ts: 2_000_000,
+ ...overrides,
+ };
+}
+
+function makeIndex(overrides: Partial = {}): BookmarkIndexContent {
+ return {
+ ...emptyIndex(),
+ ...overrides,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// addBookmark
+// ---------------------------------------------------------------------------
+
+describe('addBookmark', () => {
+ let mx: MatrixClient;
+
+ beforeEach(() => {
+ mx = makeClient();
+ });
+
+ it('writes the item event before writing the index', async () => {
+ const item = makeItem();
+ const callOrder: string[] = [];
+
+ (mx.setAccountData as ReturnType).mockImplementation(
+ async (type: string, content: unknown) => {
+ callOrder.push(type);
+ // keep default in-memory behaviour
+ (mx as any)._store[type] = content;
+ }
+ );
+
+ await addBookmark(mx, item);
+
+ expect(callOrder[0]).toBe(bookmarkItemEventType(item.bookmark_id));
+ expect(callOrder[1]).toBe(AccountDataEvent.BookmarksIndex);
+ });
+
+ it('prepends the bookmark ID to bookmark_ids in the index', async () => {
+ const existing = makeItem({ bookmark_id: 'bmk_11111111' });
+ const mx2 = makeClient({
+ [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: [existing.bookmark_id] }),
+ [bookmarkItemEventType(existing.bookmark_id)]: existing,
+ });
+
+ const newItem = makeItem({ bookmark_id: 'bmk_22222222' });
+ await addBookmark(mx2, newItem);
+
+ const store = (mx2 as any)._store;
+ const idx = store[AccountDataEvent.BookmarksIndex] as BookmarkIndexContent;
+ expect(idx.bookmark_ids[0]).toBe('bmk_22222222');
+ expect(idx.bookmark_ids[1]).toBe('bmk_11111111');
+ });
+
+ it('does not duplicate an ID already in the index', async () => {
+ const item = makeItem();
+ const mx2 = makeClient({
+ [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: [item.bookmark_id] }),
+ [bookmarkItemEventType(item.bookmark_id)]: item,
+ });
+
+ await addBookmark(mx2, item);
+
+ const idx = (mx2 as any)._store[AccountDataEvent.BookmarksIndex] as BookmarkIndexContent;
+ expect(idx.bookmark_ids.filter((id) => id === item.bookmark_id)).toHaveLength(1);
+ });
+
+ it('increments the index revision', async () => {
+ const item = makeItem();
+ await addBookmark(mx, item);
+
+ const idx = (mx as any)._store[AccountDataEvent.BookmarksIndex] as BookmarkIndexContent;
+ expect(idx.revision).toBe(1);
+ });
+
+ it('works when no index exists yet (creates an empty one)', async () => {
+ const item = makeItem();
+ await addBookmark(mx, item);
+
+ const idx = (mx as any)._store[AccountDataEvent.BookmarksIndex] as BookmarkIndexContent;
+ expect(idx.bookmark_ids).toContain(item.bookmark_id);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// removeBookmark
+// ---------------------------------------------------------------------------
+
+describe('removeBookmark', () => {
+ it('removes the bookmark ID from the index', async () => {
+ const item = makeItem();
+ const mx = makeClient({
+ [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: [item.bookmark_id] }),
+ [bookmarkItemEventType(item.bookmark_id)]: item,
+ });
+
+ await removeBookmark(mx, item.bookmark_id);
+
+ const idx = (mx as any)._store[AccountDataEvent.BookmarksIndex] as BookmarkIndexContent;
+ expect(idx.bookmark_ids).not.toContain(item.bookmark_id);
+ });
+
+ it('soft-deletes the item event (sets deleted: true)', async () => {
+ const item = makeItem();
+ const mx = makeClient({
+ [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: [item.bookmark_id] }),
+ [bookmarkItemEventType(item.bookmark_id)]: item,
+ });
+
+ await removeBookmark(mx, item.bookmark_id);
+
+ const stored = (mx as any)._store[
+ bookmarkItemEventType(item.bookmark_id)
+ ] as BookmarkItemContent;
+ expect(stored.deleted).toBe(true);
+ });
+
+ it('increments the index revision', async () => {
+ const item = makeItem();
+ const mx = makeClient({
+ [AccountDataEvent.BookmarksIndex]: makeIndex({
+ bookmark_ids: [item.bookmark_id],
+ revision: 3,
+ }),
+ [bookmarkItemEventType(item.bookmark_id)]: item,
+ });
+
+ await removeBookmark(mx, item.bookmark_id);
+
+ const idx = (mx as any)._store[AccountDataEvent.BookmarksIndex] as BookmarkIndexContent;
+ expect(idx.revision).toBe(4);
+ });
+
+ it('succeeds without error when the item event does not exist', async () => {
+ const item = makeItem();
+ const mx = makeClient({
+ [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: [item.bookmark_id] }),
+ // No item event stored
+ });
+
+ await expect(removeBookmark(mx, item.bookmark_id)).resolves.not.toThrow();
+ });
+
+ it('leaves the index unchanged when the ID was not present', async () => {
+ const mx = makeClient({
+ [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: ['bmk_aaaabbbb'] }),
+ });
+
+ await removeBookmark(mx, 'bmk_nonexistent');
+
+ const idx = (mx as any)._store[AccountDataEvent.BookmarksIndex] as BookmarkIndexContent;
+ expect(idx.bookmark_ids).toEqual(['bmk_aaaabbbb']);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// listBookmarks
+// ---------------------------------------------------------------------------
+
+describe('listBookmarks', () => {
+ it('returns an empty array when there is no index', () => {
+ const mx = makeClient();
+ expect(listBookmarks(mx)).toEqual([]);
+ });
+
+ it('returns active items in index order', () => {
+ const a = makeItem({ bookmark_id: 'bmk_aaaaaaaa' });
+ const b = makeItem({ bookmark_id: 'bmk_bbbbbbbb' });
+ const mx = makeClient({
+ [AccountDataEvent.BookmarksIndex]: makeIndex({
+ bookmark_ids: [a.bookmark_id, b.bookmark_id],
+ }),
+ [bookmarkItemEventType(a.bookmark_id)]: a,
+ [bookmarkItemEventType(b.bookmark_id)]: b,
+ });
+
+ const result = listBookmarks(mx);
+ expect(result.map((i) => i.bookmark_id)).toEqual([a.bookmark_id, b.bookmark_id]);
+ });
+
+ it('skips items that are soft-deleted (deleted: true)', () => {
+ const item = makeItem({ deleted: true });
+ const mx = makeClient({
+ [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: [item.bookmark_id] }),
+ [bookmarkItemEventType(item.bookmark_id)]: item,
+ });
+
+ expect(listBookmarks(mx)).toEqual([]);
+ });
+
+ it('skips item IDs whose event is missing from account data', () => {
+ const mx = makeClient({
+ [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: ['bmk_orphaned'] }),
+ // No item event
+ });
+
+ expect(listBookmarks(mx)).toEqual([]);
+ });
+
+ it('deduplicates IDs that appear more than once in bookmark_ids', () => {
+ const item = makeItem();
+ const mx = makeClient({
+ [AccountDataEvent.BookmarksIndex]: makeIndex({
+ bookmark_ids: [item.bookmark_id, item.bookmark_id],
+ }),
+ [bookmarkItemEventType(item.bookmark_id)]: item,
+ });
+
+ expect(listBookmarks(mx)).toHaveLength(1);
+ });
+
+ it('skips malformed item events', () => {
+ const mx = makeClient({
+ [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: ['bmk_bad'] }),
+ [bookmarkItemEventType('bmk_bad')]: { not_a_valid: 'item' },
+ });
+
+ expect(listBookmarks(mx)).toEqual([]);
+ });
+
+ it('recovers orphaned items whose event exists but ID is absent from the index', () => {
+ // Simulate a concurrent-write race: device A's bookmark_id was dropped from the
+ // index by a last-write-wins overwrite, but the item event still exists.
+ const orphan = makeItem({ bookmark_id: 'bmk_orphan1' });
+ const indexed = makeItem({ bookmark_id: 'bmk_indexed' });
+ const mx = makeClient({
+ [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: [indexed.bookmark_id] }),
+ [bookmarkItemEventType(indexed.bookmark_id)]: indexed,
+ [bookmarkItemEventType(orphan.bookmark_id)]: orphan,
+ });
+
+ const result = listBookmarks(mx);
+ expect(result.map((i) => i.bookmark_id)).toContain(orphan.bookmark_id);
+ expect(result.map((i) => i.bookmark_id)).toContain(indexed.bookmark_id);
+ // Indexed item should appear before the orphan
+ expect(result[0].bookmark_id).toBe(indexed.bookmark_id);
+ });
+
+ it('does not return soft-deleted orphaned items', () => {
+ const orphan = makeItem({ bookmark_id: 'bmk_orphan2', deleted: true });
+ const mx = makeClient({
+ // No index entry for the orphan β deleted orphan should still be skipped
+ [bookmarkItemEventType(orphan.bookmark_id)]: orphan,
+ });
+
+ expect(listBookmarks(mx)).toEqual([]);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// isBookmarked
+// ---------------------------------------------------------------------------
+
+describe('isBookmarked', () => {
+ it('returns true when the ID is in the index', () => {
+ const mx = makeClient({
+ [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: ['bmk_aabbccdd'] }),
+ });
+ expect(isBookmarked(mx, 'bmk_aabbccdd')).toBe(true);
+ });
+
+ it('returns false when the ID is not in the index', () => {
+ const mx = makeClient({
+ [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: ['bmk_aabbccdd'] }),
+ });
+ expect(isBookmarked(mx, 'bmk_ffffffff')).toBe(false);
+ });
+
+ it('returns false when there is no index', () => {
+ const mx = makeClient();
+ expect(isBookmarked(mx, 'bmk_aabbccdd')).toBe(false);
+ });
+});
diff --git a/src/app/state/bookmarks.test.ts b/src/app/state/bookmarks.test.ts
new file mode 100644
index 000000000..1145b9d1e
--- /dev/null
+++ b/src/app/state/bookmarks.test.ts
@@ -0,0 +1,69 @@
+/**
+ * Unit tests for the Jotai bookmark atoms in src/app/state/bookmarks.ts.
+ *
+ * The derived `bookmarkIdSetAtom` is the only atom with non-trivial logic β
+ * it builds an O(1) lookup Set from the bookmark list. The primitive atoms
+ * (`bookmarkListAtom`, `bookmarkLoadingAtom`) are default Jotai atoms whose
+ * read/write semantics are provided by the library itself and do not need
+ * additional testing.
+ */
+import { describe, it, expect } from 'vitest';
+import { createStore } from 'jotai';
+import { bookmarkIdSetAtom, bookmarkListAtom } from './bookmarks';
+import type { BookmarkItemContent } from '../features/bookmarks/bookmarkDomain';
+
+// Helper: minimal valid bookmark item
+function makeItem(id: string): BookmarkItemContent {
+ return {
+ version: 1,
+ bookmark_id: id,
+ uri: `matrix:roomid/foo/e/${id}`,
+ room_id: '!room:s',
+ event_id: `$${id}:s`,
+ event_ts: 1_000_000,
+ bookmarked_ts: 2_000_000,
+ };
+}
+
+describe('bookmarkIdSetAtom (derived)', () => {
+ it('returns an empty Set when the list is empty', () => {
+ const store = createStore();
+ const set = store.get(bookmarkIdSetAtom);
+ expect(set.size).toBe(0);
+ });
+
+ it('contains the IDs of every item in bookmarkListAtom', () => {
+ const store = createStore();
+ store.set(bookmarkListAtom, [makeItem('bmk_aaaaaaaa'), makeItem('bmk_bbbbbbbb')]);
+
+ const set = store.get(bookmarkIdSetAtom);
+ expect(set.has('bmk_aaaaaaaa')).toBe(true);
+ expect(set.has('bmk_bbbbbbbb')).toBe(true);
+ });
+
+ it('does not contain IDs not in the list', () => {
+ const store = createStore();
+ store.set(bookmarkListAtom, [makeItem('bmk_aaaaaaaa')]);
+
+ const set = store.get(bookmarkIdSetAtom);
+ expect(set.has('bmk_ffffffff')).toBe(false);
+ });
+
+ it('updates reactively when the list changes', () => {
+ const store = createStore();
+ store.set(bookmarkListAtom, [makeItem('bmk_11111111')]);
+
+ expect(store.get(bookmarkIdSetAtom).has('bmk_11111111')).toBe(true);
+
+ store.set(bookmarkListAtom, []);
+ expect(store.get(bookmarkIdSetAtom).has('bmk_11111111')).toBe(false);
+ });
+
+ it('returns a Set whose size equals the number of unique items', () => {
+ const store = createStore();
+ const items = [makeItem('bmk_aaaaaaaa'), makeItem('bmk_bbbbbbbb'), makeItem('bmk_cccccccc')];
+ store.set(bookmarkListAtom, items);
+
+ expect(store.get(bookmarkIdSetAtom).size).toBe(3);
+ });
+});
From 2a9fa5206c580bbd793ca06a71cee4781da30209 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Mon, 6 Apr 2026 12:46:48 -0400
Subject: [PATCH 047/253] chore: add changeset for message-bookmarks
---
.changeset/message-bookmarks.md | 5 +++++
1 file changed, 5 insertions(+)
create mode 100644 .changeset/message-bookmarks.md
diff --git a/.changeset/message-bookmarks.md b/.changeset/message-bookmarks.md
new file mode 100644
index 000000000..9ca1cf6c3
--- /dev/null
+++ b/.changeset/message-bookmarks.md
@@ -0,0 +1,5 @@
+---
+default: minor
+---
+
+Add message bookmarks (MSC4438). Users can bookmark messages for easy retrieval via a new Bookmarks section in the home sidebar. Gated by an operator `config.json` experiment flag (`experiments.messageBookmarks`) and a per-user experimental settings toggle.
From 68d35f90c4ee61b13ddfdbb0b0fdca8fe22ad9ba Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Mon, 6 Apr 2026 19:28:26 +0000
Subject: [PATCH 048/253] 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 d327e19b21339eb3a3d97b4306acdd68c7c279f3 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Mon, 6 Apr 2026 19:43:17 +0000
Subject: [PATCH 049/253] 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 426ef748c1a912bff71d8191398c3b0e76190240 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Mon, 6 Apr 2026 19:45:50 +0000
Subject: [PATCH 050/253] 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 53930d43731b522122497f9fcfbfe096b46ca217 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Mon, 6 Apr 2026 19:58:56 +0000
Subject: [PATCH 051/253] 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 d9a10cbe7e003ae0a57318132c675c6b9dc92f8f Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Mon, 6 Apr 2026 20:12:26 +0000
Subject: [PATCH 052/253] 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 6361351e270532784d7ebffc54907ac6b89cea76 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Mon, 6 Apr 2026 20:17:55 +0000
Subject: [PATCH 053/253] 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 3fcd6c19e0bf8a29ae2e0710ec564046dad545ba Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Mon, 6 Apr 2026 21:26:03 +0000
Subject: [PATCH 054/253] 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 76e0c49c6b827b0d48d7bc82e18c758a3f89beac Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Mon, 6 Apr 2026 22:14:04 +0000
Subject: [PATCH 055/253] 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 ff1b207ebb3ba3e90c3a5d69d2801c9fc40ec96f Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Mon, 6 Apr 2026 22:59:15 +0000
Subject: [PATCH 056/253] 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 bccc48f8247d8939681a2ca0077abeff9c25b09a Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Tue, 7 Apr 2026 14:18:28 +0000
Subject: [PATCH 057/253] 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 2964e8952dd129a8348d30182d92ff0e00ca1899 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Tue, 7 Apr 2026 14:48:36 +0000
Subject: [PATCH 058/253] 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 b3304bb9207ff4814cf1268e8280f9f6715ffaca Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Tue, 7 Apr 2026 14:50:09 +0000
Subject: [PATCH 059/253] 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 06c981039385db6a0b546f223f0e8603b4d8e562 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Tue, 7 Apr 2026 14:55:58 +0000
Subject: [PATCH 060/253] 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 ac899aa45b43723582b23e1d61c4f06ec24ca355 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Tue, 7 Apr 2026 15:39:38 +0000
Subject: [PATCH 061/253] 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 dcb56f69c70a424df604cb702e8afa5e7d001197 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Tue, 7 Apr 2026 16:38:55 +0000
Subject: [PATCH 062/253] 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 e848f3a686c78421d4ca678b013fad314734f4fb Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Tue, 7 Apr 2026 16:39:54 +0000
Subject: [PATCH 063/253] 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 a294d1699bf78fde178b5d7a2ecbff4b077dfcc4 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Tue, 7 Apr 2026 17:00:56 +0000
Subject: [PATCH 064/253] 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 2eeaa436f6e7a2d92c978e507f5ba6efa87183cb Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Tue, 7 Apr 2026 18:27:51 +0000
Subject: [PATCH 065/253] 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 a4751d4b5dfbe1eaa6e32e8fb07ac0f38e4f2a82 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Wed, 8 Apr 2026 18:24:20 +0000
Subject: [PATCH 066/253] 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 1ad6b038c56bfedff0381fe78974ebf160a62f47 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Wed, 8 Apr 2026 16:42:01 -0400
Subject: [PATCH 067/253] fix(bookmarks): add missing focusId to MSC4438
settings SettingTile
---
.../features/settings/experimental/MSC4438MessageBookmarks.tsx | 1 +
1 file changed, 1 insertion(+)
diff --git a/src/app/features/settings/experimental/MSC4438MessageBookmarks.tsx b/src/app/features/settings/experimental/MSC4438MessageBookmarks.tsx
index 45e429b18..0751a5578 100644
--- a/src/app/features/settings/experimental/MSC4438MessageBookmarks.tsx
+++ b/src/app/features/settings/experimental/MSC4438MessageBookmarks.tsx
@@ -16,6 +16,7 @@ export function MSC4438MessageBookmarks() {
Message Bookmarks
From 12888d9abe208f15848dccf8a58ad4ad178b67a1 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Wed, 8 Apr 2026 17:51:06 -0400
Subject: [PATCH 068/253] fix(devtools): add focusId to ExperimentsPanel
SettingTile
---
src/app/features/settings/developer-tools/ExperimentsPanel.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/app/features/settings/developer-tools/ExperimentsPanel.tsx b/src/app/features/settings/developer-tools/ExperimentsPanel.tsx
index 4c4689c03..0308d6932 100644
--- a/src/app/features/settings/developer-tools/ExperimentsPanel.tsx
+++ b/src/app/features/settings/developer-tools/ExperimentsPanel.tsx
@@ -41,7 +41,7 @@ export function ExperimentsPanel() {
gap="400"
>
{experiments.map(({ key, config: experimentConfig, selection }) => (
-
+
From ac4e5b44199391c331b0eb30457613c1e42ecdaa Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Thu, 9 Apr 2026 09:21:39 -0400
Subject: [PATCH 069/253] fix(presence): skip REST presence fetch when userId
is empty string
---
src/app/hooks/useUserPresence.ts | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/src/app/hooks/useUserPresence.ts b/src/app/hooks/useUserPresence.ts
index a3b86ef08..52bb99467 100644
--- a/src/app/hooks/useUserPresence.ts
+++ b/src/app/hooks/useUserPresence.ts
@@ -36,7 +36,9 @@ export const useUserPresence = (userId: string): UserPresence | undefined => {
// Sliding sync (Synapse MSC4186) has no presence extension β m.presence events are never
// delivered via sync. As a result, User.presence stays at the SDK default and
// getLastActiveTs() stays 0. Fall back to a direct REST fetch to bootstrap presence state.
- if (!user || user.getLastActiveTs() === 0) {
+ // Guard against empty userId β callers that render a fixed number of hooks (e.g. group DM
+ // slots) pass '' for absent members; firing getPresence('') would be a malformed request.
+ if (userId && (!user || user.getLastActiveTs() === 0)) {
mx.getPresence(userId)
.then((resp) => {
if (cancelled) return;
From 755588b737a3fb8fef3694e35f2d445637bb9a58 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Thu, 9 Apr 2026 09:23:25 -0400
Subject: [PATCH 070/253] fix(bookmarks): soft-delete item before updating
index in removeBookmark
---
src/app/features/bookmarks/bookmarkRepository.ts | 14 ++++++++------
1 file changed, 8 insertions(+), 6 deletions(-)
diff --git a/src/app/features/bookmarks/bookmarkRepository.ts b/src/app/features/bookmarks/bookmarkRepository.ts
index d6703777f..e6d712dbd 100644
--- a/src/app/features/bookmarks/bookmarkRepository.ts
+++ b/src/app/features/bookmarks/bookmarkRepository.ts
@@ -77,17 +77,19 @@ export async function addBookmark(mx: MatrixClient, item: BookmarkItemContent):
* removed.
*/
export async function removeBookmark(mx: MatrixClient, bookmarkId: string): Promise {
+ // Soft-delete the item FIRST β mirrors the item-before-index ordering of addBookmark.
+ // If writeIndex ran first, orphan recovery in listBookmarks() would transiently resurface the
+ // bookmark (item not yet deleted, but ID also not in index) between the two writes.
+ const existing = readItem(mx, bookmarkId);
+ if (existing) {
+ await writeItem(mx, { ...existing, deleted: true });
+ }
+
const index = readIndex(mx);
index.bookmark_ids = index.bookmark_ids.filter((id) => id !== bookmarkId);
index.revision += 1;
index.updated_ts = Date.now();
await writeIndex(mx, index);
-
- // Soft-delete the item event
- const existing = readItem(mx, bookmarkId);
- if (existing) {
- await writeItem(mx, { ...existing, deleted: true });
- }
}
/**
From 49a98b3df3d35a3f9b5c9730a20fe3038f353800 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Thu, 9 Apr 2026 10:53:51 -0400
Subject: [PATCH 071/253] feat(polls): expose max_selections in poll creator
dialog
Allow poll creators to choose how many options voters may select (1 to
the number of options). Defaults to 1 (single-choice). The value is
clamped to [1, validAnswers.length] on submit per MSC3381. The voting
UI already handles multi-selection correctly in PollEvent."
---
.../features/room/poll/PollCreatorDialog.tsx | 24 ++++++++++++++++++-
1 file changed, 23 insertions(+), 1 deletion(-)
diff --git a/src/app/features/room/poll/PollCreatorDialog.tsx b/src/app/features/room/poll/PollCreatorDialog.tsx
index e9a1b8ba8..f364f143f 100644
--- a/src/app/features/room/poll/PollCreatorDialog.tsx
+++ b/src/app/features/room/poll/PollCreatorDialog.tsx
@@ -53,7 +53,9 @@ type PollCreatorDialogProps = {
export function PollCreatorDialog({ onCancel, onSubmit }: PollCreatorDialogProps) {
const questionId = useId();
+ const maxSelectionsId = useId();
const [question, setQuestion] = useState('');
+ const [maxSelections, setMaxSelections] = useState(1);
const [answers, setAnswers] = useState<{ id: string; text: string }[]>(() => [
{ id: crypto.randomUUID(), text: '' },
{ id: crypto.randomUUID(), text: '' },
@@ -122,6 +124,7 @@ export function PollCreatorDialog({ onCancel, onSubmit }: PollCreatorDialogProps
setError(`Please add at least ${MIN_ANSWERS} answers.`);
return;
}
+ const clampedMaxSelections = Math.min(Math.max(1, maxSelections), validAnswers.length);
if (expiryPreset === 'custom') {
const ts = customExpiry ? new Date(customExpiry).getTime() : NaN;
if (!Number.isFinite(ts) || ts <= Date.now()) {
@@ -134,7 +137,7 @@ export function PollCreatorDialog({ onCancel, onSubmit }: PollCreatorDialogProps
question: trimmedQuestion,
answers: validAnswers,
kind,
- maxSelections: 1,
+ maxSelections: clampedMaxSelections,
showVoterNames,
closesAt: computeClosesAt(),
});
@@ -239,6 +242,25 @@ export function PollCreatorDialog({ onCancel, onSubmit }: PollCreatorDialogProps
)}
+ {/* Max selections */}
+
+
+ Max selections
+
+ {
+ const val = parseInt((e.target as HTMLInputElement).value, 10);
+ if (!Number.isNaN(val) && val >= 1) setMaxSelections(val);
+ }}
+ style={{ width: '5rem' }}
+ />
+
+
{/* Poll kind */}
From 6a27271c025a62226e6526fdd7a251ded9933df3 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sat, 11 Apr 2026 12:40:43 -0400
Subject: [PATCH 072/253] test(polls): unit-test pure functions; export
extractPollData/computeTally/formatExpiry/extractVoteSelections
---
src/app/features/room/poll/PollEvent.tsx | 8 +-
src/app/features/room/poll/pollEvent.test.ts | 408 +++++++++++++++++++
2 files changed, 412 insertions(+), 4 deletions(-)
create mode 100644 src/app/features/room/poll/pollEvent.test.ts
diff --git a/src/app/features/room/poll/PollEvent.tsx b/src/app/features/room/poll/PollEvent.tsx
index 0ebfe58b1..1565c1c30 100644
--- a/src/app/features/room/poll/PollEvent.tsx
+++ b/src/app/features/room/poll/PollEvent.tsx
@@ -37,7 +37,7 @@ import * as css from './PollEvent.css';
type PollAnswer = { id: string; text: string };
-function extractPollData(mEvent: MatrixEvent): {
+export function extractPollData(mEvent: MatrixEvent): {
question: string;
answers: PollAnswer[];
maxSelections: number;
@@ -81,7 +81,7 @@ function extractPollData(mEvent: MatrixEvent): {
return { question: questionText, answers, maxSelections, isDisclosed, showVoterNames, closesAt };
}
-function extractVoteSelections(responseEvent: MatrixEvent): string[] {
+export function extractVoteSelections(responseEvent: MatrixEvent): string[] {
const content = responseEvent.getContent();
const unstablePayload = content['org.matrix.msc3381.poll.response'];
const selections: unknown =
@@ -99,7 +99,7 @@ type TallyResult = {
isEnded: boolean;
};
-function computeTally(
+export function computeTally(
room: Room,
pollEventId: string,
pollStartEvent: MatrixEvent,
@@ -161,7 +161,7 @@ function computeTally(
return { tally, myVote, isEnded };
}
-function formatExpiry(ts: number): string {
+export function formatExpiry(ts: number): string {
const diff = ts - Date.now();
if (diff <= 0) return 'now';
const hours = diff / 3_600_000;
diff --git a/src/app/features/room/poll/pollEvent.test.ts b/src/app/features/room/poll/pollEvent.test.ts
new file mode 100644
index 000000000..b431d7b3e
--- /dev/null
+++ b/src/app/features/room/poll/pollEvent.test.ts
@@ -0,0 +1,408 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import type { Room, MatrixEvent } from '$types/matrix-sdk';
+import {
+ extractPollData,
+ extractVoteSelections,
+ computeTally,
+ formatExpiry,
+} from './PollEvent';
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+const POLL_CREATOR = '@creator:test';
+const MY_USER_ID = '@me:test';
+
+/**
+ * Build a fake MatrixEvent that looks like an `m.poll.start` event.
+ */
+function makePollStartEvent(
+ id: string,
+ opts: {
+ question?: string;
+ answers?: { id: string; text: string }[];
+ maxSelections?: number;
+ kind?: string;
+ closesAt?: number;
+ showVoterNames?: boolean;
+ /** Use unstable (org.matrix) keys if true (default false β stable m.poll.start) */
+ unstable?: boolean;
+ } = {}
+): MatrixEvent {
+ const {
+ question = 'Favourite colour?',
+ answers = [
+ { id: 'ans-red', text: 'Red' },
+ { id: 'ans-blue', text: 'Blue' },
+ ],
+ maxSelections = 1,
+ kind = 'm.poll.disclosed',
+ closesAt,
+ showVoterNames = true,
+ unstable = false,
+ } = opts;
+
+ const rawAnswers = answers.map((a) => ({
+ 'm.id': a.id,
+ 'm.text': [{ body: a.text }],
+ }));
+ const pollStartKey = unstable ? 'org.matrix.msc3381.poll.start' : 'm.poll.start';
+
+ const content: Record = {
+ [pollStartKey]: {
+ question: { 'm.text': [{ body: question }] },
+ answers: rawAnswers,
+ max_selections: maxSelections,
+ kind,
+ show_voter_names: showVoterNames,
+ ...(closesAt != null ? { closes_at: closesAt } : {}),
+ },
+ };
+
+ return {
+ getId: () => id,
+ getSender: () => POLL_CREATOR,
+ getType: () => (unstable ? 'org.matrix.msc3381.poll.start' : 'm.poll.start'),
+ getContent: () => content,
+ getTs: () => 1_000,
+ } as unknown as MatrixEvent;
+}
+
+/**
+ * Build a fake poll-response MatrixEvent.
+ */
+function makeResponseEvent(
+ sender: string,
+ selections: string[],
+ ts: number,
+ isDecryptionFailure = false
+): MatrixEvent {
+ return {
+ getId: () => `${sender}-${ts}`,
+ getSender: () => sender,
+ getType: () => 'm.poll.response',
+ getTs: () => ts,
+ getContent: () => ({ 'm.selections': selections }),
+ isDecryptionFailure: () => isDecryptionFailure,
+ } as unknown as MatrixEvent;
+}
+
+/**
+ * Build a fake poll-end MatrixEvent.
+ */
+function makeEndEvent(sender: string, ts: number): MatrixEvent {
+ return {
+ getId: () => `end-${ts}`,
+ getSender: () => sender,
+ getType: () => 'm.poll.end',
+ getTs: () => ts,
+ getContent: () => ({}),
+ isDecryptionFailure: () => false,
+ } as unknown as MatrixEvent;
+}
+
+/**
+ * Build a minimal fake Room whose `relations.getAllChildEventsForEvent` returns
+ * the provided child events.
+ */
+function makeRoom(
+ childEvents: MatrixEvent[],
+ maySendRedaction = false
+): Room {
+ return {
+ getUnfilteredTimelineSet: () => ({
+ relations: {
+ getAllChildEventsForEvent: (_id: string) => childEvents,
+ },
+ }),
+ currentState: {
+ maySendRedactionForEvent: (_event: MatrixEvent, _sender: string) => maySendRedaction,
+ },
+ } as unknown as Room;
+}
+
+// ---------------------------------------------------------------------------
+// extractPollData
+// ---------------------------------------------------------------------------
+
+describe('extractPollData', () => {
+ it('parses a stable (m.poll.start) event', () => {
+ const ev = makePollStartEvent('$poll:test');
+ const data = extractPollData(ev);
+ expect(data).not.toBeNull();
+ expect(data?.question).toBe('Favourite colour?');
+ expect(data?.answers).toHaveLength(2);
+ expect(data?.answers[0]).toEqual({ id: 'ans-red', text: 'Red' });
+ expect(data?.maxSelections).toBe(1);
+ expect(data?.isDisclosed).toBe(true);
+ expect(data?.showVoterNames).toBe(true);
+ expect(data?.closesAt).toBeUndefined();
+ });
+
+ it('parses an unstable (org.matrix.msc3381) event', () => {
+ const ev = makePollStartEvent('$poll:test', { unstable: true });
+ const data = extractPollData(ev);
+ expect(data?.question).toBe('Favourite colour?');
+ expect(data?.isDisclosed).toBe(true);
+ });
+
+ it('returns null when there is no poll payload', () => {
+ const ev = {
+ getContent: () => ({}),
+ } as unknown as MatrixEvent;
+ expect(extractPollData(ev)).toBeNull();
+ });
+
+ it('caps answers to 20 even if more are provided', () => {
+ const tooManyAnswers = Array.from({ length: 25 }, (_, i) => ({
+ id: `a${i}`,
+ text: `Answer ${i}`,
+ }));
+ const ev = makePollStartEvent('$poll:test', { answers: tooManyAnswers });
+ const data = extractPollData(ev);
+ expect(data?.answers).toHaveLength(20);
+ });
+
+ it('defaults maxSelections to 1 when not a positive integer', () => {
+ const ev = makePollStartEvent('$poll:test', { maxSelections: 0 });
+ expect(extractPollData(ev)?.maxSelections).toBe(1);
+ });
+
+ it('parses closesAt when present', () => {
+ const future = Date.now() + 3_600_000;
+ const ev = makePollStartEvent('$poll:test', { closesAt: future });
+ expect(extractPollData(ev)?.closesAt).toBe(future);
+ });
+
+ it('treats m.poll.disclosed kind as isDisclosed=true', () => {
+ const ev = makePollStartEvent('$poll:test', { kind: 'm.poll.disclosed' });
+ expect(extractPollData(ev)?.isDisclosed).toBe(true);
+ });
+
+ it('treats m.poll.undisclosed kind as isDisclosed=false', () => {
+ const ev = makePollStartEvent('$poll:test', { kind: 'm.poll.undisclosed' });
+ expect(extractPollData(ev)?.isDisclosed).toBe(false);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// extractVoteSelections
+// ---------------------------------------------------------------------------
+
+describe('extractVoteSelections', () => {
+ it('returns stable m.selections array', () => {
+ const ev = {
+ getContent: () => ({ 'm.selections': ['ans-red'] }),
+ } as unknown as MatrixEvent;
+ expect(extractVoteSelections(ev)).toEqual(['ans-red']);
+ });
+
+ it('falls back to unstable org.matrix.msc3381.poll.response.answers', () => {
+ const ev = {
+ getContent: () => ({
+ 'org.matrix.msc3381.poll.response': { answers: ['ans-blue'] },
+ }),
+ } as unknown as MatrixEvent;
+ expect(extractVoteSelections(ev)).toEqual(['ans-blue']);
+ });
+
+ it('returns [] when the content has no selections field', () => {
+ const ev = { getContent: () => ({}) } as unknown as MatrixEvent;
+ expect(extractVoteSelections(ev)).toEqual([]);
+ });
+
+ it('filters out non-string values from selections array', () => {
+ const ev = {
+ getContent: () => ({ 'm.selections': ['valid', 42, null, 'also-valid'] }),
+ } as unknown as MatrixEvent;
+ expect(extractVoteSelections(ev)).toEqual(['valid', 'also-valid']);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// computeTally
+// ---------------------------------------------------------------------------
+
+describe('computeTally', () => {
+ const ANSWERS = [
+ { id: 'ans-red', text: 'Red' },
+ { id: 'ans-blue', text: 'Blue' },
+ ];
+
+ it('correctly tallies a single vote', () => {
+ const pollStart = makePollStartEvent('$poll:test');
+ const children = [makeResponseEvent('@alice:test', ['ans-red'], 2_000)];
+ const room = makeRoom(children);
+
+ const { tally, isEnded } = computeTally(
+ room,
+ '$poll:test',
+ pollStart,
+ ANSWERS,
+ 1,
+ MY_USER_ID
+ );
+
+ expect(isEnded).toBe(false);
+ expect(tally.get('ans-red')?.size).toBe(1);
+ expect(tally.get('ans-blue')?.size).toBe(0);
+ });
+
+ it('deduplicates votes from the same sender β latest timestamp wins', () => {
+ const pollStart = makePollStartEvent('$poll:test');
+ const children = [
+ makeResponseEvent('@alice:test', ['ans-red'], 2_000), // older
+ makeResponseEvent('@alice:test', ['ans-blue'], 3_000), // newer β should win
+ ];
+ const room = makeRoom(children);
+
+ const { tally } = computeTally(room, '$poll:test', pollStart, ANSWERS, 1, MY_USER_ID);
+
+ expect(tally.get('ans-red')?.size).toBe(0);
+ expect(tally.get('ans-blue')?.size).toBe(1);
+ });
+
+ it('ignores votes for answer ids not in the poll', () => {
+ const pollStart = makePollStartEvent('$poll:test');
+ const children = [makeResponseEvent('@alice:test', ['ans-invalid'], 2_000)];
+ const room = makeRoom(children);
+
+ const { tally } = computeTally(room, '$poll:test', pollStart, ANSWERS, 1, MY_USER_ID);
+
+ expect(tally.get('ans-red')?.size).toBe(0);
+ expect(tally.get('ans-blue')?.size).toBe(0);
+ });
+
+ it('caps vote selections to maxSelections', () => {
+ const multiAnswers = [
+ { id: 'a', text: 'A' },
+ { id: 'b', text: 'B' },
+ { id: 'c', text: 'C' },
+ ];
+ const pollStart = makePollStartEvent('$poll:test', { answers: multiAnswers, maxSelections: 2 });
+ // Alice tries to vote for all 3 β only first 2 should count
+ const children = [makeResponseEvent('@alice:test', ['a', 'b', 'c'], 2_000)];
+ const room = makeRoom(children);
+
+ const { tally } = computeTally(room, '$poll:test', pollStart, multiAnswers, 2, MY_USER_ID);
+
+ expect(tally.get('a')?.size).toBe(1);
+ expect(tally.get('b')?.size).toBe(1);
+ expect(tally.get('c')?.size).toBe(0); // third selection dropped
+ });
+
+ it('marks poll as ended when poll creator sends an end event', () => {
+ const pollStart = makePollStartEvent('$poll:test');
+ const children = [
+ makeResponseEvent('@alice:test', ['ans-red'], 2_000),
+ makeEndEvent(POLL_CREATOR, 5_000),
+ ];
+ const room = makeRoom(children);
+
+ const { isEnded } = computeTally(room, '$poll:test', pollStart, ANSWERS, 1, MY_USER_ID);
+
+ expect(isEnded).toBe(true);
+ });
+
+ it('excludes votes submitted after the poll end timestamp', () => {
+ const pollStart = makePollStartEvent('$poll:test');
+ const end = makeEndEvent(POLL_CREATOR, 3_000);
+ const children = [
+ makeResponseEvent('@alice:test', ['ans-red'], 2_000), // before end β counts
+ makeResponseEvent('@bob:test', ['ans-blue'], 4_000), // after end β excluded
+ end,
+ ];
+ const room = makeRoom(children);
+
+ const { tally } = computeTally(room, '$poll:test', pollStart, ANSWERS, 1, MY_USER_ID);
+
+ expect(tally.get('ans-red')?.size).toBe(1);
+ expect(tally.get('ans-blue')?.size).toBe(0);
+ });
+
+ it('ignores end events from unauthorised senders', () => {
+ const pollStart = makePollStartEvent('$poll:test');
+ const children = [
+ makeResponseEvent('@alice:test', ['ans-red'], 2_000),
+ makeEndEvent('@rogue:test', 3_000), // not creator, no redaction power
+ ];
+ const room = makeRoom(children, /* maySendRedaction= */ false);
+
+ const { isEnded } = computeTally(room, '$poll:test', pollStart, ANSWERS, 1, MY_USER_ID);
+
+ expect(isEnded).toBe(false);
+ });
+
+ it('accepts end events from users with redaction power', () => {
+ const pollStart = makePollStartEvent('$poll:test');
+ const children = [makeEndEvent('@moderator:test', 3_000)];
+ const room = makeRoom(children, /* maySendRedaction= */ true);
+
+ const { isEnded } = computeTally(room, '$poll:test', pollStart, ANSWERS, 1, MY_USER_ID);
+
+ expect(isEnded).toBe(true);
+ });
+
+ it('ignores decryption-failure response events', () => {
+ const pollStart = makePollStartEvent('$poll:test');
+ const children = [makeResponseEvent('@alice:test', ['ans-red'], 2_000, /* decryptFailure= */ true)];
+ const room = makeRoom(children);
+
+ const { tally } = computeTally(room, '$poll:test', pollStart, ANSWERS, 1, MY_USER_ID);
+
+ expect(tally.get('ans-red')?.size).toBe(0);
+ });
+
+ it('reports myVote from the current user', () => {
+ const pollStart = makePollStartEvent('$poll:test');
+ const children = [makeResponseEvent(MY_USER_ID, ['ans-blue'], 2_000)];
+ const room = makeRoom(children);
+
+ const { myVote } = computeTally(room, '$poll:test', pollStart, ANSWERS, 1, MY_USER_ID);
+
+ expect(myVote).toEqual(['ans-blue']);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// formatExpiry
+// ---------------------------------------------------------------------------
+
+describe('formatExpiry', () => {
+ let now: number;
+
+ beforeEach(() => {
+ now = Date.now();
+ vi.useFakeTimers();
+ vi.setSystemTime(now);
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('returns "now" for a past or zero timestamp', () => {
+ expect(formatExpiry(now - 1)).toBe('now');
+ expect(formatExpiry(now)).toBe('now');
+ });
+
+ it('returns "in X min" for times less than 1 hour away', () => {
+ expect(formatExpiry(now + 30 * 60_000)).toBe('in 30 min');
+ });
+
+ it('returns "in X hr" for times between 1 and 24 hours away', () => {
+ expect(formatExpiry(now + 3 * 3_600_000)).toBe('in 3 hr');
+ });
+
+ it('returns "in X day(s)" for times between 1 and 6 days away', () => {
+ expect(formatExpiry(now + 2 * 86_400_000)).toBe('in 2 days');
+ expect(formatExpiry(now + 86_400_000)).toBe('in 1 day');
+ });
+
+ it('returns a locale date string for 7+ days away', () => {
+ const future = now + 10 * 86_400_000;
+ const expected = new Date(future).toLocaleDateString();
+ expect(formatExpiry(future)).toBe(expected);
+ });
+});
From 35acb82117bd3bdf0ad0d0529560c2982cec774b Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sat, 11 Apr 2026 12:43:27 -0400
Subject: [PATCH 073/253] test(presence): add useUserPresence unit tests
---
src/app/hooks/useUserPresence.test.tsx | 205 +++++++++++++++++++++++++
1 file changed, 205 insertions(+)
create mode 100644 src/app/hooks/useUserPresence.test.tsx
diff --git a/src/app/hooks/useUserPresence.test.tsx b/src/app/hooks/useUserPresence.test.tsx
new file mode 100644
index 000000000..125629137
--- /dev/null
+++ b/src/app/hooks/useUserPresence.test.tsx
@@ -0,0 +1,205 @@
+import { act, renderHook } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { useUserPresence, Presence } from './useUserPresence';
+
+// ------- mock setup -------
+
+// Each test can override mockUser / mockGetPresence as needed.
+let mockUser: ReturnType | null = null;
+let mockGetPresence: ReturnType;
+
+vi.mock('$hooks/useMatrixClient', () => ({
+ useMatrixClient: () => mockMx,
+}));
+
+// Listeners registered via user.on() β captured so tests can emit events.
+const userListeners = new Map void)[]>();
+
+const makeMockUser = (opts: {
+ presence?: string;
+ presenceStatusMsg?: string | undefined;
+ currentlyActive?: boolean;
+ lastActiveTs?: number;
+} = {}) => ({
+ userId: '@alice:test',
+ presence: opts.presence ?? 'online',
+ presenceStatusMsg: opts.presenceStatusMsg,
+ currentlyActive: opts.currentlyActive ?? true,
+ getLastActiveTs: vi.fn().mockReturnValue(opts.lastActiveTs ?? 1000),
+ on: vi.fn().mockImplementation((event: string, handler: (...args: unknown[]) => void) => {
+ const list = userListeners.get(event) ?? [];
+ list.push(handler);
+ userListeners.set(event, list);
+ }),
+ removeListener: vi.fn(),
+});
+
+const mockMx = {
+ getUser: vi.fn((): ReturnType | null => mockUser),
+ getPresence: vi.fn(
+ (): Promise<{
+ presence: string;
+ status_msg?: string;
+ currently_active?: boolean;
+ last_active_ago?: number | null;
+ }> =>
+ mockGetPresence()
+ ),
+ on: vi.fn(),
+ removeListener: vi.fn(),
+};
+
+const USER_ID = '@alice:test';
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ userListeners.clear();
+ mockUser = null;
+ mockGetPresence = vi.fn().mockReturnValue(new Promise(() => {})); // pending by default
+ mockMx.getUser.mockImplementation(() => mockUser);
+ mockMx.getPresence.mockImplementation(() => mockGetPresence());
+});
+
+// ------- tests -------
+
+describe('useUserPresence', () => {
+ it('returns undefined when the user is not in the SDK and REST is pending', () => {
+ // mockUser is null; REST never resolves
+ const { result } = renderHook(() => useUserPresence(USER_ID));
+ expect(result.current).toBeUndefined();
+ });
+
+ it('initialises from SDK user when available with a non-zero lastActiveTs', () => {
+ mockUser = makeMockUser({ presence: 'online', lastActiveTs: 5000 });
+ // lastActiveTs > 0 β no REST fallback should be triggered
+ const { result } = renderHook(() => useUserPresence(USER_ID));
+
+ expect(result.current).toEqual({
+ presence: Presence.Online,
+ status: undefined,
+ active: true,
+ lastActiveTs: 5000,
+ });
+ expect(mockMx.getPresence).not.toHaveBeenCalled();
+ });
+
+ it('fires the REST fallback when getLastActiveTs() is 0 (sliding-sync server)', async () => {
+ mockUser = makeMockUser({ presence: 'online', lastActiveTs: 0 });
+ let resolvePresence!: (v: {
+ presence: string;
+ status_msg?: string;
+ currently_active?: boolean;
+ last_active_ago?: number;
+ }) => void;
+ mockGetPresence = vi
+ .fn()
+ .mockReturnValue(new Promise((res) => { resolvePresence = res; }));
+
+ const { result } = renderHook(() => useUserPresence(USER_ID));
+
+ await act(async () => {
+ resolvePresence({
+ presence: 'unavailable',
+ status_msg: 'in a meeting',
+ currently_active: false,
+ last_active_ago: 60_000,
+ });
+ });
+
+ expect(result.current?.presence).toBe(Presence.Unavailable);
+ expect(result.current?.status).toBe('in a meeting');
+ expect(result.current?.active).toBe(false);
+ // lastActiveTs should be approximately Date.now() - 60_000
+ expect(result.current?.lastActiveTs).toBeGreaterThan(0);
+ });
+
+ it('fires the REST fallback when user object does not exist yet', async () => {
+ // user is null β REST should still be requested
+ let resolvePresence!: (v: { presence: string }) => void;
+ mockGetPresence = vi
+ .fn()
+ .mockReturnValue(new Promise((res) => { resolvePresence = res; }));
+
+ const { result } = renderHook(() => useUserPresence(USER_ID));
+
+ expect(mockMx.getPresence).toHaveBeenCalledWith(USER_ID);
+
+ await act(async () => {
+ resolvePresence({ presence: 'online' });
+ });
+
+ expect(result.current?.presence).toBe(Presence.Online);
+ });
+
+ it('does NOT fire REST when userId is an empty string', () => {
+ const { result } = renderHook(() => useUserPresence(''));
+
+ expect(mockMx.getPresence).not.toHaveBeenCalled();
+ expect(result.current).toBeUndefined();
+ });
+
+ it('ignores the REST response after the component unmounts (cancelled flag)', async () => {
+ let resolvePresence!: (v: { presence: string }) => void;
+ mockGetPresence = vi
+ .fn()
+ .mockReturnValue(new Promise((res) => { resolvePresence = res; }));
+
+ const { result, unmount } = renderHook(() => useUserPresence(USER_ID));
+ unmount();
+
+ // Resolve after unmount β cancelled = true, so state should NOT be updated
+ await act(async () => {
+ resolvePresence({ presence: 'online' });
+ });
+
+ expect(result.current).toBeUndefined();
+ });
+
+ it('updates presence when UserEvent.Presence fires on the user object', () => {
+ mockUser = makeMockUser({ presence: 'online', lastActiveTs: 1000 });
+ mockGetPresence = vi.fn().mockReturnValue(new Promise(() => {}));
+
+ const { result } = renderHook(() => useUserPresence(USER_ID));
+
+ // Mutate mock user to simulate a presence change, then fire the registered listener
+ mockUser!.presence = 'unavailable';
+ const handlers = userListeners.get('User.presence') ?? [];
+
+ act(() => {
+ handlers.forEach((h) => h({}, mockUser));
+ });
+
+ expect(result.current?.presence).toBe(Presence.Unavailable);
+ });
+
+ it('resets to undefined when userId changes to a user not in the SDK', () => {
+ mockUser = makeMockUser({ presence: 'online', lastActiveTs: 1000 });
+ mockGetPresence = vi.fn().mockReturnValue(new Promise(() => {}));
+
+ const { result, rerender } = renderHook(({ uid }) => useUserPresence(uid), {
+ initialProps: { uid: USER_ID },
+ });
+
+ expect(result.current).not.toBeUndefined();
+
+ // Switch to unknown user
+ mockUser = null;
+ rerender({ uid: '@bob:test' });
+
+ expect(result.current).toBeUndefined();
+ });
+
+ it('silently ignores a REST error (presence not supported on this server)', async () => {
+ mockGetPresence = vi.fn().mockReturnValue(Promise.reject(new Error('404 Not Found')));
+
+ const { result } = renderHook(() => useUserPresence(USER_ID));
+
+ // Wait for the rejection to be processed
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ // Should still be undefined without throwing
+ expect(result.current).toBeUndefined();
+ });
+});
From 4404e849f75313a8fc9b3ffbd8d89ab342a482f7 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sat, 11 Apr 2026 17:32:00 -0400
Subject: [PATCH 074/253] feat(presence): add presenceMode setting and
Discord-style status picker
Adds a new presenceMode setting ('online' | 'unavailable' | 'offline') that
controls which Matrix presence state is broadcast when sendPresence is enabled.
- Settings: new presenceMode field (default: 'online')
- PresenceFeature: uses presenceMode; Invisible mode keeps sliding sync
extension enabled so the user still receives others' presence events
- AccountSwitcherTab: drive own badge from settings state (fixes stuck-offline
badge on MSC4186 servers that never echo own presence); add Discord-style
Online/Away/Invisible status picker in the account menu
- usePresenceLabel: align label strings with Matrix state names
- DevelopTools: add focusId to Rotate Encryption Sessions tile; fix import order
---
.../settings/developer-tools/DevelopTools.tsx | 84 ++++++++++++++++++-
src/app/hooks/useUserPresence.ts | 6 +-
src/app/pages/client/ClientNonUIFeatures.tsx | 43 ++++++++--
.../client/sidebar/AccountSwitcherTab.tsx | 54 ++++++++++--
src/app/state/settings.ts | 13 +++
5 files changed, 183 insertions(+), 17 deletions(-)
diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx
index c8ffeb12d..a499faf9c 100644
--- a/src/app/features/settings/developer-tools/DevelopTools.tsx
+++ b/src/app/features/settings/developer-tools/DevelopTools.tsx
@@ -1,5 +1,6 @@
import { useCallback, useState } from 'react';
-import { Box, Text, Scroll, Switch, Button } from 'folds';
+import { Box, Text, Scroll, Switch, Button, Spinner, color } from 'folds';
+import { KnownMembership } from 'matrix-js-sdk/lib/types';
import { PageContent } from '$components/page';
import { SequenceCard } from '$components/sequence-card';
import { SettingTile } from '$components/setting-tile';
@@ -9,9 +10,11 @@ import { useMatrixClient } from '$hooks/useMatrixClient';
import { AccountDataEditor, AccountDataSubmitCallback } from '$components/AccountDataEditor';
import { copyToClipboard } from '$utils/dom';
import { SequenceCardStyle } from '$features/settings/styles.css';
+import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback';
import { SettingsSectionPage } from '../SettingsSectionPage';
import { AccountData } from './AccountData';
import { SyncDiagnostics } from './SyncDiagnostics';
+import { ExperimentsPanel } from './ExperimentsPanel';
import { DebugLogViewer } from './DebugLogViewer';
import { SentrySettings } from './SentrySettings';
@@ -25,6 +28,33 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp
const [expand, setExpend] = useState(false);
const [accountDataType, setAccountDataType] = useState();
+ const [rotateState, rotateAllSessions] = useAsyncCallback<
+ { rotated: number; total: number },
+ Error,
+ []
+ >(
+ useCallback(async () => {
+ const crypto = mx.getCrypto();
+ if (!crypto) throw new Error('Crypto module not available');
+
+ const encryptedRooms = mx
+ .getRooms()
+ .filter(
+ (room) =>
+ room.getMyMembership() === KnownMembership.Join && mx.isRoomEncrypted(room.roomId)
+ );
+
+ await Promise.all(encryptedRooms.map((room) => crypto.forceDiscardSession(room.roomId)));
+ const rotated = encryptedRooms.length;
+
+ // Proactively start session creation + key sharing with all devices
+ // (including bridge bots). fire-and-forget per room.
+ encryptedRooms.forEach((room) => crypto.prepareToEncrypt(room));
+
+ return { rotated, total: encryptedRooms.length };
+ }, [mx])
+ );
+
const submitAccountData: AccountDataSubmitCallback = useCallback(
async (type, content) => {
// TODO: remove cast once account data typing is unified.
@@ -109,6 +139,58 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp
)}
{developerTools && }
+ {developerTools && }
+ {developerTools && (
+
+ Encryption
+
+
+ )
+ }
+ >
+
+ {rotateState.status === AsyncStatus.Loading ? 'Rotatingβ¦' : 'Rotate'}
+
+
+ }
+ >
+ {rotateState.status === AsyncStatus.Success && (
+
+ Sessions discarded for {rotateState.data.rotated} of{' '}
+ {rotateState.data.total} encrypted rooms. Key sharing is starting in the
+ background β send a message in an affected room to confirm delivery to
+ bridges.
+
+ )}
+ {rotateState.status === AsyncStatus.Error && (
+
+ {rotateState.error.message}
+
+ )}
+
+
+
+ )}
{developerTools && (
{
export const usePresenceLabel = (): Record =>
useMemo(
() => ({
- [Presence.Online]: 'Active',
- [Presence.Unavailable]: 'Busy',
- [Presence.Offline]: 'Away',
+ [Presence.Online]: 'Online',
+ [Presence.Unavailable]: 'Away',
+ [Presence.Offline]: 'Offline',
}),
[]
);
diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx
index 311e31e5e..5da90e4dd 100644
--- a/src/app/pages/client/ClientNonUIFeatures.tsx
+++ b/src/app/pages/client/ClientNonUIFeatures.tsx
@@ -56,6 +56,7 @@ import { useCallSignaling } from '$hooks/useCallSignaling';
import { getBlobCacheStats } from '$hooks/useBlobCache';
import { lastVisitedRoomIdAtom } from '$state/room/lastRoom';
import { useSettingsSyncEffect } from '$hooks/useSettingsSync';
+import { useInitBookmarks } from '$features/bookmarks/useInitBookmarks';
import { getInboxInvitesPath } from '../pathUtils';
import { BackgroundNotifications } from './BackgroundNotifications';
@@ -644,10 +645,23 @@ function SyncNotificationSettingsWithServiceWorker() {
navigator.serviceWorker.ready.then((reg) => reg.active?.postMessage(msg));
};
+ const postHidden = () => {
+ // pagehide fires more reliably than visibilitychange on iOS Safari PWA
+ // when the user locks the screen or backgrounds the app quickly, making
+ // it less likely that the SW is left with a stale appIsVisible=true.
+ const msg = { type: 'setAppVisible', visible: false };
+ navigator.serviceWorker.controller?.postMessage(msg);
+ navigator.serviceWorker.ready.then((reg) => reg.active?.postMessage(msg));
+ };
+
// Report initial visibility immediately, then track changes.
postVisibility();
document.addEventListener('visibilitychange', postVisibility);
- return () => document.removeEventListener('visibilitychange', postVisibility);
+ window.addEventListener('pagehide', postHidden);
+ return () => {
+ document.removeEventListener('visibilitychange', postVisibility);
+ window.removeEventListener('pagehide', postHidden);
+ };
}, []);
useEffect(() => {
@@ -828,20 +842,27 @@ function HandleDecryptPushEvent() {
function PresenceFeature() {
const mx = useMatrixClient();
const [sendPresence] = useSetting(settingsAtom, 'sendPresence');
+ const [presenceMode] = useSetting(settingsAtom, 'presenceMode');
useEffect(() => {
+ // Effective broadcast state: honour presenceMode when presence is on, otherwise offline.
+ const effectiveState = sendPresence ? (presenceMode ?? 'online') : 'offline';
+ const broadcasting = effectiveState !== 'offline';
+
// Classic sync: set_presence query param on every /sync poll.
// Passing undefined restores the default (online); Offline suppresses broadcasting.
- mx.setSyncPresence(sendPresence ? undefined : SetPresence.Offline);
- // Sliding sync: enable/disable the presence extension on the next poll.
+ mx.setSyncPresence(broadcasting ? undefined : SetPresence.Offline);
+ // Sliding sync: keep the extension enabled so we always receive others' presence.
+ // Only disable it when the master sendPresence toggle is off (full privacy mode).
getSlidingSyncManager(mx)?.setPresenceEnabled(sendPresence);
- // Synapse MSC4186 sliding sync has no presence extension, so setSyncPresence has no
- // effect. Explicitly PUT /presence/{userId}/status so the server knows the user's
- // state β otherwise GET /presence returns stale offline and own presence badge is grey.
- mx.setPresence({ presence: sendPresence ? 'online' : 'offline' }).catch(() => {
+ // Explicitly PUT /presence/{userId}/status so the server knows the exact state:
+ // - MSC4186 servers that have no presence extension see this immediately.
+ // - When 'offline' (Invisible mode), we appear offline to others but still receive
+ // their presence events because the extension is still enabled above.
+ mx.setPresence({ presence: effectiveState }).catch(() => {
// Server doesn't support presence β ignore.
});
- }, [mx, sendPresence]);
+ }, [mx, sendPresence, presenceMode]);
return null;
}
@@ -851,11 +872,17 @@ function SettingsSyncFeature() {
return null;
}
+function BookmarksFeature() {
+ useInitBookmarks();
+ return null;
+}
+
export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
useCallSignaling();
return (
<>
+
diff --git a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx
index 31d4b1a5f..22ee02b34 100644
--- a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx
+++ b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx
@@ -40,7 +40,7 @@ import { getHomePath, getLoginPath, withSearchParam } from '$pages/pathUtils';
import { logoutClient, initClient, stopClient } from '$client/initMatrix';
import { useMatrixClient } from '$hooks/useMatrixClient';
import { useUserProfile } from '$hooks/useUserProfile';
-import { useUserPresence } from '$hooks/useUserPresence';
+import { Presence } from '$hooks/useUserPresence';
import { useMediaAuthentication } from '$hooks/useMediaAuthentication';
import { useSessionProfiles } from '$hooks/useSessionProfiles';
import { useOpenSettings } from '$features/settings';
@@ -50,6 +50,8 @@ import { createLogger } from '$utils/debug';
import { createDebugLogger } from '$utils/debugLogger';
import { useClientConfig } from '$hooks/useClientConfig';
import { UnreadBadge, UnreadBadgeCenter } from '$components/unread-badge';
+import { useSetting } from '$state/hooks/settings';
+import { settingsAtom } from '$state/settings';
const log = createLogger('AccountSwitcherTab');
const debugLog = createDebugLogger('AccountSwitcherTab');
@@ -175,7 +177,14 @@ export function AccountSwitcherTab() {
const myUserId = mx.getUserId() ?? '';
const activeProfile = useUserProfile(myUserId);
- const myPresence = useUserPresence(myUserId);
+ // Own presence badge is driven from settings state rather than the SDK's User object.
+ // The SDK won't echo your own presence back on MSC4186 sliding sync, so reading
+ // user.presence would leave the badge stuck at the SDK default forever.
+ const [sendPresence, setSendPresence] = useSetting(settingsAtom, 'sendPresence');
+ const [presenceMode, setPresenceMode] = useSetting(settingsAtom, 'presenceMode');
+ const myOwnPresence: Presence | undefined = sendPresence
+ ? ((presenceMode ?? 'online') as Presence)
+ : undefined;
const activeAvatarUrl = activeProfile.avatarUrl
? (mxcUrlToHttp(mx, activeProfile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined)
: undefined;
@@ -275,9 +284,7 @@ export function AccountSwitcherTab() {
{(triggerRef) => (
- ) : undefined
+ myOwnPresence ? : undefined
}
>
Add Account
+
+ Status
+
+ {(
+ [
+ { statusLabel: 'Online', presence: Presence.Online },
+ { statusLabel: 'Away', presence: Presence.Unavailable },
+ { statusLabel: 'Invisible', presence: Presence.Offline },
+ ] as const
+ ).map(({ statusLabel, presence }) => {
+ const isSelected = sendPresence && (presenceMode ?? 'online') === presence;
+ return (
+ }
+ after={
+ isSelected ? (
+
+ ) : undefined
+ }
+ onClick={() => {
+ setPresenceMode(presence);
+ // Re-enable presence broadcasting if the master toggle was off
+ if (!sendPresence) setSendPresence(true);
+ }}
+ >
+ {statusLabel}
+
+ );
+ })}
+
);
})}
diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts
index 56b0fba52..4538ae287 100644
--- a/src/app/state/settings.ts
+++ b/src/app/state/settings.ts
@@ -94,7 +94,7 @@ export interface Settings {
// Sable features!
sendPresence: boolean;
/** Which Matrix presence state to broadcast when sendPresence is true. */
- presenceMode: 'online' | 'unavailable' | 'offline';
+ presenceMode: 'online' | 'unavailable' | 'dnd' | 'offline';
mobileGestures: boolean;
rightSwipeAction: RightSwipeAction;
hideMembershipInReadOnly: boolean;
From 1ba25f8d18e5d4c9ead232e7b697dbf1e62c3c13 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sun, 12 Apr 2026 10:53:57 -0400
Subject: [PATCH 076/253] fix(bookmarks): wire useInitBookmarks and fix orphan
tombstoning
- Mount BookmarksFeature in ClientNonUIFeatures so useInitBookmarks()
is called; bookmarkListAtom was never populated, causing the viewer
to always show "No Bookmarks Yet" even with data on the server.
- Fix removeBookmark to tombstone any existing item event regardless
of whether it passes validation, so malformed/orphan bmk_ items
cannot be resurrected by orphan recovery in listBookmarks.
- Add regression tests: tombstoning malformed item, idempotent tombstoning.
---
.../bookmarks/bookmarkRepository.test.ts | 29 +++++++++++++++++++
.../features/bookmarks/bookmarkRepository.ts | 18 ++++++++----
src/app/pages/client/ClientNonUIFeatures.tsx | 8 +++--
3 files changed, 47 insertions(+), 8 deletions(-)
diff --git a/src/app/features/bookmarks/bookmarkRepository.test.ts b/src/app/features/bookmarks/bookmarkRepository.test.ts
index 7b928b50e..3eeaaca65 100644
--- a/src/app/features/bookmarks/bookmarkRepository.test.ts
+++ b/src/app/features/bookmarks/bookmarkRepository.test.ts
@@ -202,6 +202,35 @@ describe('removeBookmark', () => {
await expect(removeBookmark(mx, item.bookmark_id)).resolves.not.toThrow();
});
+ it('tombstones a malformed item event (sets deleted: true even when validation fails)', async () => {
+ // A malformed item exists in account data (e.g. written by a buggy client).
+ // removeBookmark must still tombstone it so orphan recovery does not resurrect it.
+ const badContent = { not_a_valid: 'item' };
+ const mx = makeClient({
+ [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: ['bmk_bad'] }),
+ [bookmarkItemEventType('bmk_bad')]: badContent,
+ });
+
+ await removeBookmark(mx, 'bmk_bad');
+
+ const stored = (mx as any)._store[bookmarkItemEventType('bmk_bad')];
+ expect(stored.deleted).toBe(true);
+ });
+
+ it('tombstones an already-deleted item event (idempotent)', async () => {
+ // If for any reason the same bookmark is removed twice, the tombstone write
+ // should still succeed and the item should remain deleted.
+ const item = makeItem({ deleted: true });
+ const mx = makeClient({
+ [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: [item.bookmark_id] }),
+ [bookmarkItemEventType(item.bookmark_id)]: item,
+ });
+
+ await expect(removeBookmark(mx, item.bookmark_id)).resolves.not.toThrow();
+ const stored = (mx as any)._store[bookmarkItemEventType(item.bookmark_id)] as BookmarkItemContent;
+ expect(stored.deleted).toBe(true);
+ });
+
it('leaves the index unchanged when the ID was not present', async () => {
const mx = makeClient({
[AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: ['bmk_aaaabbbb'] }),
diff --git a/src/app/features/bookmarks/bookmarkRepository.ts b/src/app/features/bookmarks/bookmarkRepository.ts
index e6d712dbd..d1a30370d 100644
--- a/src/app/features/bookmarks/bookmarkRepository.ts
+++ b/src/app/features/bookmarks/bookmarkRepository.ts
@@ -77,12 +77,18 @@ export async function addBookmark(mx: MatrixClient, item: BookmarkItemContent):
* removed.
*/
export async function removeBookmark(mx: MatrixClient, bookmarkId: string): Promise {
- // Soft-delete the item FIRST β mirrors the item-before-index ordering of addBookmark.
- // If writeIndex ran first, orphan recovery in listBookmarks() would transiently resurface the
- // bookmark (item not yet deleted, but ID also not in index) between the two writes.
- const existing = readItem(mx, bookmarkId);
- if (existing) {
- await writeItem(mx, { ...existing, deleted: true });
+ // Tombstone the item event directly β bypass readItem()'s validation so that
+ // malformed or already-deleted items still get marked deleted: true. Without
+ // this, orphan recovery can resurrect items whose deletion write failed halfway.
+ const evt = mx.getAccountData(bookmarkItemEventType(bookmarkId) as any);
+ const raw = evt?.getContent();
+ if (raw != null) {
+ // Write using the bookmarkId param as the canonical type key, not item.bookmark_id,
+ // so malformed items (missing bookmark_id field) still get the right event type.
+ await mx.setAccountData(
+ bookmarkItemEventType(bookmarkId) as any,
+ { ...(raw as object), deleted: true } as any
+ );
}
const index = readIndex(mx);
diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx
index caebe459a..ca0b82b81 100644
--- a/src/app/pages/client/ClientNonUIFeatures.tsx
+++ b/src/app/pages/client/ClientNonUIFeatures.tsx
@@ -514,8 +514,12 @@ function MessageNotifications() {
});
}
- // In-app audio: play when notification sounds are enabled AND this notification is loud.
- if (notificationSound && isLoud) {
+ // In-app audio: play when the app is in the foreground (has focus) and
+ // notification sounds are enabled for this notification type.
+ // Gating on hasFocus() rather than just visibilityState prevents a race
+ // where the page is still 'visible' for a brief window after the user
+ // backgrounds the app on mobile β hasFocus() flips false first.
+ if (notificationSound && isLoud && document.hasFocus()) {
playSound();
}
};
From c178b777b667df4de82d65848902233e0bbc5dee Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Tue, 31 Mar 2026 10:50:22 -0400
Subject: [PATCH 077/253] feat(presence): add presence badges to sidebar and
fix sliding sync presence data
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- DirectDMsList: show PresenceBadge on DM avatar β actual presence for 1:1 DMs,
green dot when any participant is online for group DMs
- AccountSwitcherTab: show PresenceBadge on own account avatar in sidebar
- Fix AvatarPresence placement: move wrapper outside SidebarAvatar (overflow:hidden
was clipping the badge)
- useUserPresence: reset presence state when userId changes; add REST fallback for
sliding sync (Synapse MSC4186 has no presence extension so m.presence events are
never delivered via sync β GET /presence/:userId/status bootstraps the initial state)
- ClientNonUIFeatures: explicitly PUT /presence/:userId/status on visibility change
so the server records online/offline state; setSyncPresence is a no-op on MSC4186
---
src/app/hooks/useUserPresence.ts | 50 +++++++++++++++++--
src/app/pages/client/ClientNonUIFeatures.tsx | 6 +++
.../client/sidebar/AccountSwitcherTab.tsx | 35 ++++++++-----
.../pages/client/sidebar/DirectDMsList.tsx | 34 +++++++++++--
4 files changed, 105 insertions(+), 20 deletions(-)
diff --git a/src/app/hooks/useUserPresence.ts b/src/app/hooks/useUserPresence.ts
index f1b858422..a3b86ef08 100644
--- a/src/app/hooks/useUserPresence.ts
+++ b/src/app/hooks/useUserPresence.ts
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
-import { User, UserEvent, UserEventHandlerMap } from '$types/matrix-sdk';
+import { ClientEvent, MatrixEvent, User, UserEvent, UserEventHandlerMap } from '$types/matrix-sdk';
import { useMatrixClient } from './useMatrixClient';
export enum Presence {
@@ -29,20 +29,62 @@ export const useUserPresence = (userId: string): UserPresence | undefined => {
const [presence, setPresence] = useState(() => (user ? getUserPresence(user) : undefined));
useEffect(() => {
+ setPresence(user ? getUserPresence(user) : undefined);
+
+ let cancelled = false;
+
+ // Sliding sync (Synapse MSC4186) has no presence extension β m.presence events are never
+ // delivered via sync. As a result, User.presence stays at the SDK default and
+ // getLastActiveTs() stays 0. Fall back to a direct REST fetch to bootstrap presence state.
+ if (!user || user.getLastActiveTs() === 0) {
+ mx.getPresence(userId)
+ .then((resp) => {
+ if (cancelled) return;
+ setPresence({
+ presence: resp.presence as Presence,
+ status: resp.status_msg,
+ active: resp.currently_active ?? false,
+ lastActiveTs:
+ resp.last_active_ago != null ? Date.now() - resp.last_active_ago : undefined,
+ });
+ })
+ .catch(() => {
+ // Presence not available on this server (404 or not supported) β keep existing state.
+ });
+ }
+
const updatePresence: UserEventHandlerMap[UserEvent.Presence] = (event, u) => {
- if (u.userId === user?.userId) {
- setPresence(getUserPresence(user));
+ if (u.userId === userId) {
+ setPresence(getUserPresence(u));
}
};
user?.on(UserEvent.Presence, updatePresence);
user?.on(UserEvent.CurrentlyActive, updatePresence);
user?.on(UserEvent.LastPresenceTs, updatePresence);
+
+ // If the User object doesn't exist yet, subscribe at client level as a fallback.
+ // ExtensionPresence emits ClientEvent.Event after creating and updating the User object,
+ // so by the time this fires mx.getUser(userId) is guaranteed to be non-null.
+ let removeClientListener: (() => void) | undefined;
+ if (!user) {
+ const onClientEvent = (event: MatrixEvent) => {
+ if (event.getSender() !== userId || event.getType() !== 'm.presence') return;
+ const u = mx.getUser(userId);
+ if (!u) return;
+ setPresence(getUserPresence(u));
+ };
+ mx.on(ClientEvent.Event, onClientEvent);
+ removeClientListener = () => mx.removeListener(ClientEvent.Event, onClientEvent);
+ }
+
return () => {
+ cancelled = true;
user?.removeListener(UserEvent.Presence, updatePresence);
user?.removeListener(UserEvent.CurrentlyActive, updatePresence);
user?.removeListener(UserEvent.LastPresenceTs, updatePresence);
+ removeClientListener?.();
};
- }, [user]);
+ }, [mx, userId, user]);
return presence;
};
diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx
index 26ac2f431..311e31e5e 100644
--- a/src/app/pages/client/ClientNonUIFeatures.tsx
+++ b/src/app/pages/client/ClientNonUIFeatures.tsx
@@ -835,6 +835,12 @@ function PresenceFeature() {
mx.setSyncPresence(sendPresence ? undefined : SetPresence.Offline);
// Sliding sync: enable/disable the presence extension on the next poll.
getSlidingSyncManager(mx)?.setPresenceEnabled(sendPresence);
+ // Synapse MSC4186 sliding sync has no presence extension, so setSyncPresence has no
+ // effect. Explicitly PUT /presence/{userId}/status so the server knows the user's
+ // state β otherwise GET /presence returns stale offline and own presence badge is grey.
+ mx.setPresence({ presence: sendPresence ? 'online' : 'offline' }).catch(() => {
+ // Server doesn't support presence β ignore.
+ });
}, [mx, sendPresence]);
return null;
diff --git a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx
index 6e6ecc572..31d4b1a5f 100644
--- a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx
+++ b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx
@@ -40,10 +40,12 @@ import { getHomePath, getLoginPath, withSearchParam } from '$pages/pathUtils';
import { logoutClient, initClient, stopClient } from '$client/initMatrix';
import { useMatrixClient } from '$hooks/useMatrixClient';
import { useUserProfile } from '$hooks/useUserProfile';
+import { useUserPresence } from '$hooks/useUserPresence';
import { useMediaAuthentication } from '$hooks/useMediaAuthentication';
import { useSessionProfiles } from '$hooks/useSessionProfiles';
import { useOpenSettings } from '$features/settings';
import { Modal500 } from '$components/Modal500';
+import { AvatarPresence, PresenceBadge } from '$components/presence';
import { createLogger } from '$utils/debug';
import { createDebugLogger } from '$utils/debugLogger';
import { useClientConfig } from '$hooks/useClientConfig';
@@ -173,6 +175,7 @@ export function AccountSwitcherTab() {
const myUserId = mx.getUserId() ?? '';
const activeProfile = useUserProfile(myUserId);
+ const myPresence = useUserPresence(myUserId);
const activeAvatarUrl = activeProfile.avatarUrl
? (mxcUrlToHttp(mx, activeProfile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined)
: undefined;
@@ -270,19 +273,27 @@ export function AccountSwitcherTab() {
{(triggerRef) => (
- 1}
+
+ ) : undefined
+ }
>
- {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 16e829ce5..34a108a60 100644
--- a/src/app/pages/client/sidebar/DirectDMsList.tsx
+++ b/src/app/pages/client/sidebar/DirectDMsList.tsx
@@ -1,4 +1,4 @@
-import { useMemo, useRef, useEffect } from 'react';
+import { useMemo, useRef, useEffect, ReactNode } from 'react';
import * as Sentry from '@sentry/react';
import { useNavigate } from 'react-router-dom';
import { Avatar, Text, Box } from 'folds';
@@ -15,6 +15,8 @@ import {
} from '$components/sidebar';
import { RoomAvatar } from '$components/room-avatar';
import { UserAvatar } from '$components/user-avatar';
+import { AvatarPresence, PresenceBadge } from '$components/presence';
+import { useUserPresence, Presence } from '$hooks/useUserPresence';
import { getDirectRoomAvatarUrl } from '$utils/room';
import { useMediaAuthentication } from '$hooks/useMediaAuthentication';
import { nameInitials } from '$utils/common';
@@ -48,6 +50,28 @@ function DMItem({ room, selected }: DMItemProps) {
// Members are sorted by who last sent messages (most recent first)
const groupMembers = useGroupDMMembers(mx, room, MAX_GROUP_MEMBERS);
+ // Presence hooks β always called unconditionally (React rules of hooks).
+ // For single DMs: guessDMUserId() is synchronous; group slots use '' β undefined.
+ // For group DMs: singleDMUserId is '' β undefined; member slots use groupMembers.
+ const singleDMUserId = isGroupDM ? '' : room.guessDMUserId();
+ const singleDMPresence = useUserPresence(singleDMUserId);
+ const member0Presence = useUserPresence(isGroupDM ? (groupMembers[0]?.userId ?? '') : '');
+ const member1Presence = useUserPresence(isGroupDM ? (groupMembers[1]?.userId ?? '') : '');
+ const member2Presence = useUserPresence(isGroupDM ? (groupMembers[2]?.userId ?? '') : '');
+
+ const groupDMOnline =
+ isGroupDM &&
+ [member0Presence, member1Presence, member2Presence].some(
+ (p) => p && p.lastActiveTs !== 0 && p.presence === Presence.Online
+ );
+
+ let presenceBadge: ReactNode;
+ if (!isGroupDM && singleDMPresence && singleDMPresence.lastActiveTs !== 0) {
+ presenceBadge = ;
+ } else if (isGroupDM && groupDMOnline) {
+ presenceBadge = ;
+ }
+
// Get unread info for badge
const unread = roomToUnread.get(room.roomId);
@@ -132,9 +156,11 @@ function DMItem({ room, selected }: DMItemProps) {
{(triggerRef) => (
-
- {renderAvatar()}
-
+
+
+ {renderAvatar()}
+
+
)}
{unread && (unread.total > 0 || unread.highlight > 0) && (
From ac7528459ad5ebbae9d703f5832fdd19c9700b53 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Tue, 31 Mar 2026 12:17:18 -0400
Subject: [PATCH 078/253] chore: add changeset for presence-sidebar-badges
---
.changeset/presence-sidebar-badges.md | 5 +++++
1 file changed, 5 insertions(+)
create mode 100644 .changeset/presence-sidebar-badges.md
diff --git a/.changeset/presence-sidebar-badges.md b/.changeset/presence-sidebar-badges.md
new file mode 100644
index 000000000..9d0356c48
--- /dev/null
+++ b/.changeset/presence-sidebar-badges.md
@@ -0,0 +1,5 @@
+---
+default: patch
+---
+
+Add presence status badges to sidebar DM list and account switcher
From c7d44d87e6dd786a55ebd26ba7c892c10d5f1ad8 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Thu, 9 Apr 2026 09:21:39 -0400
Subject: [PATCH 079/253] fix(presence): skip REST presence fetch when userId
is empty string
---
src/app/hooks/useUserPresence.ts | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/src/app/hooks/useUserPresence.ts b/src/app/hooks/useUserPresence.ts
index a3b86ef08..52bb99467 100644
--- a/src/app/hooks/useUserPresence.ts
+++ b/src/app/hooks/useUserPresence.ts
@@ -36,7 +36,9 @@ export const useUserPresence = (userId: string): UserPresence | undefined => {
// Sliding sync (Synapse MSC4186) has no presence extension β m.presence events are never
// delivered via sync. As a result, User.presence stays at the SDK default and
// getLastActiveTs() stays 0. Fall back to a direct REST fetch to bootstrap presence state.
- if (!user || user.getLastActiveTs() === 0) {
+ // Guard against empty userId β callers that render a fixed number of hooks (e.g. group DM
+ // slots) pass '' for absent members; firing getPresence('') would be a malformed request.
+ if (userId && (!user || user.getLastActiveTs() === 0)) {
mx.getPresence(userId)
.then((resp) => {
if (cancelled) return;
From ce458fb5f870527263f81a42e8009466b86f0be6 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sat, 11 Apr 2026 12:43:27 -0400
Subject: [PATCH 080/253] test(presence): add useUserPresence unit tests
---
src/app/hooks/useUserPresence.test.tsx | 205 +++++++++++++++++++++++++
1 file changed, 205 insertions(+)
create mode 100644 src/app/hooks/useUserPresence.test.tsx
diff --git a/src/app/hooks/useUserPresence.test.tsx b/src/app/hooks/useUserPresence.test.tsx
new file mode 100644
index 000000000..125629137
--- /dev/null
+++ b/src/app/hooks/useUserPresence.test.tsx
@@ -0,0 +1,205 @@
+import { act, renderHook } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { useUserPresence, Presence } from './useUserPresence';
+
+// ------- mock setup -------
+
+// Each test can override mockUser / mockGetPresence as needed.
+let mockUser: ReturnType | null = null;
+let mockGetPresence: ReturnType;
+
+vi.mock('$hooks/useMatrixClient', () => ({
+ useMatrixClient: () => mockMx,
+}));
+
+// Listeners registered via user.on() β captured so tests can emit events.
+const userListeners = new Map void)[]>();
+
+const makeMockUser = (opts: {
+ presence?: string;
+ presenceStatusMsg?: string | undefined;
+ currentlyActive?: boolean;
+ lastActiveTs?: number;
+} = {}) => ({
+ userId: '@alice:test',
+ presence: opts.presence ?? 'online',
+ presenceStatusMsg: opts.presenceStatusMsg,
+ currentlyActive: opts.currentlyActive ?? true,
+ getLastActiveTs: vi.fn().mockReturnValue(opts.lastActiveTs ?? 1000),
+ on: vi.fn().mockImplementation((event: string, handler: (...args: unknown[]) => void) => {
+ const list = userListeners.get(event) ?? [];
+ list.push(handler);
+ userListeners.set(event, list);
+ }),
+ removeListener: vi.fn(),
+});
+
+const mockMx = {
+ getUser: vi.fn((): ReturnType | null => mockUser),
+ getPresence: vi.fn(
+ (): Promise<{
+ presence: string;
+ status_msg?: string;
+ currently_active?: boolean;
+ last_active_ago?: number | null;
+ }> =>
+ mockGetPresence()
+ ),
+ on: vi.fn(),
+ removeListener: vi.fn(),
+};
+
+const USER_ID = '@alice:test';
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ userListeners.clear();
+ mockUser = null;
+ mockGetPresence = vi.fn().mockReturnValue(new Promise(() => {})); // pending by default
+ mockMx.getUser.mockImplementation(() => mockUser);
+ mockMx.getPresence.mockImplementation(() => mockGetPresence());
+});
+
+// ------- tests -------
+
+describe('useUserPresence', () => {
+ it('returns undefined when the user is not in the SDK and REST is pending', () => {
+ // mockUser is null; REST never resolves
+ const { result } = renderHook(() => useUserPresence(USER_ID));
+ expect(result.current).toBeUndefined();
+ });
+
+ it('initialises from SDK user when available with a non-zero lastActiveTs', () => {
+ mockUser = makeMockUser({ presence: 'online', lastActiveTs: 5000 });
+ // lastActiveTs > 0 β no REST fallback should be triggered
+ const { result } = renderHook(() => useUserPresence(USER_ID));
+
+ expect(result.current).toEqual({
+ presence: Presence.Online,
+ status: undefined,
+ active: true,
+ lastActiveTs: 5000,
+ });
+ expect(mockMx.getPresence).not.toHaveBeenCalled();
+ });
+
+ it('fires the REST fallback when getLastActiveTs() is 0 (sliding-sync server)', async () => {
+ mockUser = makeMockUser({ presence: 'online', lastActiveTs: 0 });
+ let resolvePresence!: (v: {
+ presence: string;
+ status_msg?: string;
+ currently_active?: boolean;
+ last_active_ago?: number;
+ }) => void;
+ mockGetPresence = vi
+ .fn()
+ .mockReturnValue(new Promise((res) => { resolvePresence = res; }));
+
+ const { result } = renderHook(() => useUserPresence(USER_ID));
+
+ await act(async () => {
+ resolvePresence({
+ presence: 'unavailable',
+ status_msg: 'in a meeting',
+ currently_active: false,
+ last_active_ago: 60_000,
+ });
+ });
+
+ expect(result.current?.presence).toBe(Presence.Unavailable);
+ expect(result.current?.status).toBe('in a meeting');
+ expect(result.current?.active).toBe(false);
+ // lastActiveTs should be approximately Date.now() - 60_000
+ expect(result.current?.lastActiveTs).toBeGreaterThan(0);
+ });
+
+ it('fires the REST fallback when user object does not exist yet', async () => {
+ // user is null β REST should still be requested
+ let resolvePresence!: (v: { presence: string }) => void;
+ mockGetPresence = vi
+ .fn()
+ .mockReturnValue(new Promise((res) => { resolvePresence = res; }));
+
+ const { result } = renderHook(() => useUserPresence(USER_ID));
+
+ expect(mockMx.getPresence).toHaveBeenCalledWith(USER_ID);
+
+ await act(async () => {
+ resolvePresence({ presence: 'online' });
+ });
+
+ expect(result.current?.presence).toBe(Presence.Online);
+ });
+
+ it('does NOT fire REST when userId is an empty string', () => {
+ const { result } = renderHook(() => useUserPresence(''));
+
+ expect(mockMx.getPresence).not.toHaveBeenCalled();
+ expect(result.current).toBeUndefined();
+ });
+
+ it('ignores the REST response after the component unmounts (cancelled flag)', async () => {
+ let resolvePresence!: (v: { presence: string }) => void;
+ mockGetPresence = vi
+ .fn()
+ .mockReturnValue(new Promise((res) => { resolvePresence = res; }));
+
+ const { result, unmount } = renderHook(() => useUserPresence(USER_ID));
+ unmount();
+
+ // Resolve after unmount β cancelled = true, so state should NOT be updated
+ await act(async () => {
+ resolvePresence({ presence: 'online' });
+ });
+
+ expect(result.current).toBeUndefined();
+ });
+
+ it('updates presence when UserEvent.Presence fires on the user object', () => {
+ mockUser = makeMockUser({ presence: 'online', lastActiveTs: 1000 });
+ mockGetPresence = vi.fn().mockReturnValue(new Promise(() => {}));
+
+ const { result } = renderHook(() => useUserPresence(USER_ID));
+
+ // Mutate mock user to simulate a presence change, then fire the registered listener
+ mockUser!.presence = 'unavailable';
+ const handlers = userListeners.get('User.presence') ?? [];
+
+ act(() => {
+ handlers.forEach((h) => h({}, mockUser));
+ });
+
+ expect(result.current?.presence).toBe(Presence.Unavailable);
+ });
+
+ it('resets to undefined when userId changes to a user not in the SDK', () => {
+ mockUser = makeMockUser({ presence: 'online', lastActiveTs: 1000 });
+ mockGetPresence = vi.fn().mockReturnValue(new Promise(() => {}));
+
+ const { result, rerender } = renderHook(({ uid }) => useUserPresence(uid), {
+ initialProps: { uid: USER_ID },
+ });
+
+ expect(result.current).not.toBeUndefined();
+
+ // Switch to unknown user
+ mockUser = null;
+ rerender({ uid: '@bob:test' });
+
+ expect(result.current).toBeUndefined();
+ });
+
+ it('silently ignores a REST error (presence not supported on this server)', async () => {
+ mockGetPresence = vi.fn().mockReturnValue(Promise.reject(new Error('404 Not Found')));
+
+ const { result } = renderHook(() => useUserPresence(USER_ID));
+
+ // Wait for the rejection to be processed
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ // Should still be undefined without throwing
+ expect(result.current).toBeUndefined();
+ });
+});
From f7c7fee75eb78cf83e2bc55eaadac21f0518757a Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sat, 11 Apr 2026 17:32:00 -0400
Subject: [PATCH 081/253] feat(presence): add presenceMode setting and
Discord-style status picker
Adds a new presenceMode setting ('online' | 'unavailable' | 'offline') that
controls which Matrix presence state is broadcast when sendPresence is enabled.
- Settings: new presenceMode field (default: 'online')
- PresenceFeature: uses presenceMode; Invisible mode keeps sliding sync
extension enabled so the user still receives others' presence events
- AccountSwitcherTab: drive own badge from settings state (fixes stuck-offline
badge on MSC4186 servers that never echo own presence); add Discord-style
Online/Away/Invisible status picker in the account menu
- usePresenceLabel: align label strings with Matrix state names
- DevelopTools: add focusId to Rotate Encryption Sessions tile; fix import order
---
.../settings/developer-tools/DevelopTools.tsx | 84 ++++++++++++++++++-
src/app/hooks/useUserPresence.ts | 6 +-
src/app/pages/client/ClientNonUIFeatures.tsx | 43 ++++++++--
.../client/sidebar/AccountSwitcherTab.tsx | 54 ++++++++++--
src/app/state/settings.ts | 9 ++
5 files changed, 179 insertions(+), 17 deletions(-)
diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx
index c8ffeb12d..a499faf9c 100644
--- a/src/app/features/settings/developer-tools/DevelopTools.tsx
+++ b/src/app/features/settings/developer-tools/DevelopTools.tsx
@@ -1,5 +1,6 @@
import { useCallback, useState } from 'react';
-import { Box, Text, Scroll, Switch, Button } from 'folds';
+import { Box, Text, Scroll, Switch, Button, Spinner, color } from 'folds';
+import { KnownMembership } from 'matrix-js-sdk/lib/types';
import { PageContent } from '$components/page';
import { SequenceCard } from '$components/sequence-card';
import { SettingTile } from '$components/setting-tile';
@@ -9,9 +10,11 @@ import { useMatrixClient } from '$hooks/useMatrixClient';
import { AccountDataEditor, AccountDataSubmitCallback } from '$components/AccountDataEditor';
import { copyToClipboard } from '$utils/dom';
import { SequenceCardStyle } from '$features/settings/styles.css';
+import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback';
import { SettingsSectionPage } from '../SettingsSectionPage';
import { AccountData } from './AccountData';
import { SyncDiagnostics } from './SyncDiagnostics';
+import { ExperimentsPanel } from './ExperimentsPanel';
import { DebugLogViewer } from './DebugLogViewer';
import { SentrySettings } from './SentrySettings';
@@ -25,6 +28,33 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp
const [expand, setExpend] = useState(false);
const [accountDataType, setAccountDataType] = useState();
+ const [rotateState, rotateAllSessions] = useAsyncCallback<
+ { rotated: number; total: number },
+ Error,
+ []
+ >(
+ useCallback(async () => {
+ const crypto = mx.getCrypto();
+ if (!crypto) throw new Error('Crypto module not available');
+
+ const encryptedRooms = mx
+ .getRooms()
+ .filter(
+ (room) =>
+ room.getMyMembership() === KnownMembership.Join && mx.isRoomEncrypted(room.roomId)
+ );
+
+ await Promise.all(encryptedRooms.map((room) => crypto.forceDiscardSession(room.roomId)));
+ const rotated = encryptedRooms.length;
+
+ // Proactively start session creation + key sharing with all devices
+ // (including bridge bots). fire-and-forget per room.
+ encryptedRooms.forEach((room) => crypto.prepareToEncrypt(room));
+
+ return { rotated, total: encryptedRooms.length };
+ }, [mx])
+ );
+
const submitAccountData: AccountDataSubmitCallback = useCallback(
async (type, content) => {
// TODO: remove cast once account data typing is unified.
@@ -109,6 +139,58 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp
)}
{developerTools && }
+ {developerTools && }
+ {developerTools && (
+
+ Encryption
+
+
+ )
+ }
+ >
+
+ {rotateState.status === AsyncStatus.Loading ? 'Rotatingβ¦' : 'Rotate'}
+
+
+ }
+ >
+ {rotateState.status === AsyncStatus.Success && (
+
+ Sessions discarded for {rotateState.data.rotated} of{' '}
+ {rotateState.data.total} encrypted rooms. Key sharing is starting in the
+ background β send a message in an affected room to confirm delivery to
+ bridges.
+
+ )}
+ {rotateState.status === AsyncStatus.Error && (
+
+ {rotateState.error.message}
+
+ )}
+
+
+
+ )}
{developerTools && (
{
export const usePresenceLabel = (): Record =>
useMemo(
() => ({
- [Presence.Online]: 'Active',
- [Presence.Unavailable]: 'Busy',
- [Presence.Offline]: 'Away',
+ [Presence.Online]: 'Online',
+ [Presence.Unavailable]: 'Away',
+ [Presence.Offline]: 'Offline',
}),
[]
);
diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx
index 311e31e5e..5da90e4dd 100644
--- a/src/app/pages/client/ClientNonUIFeatures.tsx
+++ b/src/app/pages/client/ClientNonUIFeatures.tsx
@@ -56,6 +56,7 @@ import { useCallSignaling } from '$hooks/useCallSignaling';
import { getBlobCacheStats } from '$hooks/useBlobCache';
import { lastVisitedRoomIdAtom } from '$state/room/lastRoom';
import { useSettingsSyncEffect } from '$hooks/useSettingsSync';
+import { useInitBookmarks } from '$features/bookmarks/useInitBookmarks';
import { getInboxInvitesPath } from '../pathUtils';
import { BackgroundNotifications } from './BackgroundNotifications';
@@ -644,10 +645,23 @@ function SyncNotificationSettingsWithServiceWorker() {
navigator.serviceWorker.ready.then((reg) => reg.active?.postMessage(msg));
};
+ const postHidden = () => {
+ // pagehide fires more reliably than visibilitychange on iOS Safari PWA
+ // when the user locks the screen or backgrounds the app quickly, making
+ // it less likely that the SW is left with a stale appIsVisible=true.
+ const msg = { type: 'setAppVisible', visible: false };
+ navigator.serviceWorker.controller?.postMessage(msg);
+ navigator.serviceWorker.ready.then((reg) => reg.active?.postMessage(msg));
+ };
+
// Report initial visibility immediately, then track changes.
postVisibility();
document.addEventListener('visibilitychange', postVisibility);
- return () => document.removeEventListener('visibilitychange', postVisibility);
+ window.addEventListener('pagehide', postHidden);
+ return () => {
+ document.removeEventListener('visibilitychange', postVisibility);
+ window.removeEventListener('pagehide', postHidden);
+ };
}, []);
useEffect(() => {
@@ -828,20 +842,27 @@ function HandleDecryptPushEvent() {
function PresenceFeature() {
const mx = useMatrixClient();
const [sendPresence] = useSetting(settingsAtom, 'sendPresence');
+ const [presenceMode] = useSetting(settingsAtom, 'presenceMode');
useEffect(() => {
+ // Effective broadcast state: honour presenceMode when presence is on, otherwise offline.
+ const effectiveState = sendPresence ? (presenceMode ?? 'online') : 'offline';
+ const broadcasting = effectiveState !== 'offline';
+
// Classic sync: set_presence query param on every /sync poll.
// Passing undefined restores the default (online); Offline suppresses broadcasting.
- mx.setSyncPresence(sendPresence ? undefined : SetPresence.Offline);
- // Sliding sync: enable/disable the presence extension on the next poll.
+ mx.setSyncPresence(broadcasting ? undefined : SetPresence.Offline);
+ // Sliding sync: keep the extension enabled so we always receive others' presence.
+ // Only disable it when the master sendPresence toggle is off (full privacy mode).
getSlidingSyncManager(mx)?.setPresenceEnabled(sendPresence);
- // Synapse MSC4186 sliding sync has no presence extension, so setSyncPresence has no
- // effect. Explicitly PUT /presence/{userId}/status so the server knows the user's
- // state β otherwise GET /presence returns stale offline and own presence badge is grey.
- mx.setPresence({ presence: sendPresence ? 'online' : 'offline' }).catch(() => {
+ // Explicitly PUT /presence/{userId}/status so the server knows the exact state:
+ // - MSC4186 servers that have no presence extension see this immediately.
+ // - When 'offline' (Invisible mode), we appear offline to others but still receive
+ // their presence events because the extension is still enabled above.
+ mx.setPresence({ presence: effectiveState }).catch(() => {
// Server doesn't support presence β ignore.
});
- }, [mx, sendPresence]);
+ }, [mx, sendPresence, presenceMode]);
return null;
}
@@ -851,11 +872,17 @@ function SettingsSyncFeature() {
return null;
}
+function BookmarksFeature() {
+ useInitBookmarks();
+ return null;
+}
+
export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
useCallSignaling();
return (
<>
+
diff --git a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx
index 31d4b1a5f..22ee02b34 100644
--- a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx
+++ b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx
@@ -40,7 +40,7 @@ import { getHomePath, getLoginPath, withSearchParam } from '$pages/pathUtils';
import { logoutClient, initClient, stopClient } from '$client/initMatrix';
import { useMatrixClient } from '$hooks/useMatrixClient';
import { useUserProfile } from '$hooks/useUserProfile';
-import { useUserPresence } from '$hooks/useUserPresence';
+import { Presence } from '$hooks/useUserPresence';
import { useMediaAuthentication } from '$hooks/useMediaAuthentication';
import { useSessionProfiles } from '$hooks/useSessionProfiles';
import { useOpenSettings } from '$features/settings';
@@ -50,6 +50,8 @@ import { createLogger } from '$utils/debug';
import { createDebugLogger } from '$utils/debugLogger';
import { useClientConfig } from '$hooks/useClientConfig';
import { UnreadBadge, UnreadBadgeCenter } from '$components/unread-badge';
+import { useSetting } from '$state/hooks/settings';
+import { settingsAtom } from '$state/settings';
const log = createLogger('AccountSwitcherTab');
const debugLog = createDebugLogger('AccountSwitcherTab');
@@ -175,7 +177,14 @@ export function AccountSwitcherTab() {
const myUserId = mx.getUserId() ?? '';
const activeProfile = useUserProfile(myUserId);
- const myPresence = useUserPresence(myUserId);
+ // Own presence badge is driven from settings state rather than the SDK's User object.
+ // The SDK won't echo your own presence back on MSC4186 sliding sync, so reading
+ // user.presence would leave the badge stuck at the SDK default forever.
+ const [sendPresence, setSendPresence] = useSetting(settingsAtom, 'sendPresence');
+ const [presenceMode, setPresenceMode] = useSetting(settingsAtom, 'presenceMode');
+ const myOwnPresence: Presence | undefined = sendPresence
+ ? ((presenceMode ?? 'online') as Presence)
+ : undefined;
const activeAvatarUrl = activeProfile.avatarUrl
? (mxcUrlToHttp(mx, activeProfile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined)
: undefined;
@@ -275,9 +284,7 @@ export function AccountSwitcherTab() {
{(triggerRef) => (
- ) : undefined
+ myOwnPresence ? : undefined
}
>
Add Account
+
+ Status
+
+ {(
+ [
+ { statusLabel: 'Online', presence: Presence.Online },
+ { statusLabel: 'Away', presence: Presence.Unavailable },
+ { statusLabel: 'Invisible', presence: Presence.Offline },
+ ] as const
+ ).map(({ statusLabel, presence }) => {
+ const isSelected = sendPresence && (presenceMode ?? 'online') === presence;
+ return (
+ }
+ after={
+ isSelected ? (
+
+ ) : undefined
+ }
+ onClick={() => {
+ setPresenceMode(presence);
+ // Re-enable presence broadcasting if the master toggle was off
+ if (!sendPresence) setSendPresence(true);
+ }}
+ >
+ {statusLabel}
+
+ );
+ })}
+
Date: Sat, 11 Apr 2026 18:50:55 -0400
Subject: [PATCH 082/253] feat(presence): Discord-style presence picker with
Idle, DND, and Invisible options
---
src/app/hooks/useAppVisibility.ts | 222 ++++++++++++++++--
src/app/hooks/useUserPresence.ts | 2 +-
src/app/pages/client/ClientNonUIFeatures.tsx | 9 +-
.../client/sidebar/AccountSwitcherTab.tsx | 58 +++--
src/app/state/settings.ts | 2 +-
5 files changed, 251 insertions(+), 42 deletions(-)
diff --git a/src/app/hooks/useAppVisibility.ts b/src/app/hooks/useAppVisibility.ts
index 7fd5f2325..144f132a9 100644
--- a/src/app/hooks/useAppVisibility.ts
+++ b/src/app/hooks/useAppVisibility.ts
@@ -1,22 +1,102 @@
-import { useEffect } from 'react';
+import { useCallback, useEffect, useRef } from 'react';
import { MatrixClient } from '$types/matrix-sdk';
-import { useAtom } from 'jotai';
-import { togglePusher } from '../features/settings/notifications/PushNotifications';
+import { Session } from '$state/sessions';
import { appEvents } from '../utils/appEvents';
-import { useClientConfig } from './useClientConfig';
-import { useSetting } from '../state/hooks/settings';
-import { settingsAtom } from '../state/settings';
-import { pushSubscriptionAtom } from '../state/pushSubscription';
-import { mobileOrTablet } from '../utils/user-agent';
+import { useClientConfig, useExperimentVariant } from './useClientConfig';
import { createDebugLogger } from '../utils/debugLogger';
+import { pushSessionToSW } from '../../sw-session';
const debugLog = createDebugLogger('AppVisibility');
-export function useAppVisibility(mx: MatrixClient | undefined) {
+const DEFAULT_FOREGROUND_DEBOUNCE_MS = 1500;
+const DEFAULT_HEARTBEAT_INTERVAL_MS = 10 * 60 * 1000;
+const DEFAULT_RESUME_HEARTBEAT_SUPPRESS_MS = 60 * 1000;
+const DEFAULT_HEARTBEAT_MAX_BACKOFF_MS = 30 * 60 * 1000;
+
+export function useAppVisibility(mx: MatrixClient | undefined, activeSession?: Session) {
const clientConfig = useClientConfig();
- const [usePushNotifications] = useSetting(settingsAtom, 'usePushNotifications');
- const pushSubAtom = useAtom(pushSubscriptionAtom);
- const isMobile = mobileOrTablet();
+
+ const sessionSyncConfig = clientConfig.sessionSync;
+ const sessionSyncVariant = useExperimentVariant('sessionSyncStrategy', activeSession?.userId);
+
+ // Derive phase flags from experiment variant; fall back to direct config when not in experiment.
+ const inSessionSync = sessionSyncVariant.inExperiment;
+ const syncVariant = sessionSyncVariant.variant;
+ const phase1ForegroundResync = inSessionSync
+ ? syncVariant === 'session-sync-heartbeat' || syncVariant === 'session-sync-adaptive'
+ : sessionSyncConfig?.phase1ForegroundResync === true;
+ const phase2VisibleHeartbeat = inSessionSync
+ ? syncVariant === 'session-sync-heartbeat' || syncVariant === 'session-sync-adaptive'
+ : sessionSyncConfig?.phase2VisibleHeartbeat === true;
+ const phase3AdaptiveBackoffJitter = inSessionSync
+ ? syncVariant === 'session-sync-adaptive'
+ : sessionSyncConfig?.phase3AdaptiveBackoffJitter === true;
+
+ const foregroundDebounceMs = Math.max(
+ 0,
+ sessionSyncConfig?.foregroundDebounceMs ?? DEFAULT_FOREGROUND_DEBOUNCE_MS
+ );
+ const heartbeatIntervalMs = Math.max(
+ 1000,
+ sessionSyncConfig?.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS
+ );
+ const resumeHeartbeatSuppressMs = Math.max(
+ 0,
+ sessionSyncConfig?.resumeHeartbeatSuppressMs ?? DEFAULT_RESUME_HEARTBEAT_SUPPRESS_MS
+ );
+ const heartbeatMaxBackoffMs = Math.max(
+ heartbeatIntervalMs,
+ sessionSyncConfig?.heartbeatMaxBackoffMs ?? DEFAULT_HEARTBEAT_MAX_BACKOFF_MS
+ );
+
+ const lastForegroundPushAtRef = useRef(0);
+ const suppressHeartbeatUntilRef = useRef(0);
+ const heartbeatFailuresRef = useRef(0);
+
+ const pushSessionNow = useCallback(
+ (reason: 'foreground' | 'focus' | 'heartbeat'): 'sent' | 'skipped' => {
+ const baseUrl = activeSession?.baseUrl;
+ const accessToken = activeSession?.accessToken;
+ const userId = activeSession?.userId;
+ const canPush =
+ !!mx &&
+ typeof baseUrl === 'string' &&
+ typeof accessToken === 'string' &&
+ typeof userId === 'string' &&
+ 'serviceWorker' in navigator &&
+ !!navigator.serviceWorker.controller;
+
+ if (!canPush) {
+ debugLog.warn('network', 'Skipped SW session sync', {
+ reason,
+ hasClient: !!mx,
+ hasBaseUrl: !!baseUrl,
+ hasAccessToken: !!accessToken,
+ hasUserId: !!userId,
+ hasSwController: !!navigator.serviceWorker?.controller,
+ });
+ return 'skipped';
+ }
+
+ pushSessionToSW(baseUrl, accessToken, userId);
+ debugLog.info('network', 'Pushed session to SW', {
+ reason,
+ phase1ForegroundResync,
+ phase2VisibleHeartbeat,
+ phase3AdaptiveBackoffJitter,
+ });
+ return 'sent';
+ },
+ [
+ activeSession?.accessToken,
+ activeSession?.baseUrl,
+ activeSession?.userId,
+ mx,
+ phase1ForegroundResync,
+ phase2VisibleHeartbeat,
+ phase3AdaptiveBackoffJitter,
+ ]
+ );
useEffect(() => {
const handleVisibilityChange = () => {
@@ -29,27 +109,129 @@ export function useAppVisibility(mx: MatrixClient | undefined) {
appEvents.onVisibilityChange?.(isVisible);
if (!isVisible) {
appEvents.onVisibilityHidden?.();
+ return;
+ }
+
+ // Always kick the sync loop on foreground regardless of phase flags β
+ // the SDK may be sitting in exponential backoff after iOS froze the tab.
+ mx?.retryImmediately();
+
+ if (!phase1ForegroundResync) return;
+
+ const now = Date.now();
+ if (now - lastForegroundPushAtRef.current < foregroundDebounceMs) return;
+ lastForegroundPushAtRef.current = now;
+
+ if (pushSessionNow('foreground') === 'sent') {
+ // A successful push proves the SW controller is up β reset adaptive backoff
+ // so the heartbeat returns to its normal interval immediately rather than
+ // staying on an inflated delay left over from a prior SW absence period.
+ if (phase3AdaptiveBackoffJitter) heartbeatFailuresRef.current = 0;
+ if (phase3AdaptiveBackoffJitter && phase2VisibleHeartbeat) {
+ suppressHeartbeatUntilRef.current = now + resumeHeartbeatSuppressMs;
+ }
+ }
+ };
+
+ const handleFocus = () => {
+ if (document.visibilityState !== 'visible') return;
+
+ // Always kick the sync loop on focus for the same reason as above.
+ mx?.retryImmediately();
+
+ if (!phase1ForegroundResync) return;
+
+ const now = Date.now();
+ if (now - lastForegroundPushAtRef.current < foregroundDebounceMs) return;
+ lastForegroundPushAtRef.current = now;
+
+ if (pushSessionNow('focus') === 'sent') {
+ if (phase3AdaptiveBackoffJitter) heartbeatFailuresRef.current = 0;
+ if (phase3AdaptiveBackoffJitter && phase2VisibleHeartbeat) {
+ suppressHeartbeatUntilRef.current = now + resumeHeartbeatSuppressMs;
+ }
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
+ window.addEventListener('focus', handleFocus);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
+ window.removeEventListener('focus', handleFocus);
};
- }, []);
+ }, [
+ foregroundDebounceMs,
+ mx,
+ phase1ForegroundResync,
+ phase2VisibleHeartbeat,
+ phase3AdaptiveBackoffJitter,
+ pushSessionNow,
+ resumeHeartbeatSuppressMs,
+ ]);
useEffect(() => {
- if (!mx) return;
+ if (!phase2VisibleHeartbeat) return undefined;
+
+ // Reset adaptive backoff/suppression so a config or session change starts fresh.
+ heartbeatFailuresRef.current = 0;
+ suppressHeartbeatUntilRef.current = 0;
+
+ let timeoutId: number | undefined;
+
+ const getDelayMs = (): number => {
+ let delay = heartbeatIntervalMs;
+
+ if (phase3AdaptiveBackoffJitter) {
+ const failures = heartbeatFailuresRef.current;
+ const backoffFactor = Math.min(2 ** failures, heartbeatMaxBackoffMs / heartbeatIntervalMs);
+ delay = Math.min(heartbeatMaxBackoffMs, Math.round(heartbeatIntervalMs * backoffFactor));
+
+ // Add +-20% jitter to avoid synchronized heartbeat spikes across many clients.
+ const jitter = 0.8 + Math.random() * 0.4;
+ delay = Math.max(1000, Math.round(delay * jitter));
+ }
+
+ return delay;
+ };
+
+ const tick = () => {
+ const now = Date.now();
- const handleVisibilityForNotifications = (isVisible: boolean) => {
- togglePusher(mx, clientConfig, isVisible, usePushNotifications, pushSubAtom, isMobile);
+ if (document.visibilityState !== 'visible' || !navigator.onLine) {
+ timeoutId = window.setTimeout(tick, getDelayMs());
+ return;
+ }
+
+ if (phase3AdaptiveBackoffJitter && now < suppressHeartbeatUntilRef.current) {
+ timeoutId = window.setTimeout(tick, getDelayMs());
+ return;
+ }
+
+ const result = pushSessionNow('heartbeat');
+ if (phase3AdaptiveBackoffJitter) {
+ if (result === 'sent') {
+ heartbeatFailuresRef.current = 0;
+ } else {
+ // 'skipped' means prerequisites (SW controller, session) aren't ready.
+ // Treat as a transient failure so backoff grows until the SW is ready.
+ heartbeatFailuresRef.current += 1;
+ }
+ }
+
+ timeoutId = window.setTimeout(tick, getDelayMs());
};
- appEvents.onVisibilityChange = handleVisibilityForNotifications;
- // eslint-disable-next-line consistent-return
+ timeoutId = window.setTimeout(tick, getDelayMs());
+
return () => {
- appEvents.onVisibilityChange = null;
+ if (timeoutId !== undefined) window.clearTimeout(timeoutId);
};
- }, [mx, clientConfig, usePushNotifications, pushSubAtom, isMobile]);
+ }, [
+ heartbeatIntervalMs,
+ heartbeatMaxBackoffMs,
+ phase2VisibleHeartbeat,
+ phase3AdaptiveBackoffJitter,
+ pushSessionNow,
+ ]);
}
diff --git a/src/app/hooks/useUserPresence.ts b/src/app/hooks/useUserPresence.ts
index 7e0f0e78b..8c9b85959 100644
--- a/src/app/hooks/useUserPresence.ts
+++ b/src/app/hooks/useUserPresence.ts
@@ -95,7 +95,7 @@ export const usePresenceLabel = (): Record =>
useMemo(
() => ({
[Presence.Online]: 'Online',
- [Presence.Unavailable]: 'Away',
+ [Presence.Unavailable]: 'Idle',
[Presence.Offline]: 'Offline',
}),
[]
diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx
index 5da90e4dd..260f1dc28 100644
--- a/src/app/pages/client/ClientNonUIFeatures.tsx
+++ b/src/app/pages/client/ClientNonUIFeatures.tsx
@@ -846,7 +846,9 @@ function PresenceFeature() {
useEffect(() => {
// Effective broadcast state: honour presenceMode when presence is on, otherwise offline.
- const effectiveState = sendPresence ? (presenceMode ?? 'online') : 'offline';
+ // DND broadcasts as online (you're active but don't want to be disturbed) with a status_msg.
+ const activePresence = presenceMode === 'dnd' ? 'online' : (presenceMode ?? 'online');
+ const effectiveState = sendPresence ? activePresence : 'offline';
const broadcasting = effectiveState !== 'offline';
// Classic sync: set_presence query param on every /sync poll.
@@ -859,7 +861,10 @@ function PresenceFeature() {
// - MSC4186 servers that have no presence extension see this immediately.
// - When 'offline' (Invisible mode), we appear offline to others but still receive
// their presence events because the extension is still enabled above.
- mx.setPresence({ presence: effectiveState }).catch(() => {
+ mx.setPresence({
+ presence: effectiveState,
+ status_msg: sendPresence && presenceMode === 'dnd' ? 'dnd' : '',
+ }).catch(() => {
// Server doesn't support presence β ignore.
});
}, [mx, sendPresence, presenceMode]);
diff --git a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx
index 22ee02b34..395edcfe7 100644
--- a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx
+++ b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx
@@ -1,5 +1,6 @@
-import { MouseEvent, MouseEventHandler, useCallback, useState } from 'react';
+import { MouseEvent, MouseEventHandler, ReactNode, useCallback, useState } from 'react';
import {
+ Badge,
Box,
Button,
Dialog,
@@ -182,9 +183,16 @@ export function AccountSwitcherTab() {
// user.presence would leave the badge stuck at the SDK default forever.
const [sendPresence, setSendPresence] = useSetting(settingsAtom, 'sendPresence');
const [presenceMode, setPresenceMode] = useSetting(settingsAtom, 'presenceMode');
- const myOwnPresence: Presence | undefined = sendPresence
- ? ((presenceMode ?? 'online') as Presence)
- : undefined;
+ let myOwnPresenceBadge: ReactNode;
+ if (sendPresence) {
+ myOwnPresenceBadge =
+ presenceMode === 'dnd' ? (
+ // DND: solid red badge (broadcasts as online with status_msg 'dnd')
+
+ ) : (
+
+ );
+ }
const activeAvatarUrl = activeProfile.avatarUrl
? (mxcUrlToHttp(mx, activeProfile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined)
: undefined;
@@ -282,11 +290,7 @@ export function AccountSwitcherTab() {
{(triggerRef) => (
- : undefined
- }
- >
+
{(
[
- { statusLabel: 'Online', presence: Presence.Online },
- { statusLabel: 'Away', presence: Presence.Unavailable },
- { statusLabel: 'Invisible', presence: Presence.Offline },
+ { label: 'Online', desc: undefined, mode: 'online' as const },
+ { label: 'Idle', desc: undefined, mode: 'unavailable' as const },
+ { label: 'Do Not Disturb', desc: undefined, mode: 'dnd' as const },
+ {
+ label: 'Invisible',
+ desc: 'You will appear offline',
+ mode: 'offline' as const,
+ },
] as const
- ).map(({ statusLabel, presence }) => {
- const isSelected = sendPresence && (presenceMode ?? 'online') === presence;
+ ).map(({ label: statusLabel, desc, mode }) => {
+ const isSelected = sendPresence && (presenceMode ?? 'online') === mode;
+ const badge =
+ mode === 'dnd' ? (
+
+ ) : (
+
+ );
return (
}
+ before={badge}
after={
isSelected ? (
{
- setPresenceMode(presence);
+ setPresenceMode(mode);
// Re-enable presence broadcasting if the master toggle was off
if (!sendPresence) setSendPresence(true);
}}
>
- {statusLabel}
+
+ {statusLabel}
+ {desc && (
+
+ {desc}
+
+ )}
+
);
})}
diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts
index 56b0fba52..4538ae287 100644
--- a/src/app/state/settings.ts
+++ b/src/app/state/settings.ts
@@ -94,7 +94,7 @@ export interface Settings {
// Sable features!
sendPresence: boolean;
/** Which Matrix presence state to broadcast when sendPresence is true. */
- presenceMode: 'online' | 'unavailable' | 'offline';
+ presenceMode: 'online' | 'unavailable' | 'dnd' | 'offline';
mobileGestures: boolean;
rightSwipeAction: RightSwipeAction;
hideMembershipInReadOnly: boolean;
From a71bdab9a4100c42acd42d6a9015ca38270550f0 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sat, 11 Apr 2026 21:49:38 -0400
Subject: [PATCH 083/253] feat(presence): auto-idle after inactivity timeout
Adds an optional inactivity-based presence auto-idle that downgrades the
user's broadcast presence from online to unavailable after a configurable
period without keyboard or pointer input.
## How it works
- New config flag `presenceAutoIdleTimeoutMs` (default: 600 000 ms = 10 min,
0 = disabled). Operators can adjust or disable via config.json.
- New hook `usePresenceAutoIdle` sets `presenceAutoIdledAtom` (ephemeral,
not persisted) after the timeout, and clears it immediately on any
mousemove / mousedown / keydown / touchstart / wheel event.
- `PresenceFeature` reads `autoIdled` and derives the effective broadcast
mode: when auto-idled the broadcast is forced to `unavailable` regardless
of the user's configured presenceMode, then restored on activity.
- `AccountSwitcherTab` badge and picker reflect the effective mode so the
UI is consistent with what is actually broadcasted.
## Multi-device sync
If another device sets the user back to `online` (e.g. the user becomes
active there), the `User.presence` event handler in `usePresenceAutoIdle`
clears the auto-idle flag on this device too.
## iOS caveat
Background tab throttling on iOS Safari PWA may delay or prevent the
inactivity timer from firing reliably. The feature degrades gracefully:
presence will eventually update when the tab regains focus.
---
config.json | 2 +
src/app/hooks/useClientConfig.ts | 2 +
src/app/hooks/usePresenceAutoIdle.ts | 101 ++++++++++++++++++
src/app/pages/client/ClientNonUIFeatures.tsx | 28 ++---
.../client/sidebar/AccountSwitcherTab.tsx | 12 ++-
src/app/state/settings.ts | 3 +
6 files changed, 132 insertions(+), 16 deletions(-)
create mode 100644 src/app/hooks/usePresenceAutoIdle.ts
diff --git a/config.json b/config.json
index f0c3c8b61..b930f457e 100644
--- a/config.json
+++ b/config.json
@@ -19,6 +19,8 @@
"enabled": true
},
+ "presenceAutoIdleTimeoutMs": 600000,
+
"featuredCommunities": {
"openAsDefault": false,
"spaces": [
diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts
index e523f15a7..0e7257532 100644
--- a/src/app/hooks/useClientConfig.ts
+++ b/src/app/hooks/useClientConfig.ts
@@ -43,6 +43,8 @@ export type ClientConfig = {
matrixToBaseUrl?: string;
settingsLinkBaseUrl?: string;
+ /** How long (ms) without input before auto-idling presence. 0 = disabled. */
+ presenceAutoIdleTimeoutMs?: number;
};
const ClientConfigContext = createContext(null);
diff --git a/src/app/hooks/usePresenceAutoIdle.ts b/src/app/hooks/usePresenceAutoIdle.ts
new file mode 100644
index 000000000..dd11e729b
--- /dev/null
+++ b/src/app/hooks/usePresenceAutoIdle.ts
@@ -0,0 +1,101 @@
+import { useCallback, useEffect, useRef } from 'react';
+import { useSetAtom } from 'jotai';
+import { type MatrixClient, UserEvent, type UserEventHandlerMap } from '$types/matrix-sdk';
+import { presenceAutoIdledAtom } from '$state/settings';
+import { createDebugLogger } from '$utils/debugLogger';
+
+const debugLog = createDebugLogger('PresenceAutoIdle');
+const ACTIVITY_EVENTS = ['mousemove', 'mousedown', 'keydown', 'touchstart', 'wheel'] as const;
+
+/**
+ * Automatically transitions presence to idle after a configurable inactivity
+ * timeout, and clears the idle state when activity is detected.
+ *
+ * Also subscribes to the Matrix `User.presence` event so that if another device
+ * sets you back to `online`, the auto-idle state is cleared here too (multi-device
+ * sync).
+ *
+ * Note: On iOS Safari PWA, background tab throttling may delay or prevent the
+ * inactivity timer from firing reliably. The feature degrades gracefully β presence
+ * will eventually update when the tab regains focus.
+ */
+export function usePresenceAutoIdle(
+ mx: MatrixClient,
+ presenceMode: string,
+ sendPresence: boolean,
+ timeoutMs: number
+): void {
+ const setAutoIdled = useSetAtom(presenceAutoIdledAtom);
+ const autoIdledRef = useRef(false);
+ const timerRef = useRef(undefined);
+
+ const clearTimer = useCallback(() => {
+ if (timerRef.current !== undefined) {
+ window.clearTimeout(timerRef.current);
+ timerRef.current = undefined;
+ }
+ }, []);
+
+ // Inactivity timer: go idle after timeoutMs without user input.
+ useEffect(() => {
+ const shouldAutoIdle = presenceMode === 'online' && sendPresence && timeoutMs > 0;
+ if (!shouldAutoIdle) {
+ clearTimer();
+ if (autoIdledRef.current) {
+ autoIdledRef.current = false;
+ setAutoIdled(false);
+ }
+ return undefined;
+ }
+
+ const goIdle = () => {
+ debugLog.info('general', 'Inactivity timeout β auto-idling');
+ autoIdledRef.current = true;
+ setAutoIdled(true);
+ };
+
+ const handleActivity = () => {
+ clearTimer();
+ if (autoIdledRef.current) {
+ debugLog.info('general', 'Activity detected β clearing auto-idle');
+ autoIdledRef.current = false;
+ setAutoIdled(false);
+ }
+ timerRef.current = window.setTimeout(goIdle, timeoutMs);
+ };
+
+ // Start the initial timer.
+ timerRef.current = window.setTimeout(goIdle, timeoutMs);
+ ACTIVITY_EVENTS.forEach((ev) =>
+ document.addEventListener(ev, handleActivity, { passive: true })
+ );
+
+ return () => {
+ ACTIVITY_EVENTS.forEach((ev) => document.removeEventListener(ev, handleActivity));
+ clearTimer();
+ };
+ }, [clearTimer, presenceMode, sendPresence, setAutoIdled, timeoutMs]);
+
+ // Multi-device sync: if another device sets us back to online, clear auto-idle.
+ useEffect(() => {
+ if (!sendPresence) return undefined;
+ const myUserId = mx.getUserId();
+ if (!myUserId) return undefined;
+ const user = mx.getUser(myUserId);
+ if (!user) return undefined;
+
+ const handlePresence: UserEventHandlerMap[UserEvent.Presence] = (_event, u) => {
+ if (u.userId !== myUserId) return;
+ if (u.presence === 'online' && autoIdledRef.current) {
+ debugLog.info('general', 'Remote device set Online β clearing auto-idle');
+ autoIdledRef.current = false;
+ setAutoIdled(false);
+ }
+ };
+
+ user.on(UserEvent.Presence, handlePresence);
+ return () => {
+ user.removeListener(UserEvent.Presence, handlePresence);
+ };
+ }, [mx, sendPresence, setAutoIdled]);
+}
diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx
index 260f1dc28..e4a8037ac 100644
--- a/src/app/pages/client/ClientNonUIFeatures.tsx
+++ b/src/app/pages/client/ClientNonUIFeatures.tsx
@@ -1,4 +1,4 @@
-import { useAtomValue, useSetAtom } from 'jotai';
+import { useAtomValue, useSetAtom, useAtom } from 'jotai';
import * as Sentry from '@sentry/react';
import { ReactNode, useCallback, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
@@ -21,7 +21,9 @@ import NotificationSound from '$public/sound/notification.ogg';
import InviteSound from '$public/sound/invite.ogg';
import { notificationPermission, setFavicon } from '$utils/dom';
import { useSetting } from '$state/hooks/settings';
-import { settingsAtom } from '$state/settings';
+import { settingsAtom, presenceAutoIdledAtom } from '$state/settings';
+import { useClientConfig } from '$hooks/useClientConfig';
+import { usePresenceAutoIdle } from '$hooks/usePresenceAutoIdle';
import { nicknamesAtom } from '$state/nicknames';
import { mDirectAtom } from '$state/mDirectList';
import { allInvitesAtom } from '$state/room-list/inviteList';
@@ -56,7 +58,6 @@ import { useCallSignaling } from '$hooks/useCallSignaling';
import { getBlobCacheStats } from '$hooks/useBlobCache';
import { lastVisitedRoomIdAtom } from '$state/room/lastRoom';
import { useSettingsSyncEffect } from '$hooks/useSettingsSync';
-import { useInitBookmarks } from '$features/bookmarks/useInitBookmarks';
import { getInboxInvitesPath } from '../pathUtils';
import { BackgroundNotifications } from './BackgroundNotifications';
@@ -843,11 +844,18 @@ function PresenceFeature() {
const mx = useMatrixClient();
const [sendPresence] = useSetting(settingsAtom, 'sendPresence');
const [presenceMode] = useSetting(settingsAtom, 'presenceMode');
+ const [autoIdled] = useAtom(presenceAutoIdledAtom);
+ const clientConfig = useClientConfig();
+ const timeoutMs = clientConfig.presenceAutoIdleTimeoutMs ?? 0;
+
+ usePresenceAutoIdle(mx, presenceMode ?? 'online', sendPresence, timeoutMs);
useEffect(() => {
- // Effective broadcast state: honour presenceMode when presence is on, otherwise offline.
+ // When auto-idled, broadcast as unavailable regardless of the configured mode.
+ const effectiveMode = autoIdled ? 'unavailable' : (presenceMode ?? 'online');
+ // Effective broadcast state: honour effectiveMode when presence is on, otherwise offline.
// DND broadcasts as online (you're active but don't want to be disturbed) with a status_msg.
- const activePresence = presenceMode === 'dnd' ? 'online' : (presenceMode ?? 'online');
+ const activePresence = effectiveMode === 'dnd' ? 'online' : effectiveMode;
const effectiveState = sendPresence ? activePresence : 'offline';
const broadcasting = effectiveState !== 'offline';
@@ -863,11 +871,11 @@ function PresenceFeature() {
// their presence events because the extension is still enabled above.
mx.setPresence({
presence: effectiveState,
- status_msg: sendPresence && presenceMode === 'dnd' ? 'dnd' : '',
+ status_msg: sendPresence && effectiveMode === 'dnd' ? 'dnd' : '',
}).catch(() => {
// Server doesn't support presence β ignore.
});
- }, [mx, sendPresence, presenceMode]);
+ }, [mx, sendPresence, presenceMode, autoIdled]);
return null;
}
@@ -877,17 +885,11 @@ function SettingsSyncFeature() {
return null;
}
-function BookmarksFeature() {
- useInitBookmarks();
- return null;
-}
-
export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
useCallSignaling();
return (
<>
-
diff --git a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx
index 395edcfe7..737bcf7c4 100644
--- a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx
+++ b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx
@@ -52,7 +52,7 @@ import { createDebugLogger } from '$utils/debugLogger';
import { useClientConfig } from '$hooks/useClientConfig';
import { UnreadBadge, UnreadBadgeCenter } from '$components/unread-badge';
import { useSetting } from '$state/hooks/settings';
-import { settingsAtom } from '$state/settings';
+import { settingsAtom, presenceAutoIdledAtom } from '$state/settings';
const log = createLogger('AccountSwitcherTab');
const debugLog = createDebugLogger('AccountSwitcherTab');
@@ -183,14 +183,18 @@ export function AccountSwitcherTab() {
// user.presence would leave the badge stuck at the SDK default forever.
const [sendPresence, setSendPresence] = useSetting(settingsAtom, 'sendPresence');
const [presenceMode, setPresenceMode] = useSetting(settingsAtom, 'presenceMode');
+ const autoIdled = useAtomValue(presenceAutoIdledAtom);
+ const setAutoIdled = useSetAtom(presenceAutoIdledAtom);
+ // The effective mode for badge display: if auto-idled, show unavailable regardless of selected mode.
+ const effectiveDisplayMode = autoIdled ? 'unavailable' : (presenceMode ?? 'online');
let myOwnPresenceBadge: ReactNode;
if (sendPresence) {
myOwnPresenceBadge =
- presenceMode === 'dnd' ? (
+ effectiveDisplayMode === 'dnd' ? (
// DND: solid red badge (broadcasts as online with status_msg 'dnd')
) : (
-
+
);
}
const activeAvatarUrl = activeProfile.avatarUrl
@@ -413,6 +417,8 @@ export function AccountSwitcherTab() {
}
onClick={() => {
setPresenceMode(mode);
+ // Clear auto-idle so the badge updates immediately on manual selection.
+ setAutoIdled(false);
// Re-enable presence broadcasting if the master toggle was off
if (!sendPresence) setSendPresence(true);
}}
diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts
index 4538ae287..0d4c16bc8 100644
--- a/src/app/state/settings.ts
+++ b/src/app/state/settings.ts
@@ -265,3 +265,6 @@ export const settingsAtom = atom(
setSettings(update);
}
);
+
+/** Ephemeral (not persisted) β true when auto-idled due to inactivity. */
+export const presenceAutoIdledAtom = atom(false);
From ca97c9bc83b661fc2c8513f14e2da4a37280fa53 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sun, 12 Apr 2026 12:10:29 -0400
Subject: [PATCH 084/253] chore: add changeset for presence-auto-idle
---
.changeset/presence-auto-idle.md | 5 +++++
1 file changed, 5 insertions(+)
create mode 100644 .changeset/presence-auto-idle.md
diff --git a/.changeset/presence-auto-idle.md b/.changeset/presence-auto-idle.md
new file mode 100644
index 000000000..0cdedfdac
--- /dev/null
+++ b/.changeset/presence-auto-idle.md
@@ -0,0 +1,5 @@
+---
+'@sable/client': minor
+---
+
+feat(presence): add auto-idle presence after configurable inactivity timeout with Discord-style status picker
From 594fda08df6860d9909cc4ea218ab6419c92512f Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sun, 12 Apr 2026 14:52:58 -0400
Subject: [PATCH 085/253] fix(bookmarks): strip deleted flag on re-add to
guarantee re-activation
---
.../bookmarks/bookmarkRepository.test.ts | 29 +++++++++++++++++++
.../features/bookmarks/bookmarkRepository.ts | 11 +++++--
2 files changed, 37 insertions(+), 3 deletions(-)
diff --git a/src/app/features/bookmarks/bookmarkRepository.test.ts b/src/app/features/bookmarks/bookmarkRepository.test.ts
index 3eeaaca65..54e3d85b8 100644
--- a/src/app/features/bookmarks/bookmarkRepository.test.ts
+++ b/src/app/features/bookmarks/bookmarkRepository.test.ts
@@ -141,6 +141,35 @@ describe('addBookmark', () => {
const idx = (mx as any)._store[AccountDataEvent.BookmarksIndex] as BookmarkIndexContent;
expect(idx.bookmark_ids).toContain(item.bookmark_id);
});
+
+ it('re-activates a tombstoned bookmark (strips deleted: true)', async () => {
+ const tombstoned = makeItem({ deleted: true });
+ const mx2 = makeClient({
+ [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: [] }),
+ [bookmarkItemEventType(tombstoned.bookmark_id)]: tombstoned,
+ });
+
+ // Re-add with a fresh item (same bookmark_id, no deleted flag)
+ const freshItem = makeItem();
+ await addBookmark(mx2, freshItem);
+
+ const stored = (mx2 as any)._store[
+ bookmarkItemEventType(freshItem.bookmark_id)
+ ] as BookmarkItemContent;
+ expect(stored.deleted).toBeUndefined();
+ const idx = (mx2 as any)._store[AccountDataEvent.BookmarksIndex] as BookmarkIndexContent;
+ expect(idx.bookmark_ids).toContain(freshItem.bookmark_id);
+ });
+
+ it('strips deleted: true even when the item passed in carries the flag', async () => {
+ const item = makeItem({ deleted: true });
+ await addBookmark(mx, item);
+
+ const stored = (mx as any)._store[
+ bookmarkItemEventType(item.bookmark_id)
+ ] as BookmarkItemContent;
+ expect(stored.deleted).toBeUndefined();
+ });
});
// ---------------------------------------------------------------------------
diff --git a/src/app/features/bookmarks/bookmarkRepository.ts b/src/app/features/bookmarks/bookmarkRepository.ts
index d1a30370d..241430e76 100644
--- a/src/app/features/bookmarks/bookmarkRepository.ts
+++ b/src/app/features/bookmarks/bookmarkRepository.ts
@@ -44,17 +44,22 @@ async function writeItem(mx: MatrixClient, item: BookmarkItemContent): Promise {
+ // Strip deleted so that re-bookmarking a previously removed message always
+ // produces an active item, even if a stale tombstoned item is passed in.
+ const { deleted, ...activeItem } = item;
// Write item before updating index (cross-device consistency)
- await writeItem(mx, item);
+ await writeItem(mx, activeItem as BookmarkItemContent);
const index = readIndex(mx);
if (!index.bookmark_ids.includes(item.bookmark_id)) {
From 31441add1d6a7ce0d2e553be855a8e787f3eb88a Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sun, 12 Apr 2026 15:15:01 -0400
Subject: [PATCH 086/253] feat(bookmarks): show Recently Removed section with
Restore button
---
.../bookmarks/bookmarkRepository.test.ts | 77 ++++++++++++++++++-
.../features/bookmarks/bookmarkRepository.ts | 39 ++++++++++
src/app/features/bookmarks/useBookmarks.ts | 47 +++++++++--
.../features/bookmarks/useInitBookmarks.ts | 8 +-
.../pages/client/bookmarks/BookmarksList.tsx | 70 ++++++++++++++++-
src/app/state/bookmarks.ts | 7 ++
6 files changed, 235 insertions(+), 13 deletions(-)
diff --git a/src/app/features/bookmarks/bookmarkRepository.test.ts b/src/app/features/bookmarks/bookmarkRepository.test.ts
index 54e3d85b8..d9724cf67 100644
--- a/src/app/features/bookmarks/bookmarkRepository.test.ts
+++ b/src/app/features/bookmarks/bookmarkRepository.test.ts
@@ -8,7 +8,13 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import type { MatrixClient } from '$types/matrix-sdk';
import { AccountDataEvent } from '$types/matrix/accountData';
-import { addBookmark, removeBookmark, listBookmarks, isBookmarked } from './bookmarkRepository';
+import {
+ addBookmark,
+ removeBookmark,
+ listBookmarks,
+ listDeletedBookmarks,
+ isBookmarked,
+} from './bookmarkRepository';
import {
bookmarkItemEventType,
emptyIndex,
@@ -366,6 +372,75 @@ describe('listBookmarks', () => {
});
});
+// ---------------------------------------------------------------------------
+// listDeletedBookmarks
+// ---------------------------------------------------------------------------
+
+describe('listDeletedBookmarks', () => {
+ it('returns an empty array when there are no tombstoned items', () => {
+ const item = makeItem();
+ const mx = makeClient({
+ [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: [item.bookmark_id] }),
+ [bookmarkItemEventType(item.bookmark_id)]: item,
+ });
+ expect(listDeletedBookmarks(mx)).toEqual([]);
+ });
+
+ it('returns index-referenced items that are tombstoned (partial remove failure)', () => {
+ const item = makeItem({ deleted: true });
+ const mx = makeClient({
+ [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: [item.bookmark_id] }),
+ [bookmarkItemEventType(item.bookmark_id)]: item,
+ });
+ const result = listDeletedBookmarks(mx);
+ expect(result).toHaveLength(1);
+ expect(result[0].bookmark_id).toBe(item.bookmark_id);
+ });
+
+ it('returns orphan tombstones not in the index (normal remove path)', () => {
+ const item = makeItem({ bookmark_id: 'bmk_orphan99', deleted: true });
+ const mx = makeClient({
+ // ID intentionally absent from the index
+ [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: [] }),
+ [bookmarkItemEventType(item.bookmark_id)]: item,
+ });
+ const result = listDeletedBookmarks(mx);
+ expect(result).toHaveLength(1);
+ expect(result[0].bookmark_id).toBe(item.bookmark_id);
+ });
+
+ it('does not return active (non-deleted) items', () => {
+ const active = makeItem();
+ const deleted = makeItem({ bookmark_id: 'bmk_deleted1', deleted: true });
+ const mx = makeClient({
+ [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: [active.bookmark_id] }),
+ [bookmarkItemEventType(active.bookmark_id)]: active,
+ [bookmarkItemEventType(deleted.bookmark_id)]: deleted,
+ });
+ const result = listDeletedBookmarks(mx);
+ expect(result.map((i) => i.bookmark_id)).not.toContain(active.bookmark_id);
+ expect(result.map((i) => i.bookmark_id)).toContain(deleted.bookmark_id);
+ });
+
+ it('deduplicates when the same ID appears in both index and orphan scan', () => {
+ const item = makeItem({ deleted: true });
+ const mx = makeClient({
+ [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: [item.bookmark_id] }),
+ [bookmarkItemEventType(item.bookmark_id)]: item,
+ });
+ const result = listDeletedBookmarks(mx);
+ expect(result).toHaveLength(1);
+ });
+
+ it('skips malformed item events even if deleted: true', () => {
+ const mx = makeClient({
+ [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: [] }),
+ [bookmarkItemEventType('bmk_bad')]: { deleted: true, not_valid: 'junk' },
+ });
+ expect(listDeletedBookmarks(mx)).toEqual([]);
+ });
+});
+
// ---------------------------------------------------------------------------
// isBookmarked
// ---------------------------------------------------------------------------
diff --git a/src/app/features/bookmarks/bookmarkRepository.ts b/src/app/features/bookmarks/bookmarkRepository.ts
index 241430e76..bd9cda928 100644
--- a/src/app/features/bookmarks/bookmarkRepository.ts
+++ b/src/app/features/bookmarks/bookmarkRepository.ts
@@ -146,6 +146,45 @@ export function listBookmarks(mx: MatrixClient): BookmarkItemContent[] {
return items;
}
+/**
+ * List all deleted (tombstoned) bookmark items.
+ *
+ * Includes both:
+ * - Items still referenced in the index whose item event carries deleted: true
+ * (arises when the index write fails after a soft-delete).
+ * - Orphaned tombstones whose ID has already been removed from the index
+ * (the normal case after a successful remove).
+ *
+ * Results are deduplicated and include only items that pass isValidBookmarkItem
+ * (ensuring enough stored metadata is available to display and restore them).
+ */
+export function listDeletedBookmarks(mx: MatrixClient): BookmarkItemContent[] {
+ const index = readIndex(mx);
+ const results: BookmarkItemContent[] = [];
+ const seen = new Set();
+
+ // 1. Index-referenced items that are tombstoned (partial remove failure)
+ index.bookmark_ids.forEach((id) => {
+ if (seen.has(id)) return;
+ seen.add(id);
+ const content = mx.getAccountData(bookmarkItemEventType(id) as any)?.getContent();
+ if (isValidBookmarkItem(content) && content.deleted === true) results.push(content);
+ });
+
+ // 2. Orphan tombstones (properly removed from index but item event persists)
+ const prefix = AccountDataEvent.BookmarkItemPrefix as string;
+ Array.from(mx.store.accountData.keys()).forEach((key) => {
+ if (!key.startsWith(prefix)) return;
+ const bookmarkId = key.slice(prefix.length);
+ if (seen.has(bookmarkId)) return;
+ seen.add(bookmarkId);
+ const content = mx.getAccountData(key as any)?.getContent();
+ if (isValidBookmarkItem(content) && content.deleted === true) results.push(content);
+ });
+
+ return results;
+}
+
/**
* Check whether a specific bookmark ID is in the index.
*
diff --git a/src/app/features/bookmarks/useBookmarks.ts b/src/app/features/bookmarks/useBookmarks.ts
index aadd0bb8f..df9346dfd 100644
--- a/src/app/features/bookmarks/useBookmarks.ts
+++ b/src/app/features/bookmarks/useBookmarks.ts
@@ -1,15 +1,31 @@
import { useAtomValue, useSetAtom } from 'jotai';
import { useCallback } from 'react';
import { useMatrixClient } from '$hooks/useMatrixClient';
-import { bookmarkIdSetAtom, bookmarkListAtom, bookmarkLoadingAtom } from '$state/bookmarks';
+import {
+ bookmarkDeletedListAtom,
+ bookmarkIdSetAtom,
+ bookmarkListAtom,
+ bookmarkLoadingAtom,
+} from '$state/bookmarks';
import { BookmarkItemContent, computeBookmarkId } from './bookmarkDomain';
-import { addBookmark, removeBookmark, listBookmarks, isBookmarked } from './bookmarkRepository';
+import {
+ addBookmark,
+ listBookmarks,
+ listDeletedBookmarks,
+ removeBookmark,
+ isBookmarked,
+} from './bookmarkRepository';
/** Returns the current ordered bookmark list. */
export function useBookmarkList(): BookmarkItemContent[] {
return useAtomValue(bookmarkListAtom);
}
+/** Returns deleted (tombstoned) bookmarks that can be restored. */
+export function useBookmarkDeletedList(): BookmarkItemContent[] {
+ return useAtomValue(bookmarkDeletedListAtom);
+}
+
/** Returns true while a bookmark refresh is in progress. */
export function useBookmarkLoading(): boolean {
return useAtomValue(bookmarkLoadingAtom);
@@ -35,28 +51,30 @@ export function useIsBookmarked(roomId: string, eventId: string): boolean {
export function useBookmarkActions() {
const mx = useMatrixClient();
const setList = useSetAtom(bookmarkListAtom);
+ const setDeletedList = useSetAtom(bookmarkDeletedListAtom);
const setLoading = useSetAtom(bookmarkLoadingAtom);
const refresh = useCallback(async () => {
setLoading(true);
try {
- const items = listBookmarks(mx);
- setList(items);
+ setList(listBookmarks(mx));
+ setDeletedList(listDeletedBookmarks(mx));
} finally {
setLoading(false);
}
- }, [mx, setList, setLoading]);
+ }, [mx, setList, setDeletedList, setLoading]);
const add = useCallback(
async (item: BookmarkItemContent) => {
- // Optimistic update
+ // Optimistic update: add to active list, remove from deleted list
setList((prev) => {
if (prev.some((b) => b.bookmark_id === item.bookmark_id)) return prev;
return [item, ...prev];
});
+ setDeletedList((prev) => prev.filter((b) => b.bookmark_id !== item.bookmark_id));
await addBookmark(mx, item);
},
- [mx, setList]
+ [mx, setList, setDeletedList]
);
const remove = useCallback(
@@ -68,11 +86,24 @@ export function useBookmarkActions() {
[mx, setList]
);
+ const restore = useCallback(
+ async (item: BookmarkItemContent) => {
+ // Optimistic update: move from deleted list to active list
+ setDeletedList((prev) => prev.filter((b) => b.bookmark_id !== item.bookmark_id));
+ setList((prev) => {
+ if (prev.some((b) => b.bookmark_id === item.bookmark_id)) return prev;
+ return [item, ...prev];
+ });
+ await addBookmark(mx, item); // strips deleted flag
+ },
+ [mx, setList, setDeletedList]
+ );
+
const checkIsBookmarked = useCallback(
(roomId: string, eventId: string): boolean =>
isBookmarked(mx, computeBookmarkId(roomId, eventId)),
[mx]
);
- return { refresh, add, remove, checkIsBookmarked };
+ return { refresh, add, remove, restore, checkIsBookmarked };
}
diff --git a/src/app/features/bookmarks/useInitBookmarks.ts b/src/app/features/bookmarks/useInitBookmarks.ts
index 40175ce58..3b6cb2247 100644
--- a/src/app/features/bookmarks/useInitBookmarks.ts
+++ b/src/app/features/bookmarks/useInitBookmarks.ts
@@ -4,9 +4,9 @@ import { useSetAtom } from 'jotai';
import { useMatrixClient } from '$hooks/useMatrixClient';
import { useSyncState } from '$hooks/useSyncState';
import { useAccountDataCallback } from '$hooks/useAccountDataCallback';
-import { bookmarkListAtom, bookmarkLoadingAtom } from '$state/bookmarks';
+import { bookmarkDeletedListAtom, bookmarkListAtom, bookmarkLoadingAtom } from '$state/bookmarks';
import { AccountDataEvent } from '$types/matrix/accountData';
-import { listBookmarks } from './bookmarkRepository';
+import { listBookmarks, listDeletedBookmarks } from './bookmarkRepository';
/**
* Top-level hook that keeps `bookmarkListAtom` in sync with account data.
@@ -24,16 +24,18 @@ import { listBookmarks } from './bookmarkRepository';
export function useInitBookmarks(): void {
const mx = useMatrixClient();
const setList = useSetAtom(bookmarkListAtom);
+ const setDeletedList = useSetAtom(bookmarkDeletedListAtom);
const setLoading = useSetAtom(bookmarkLoadingAtom);
const loadBookmarks = useCallback(() => {
setLoading(true);
try {
setList(listBookmarks(mx));
+ setDeletedList(listDeletedBookmarks(mx));
} finally {
setLoading(false);
}
- }, [mx, setList, setLoading]);
+ }, [mx, setList, setDeletedList, setLoading]);
// Immediate load: fires once on mount to cover the case where ClientNonUIFeatures
// mounts after the initial SyncState.Syncing transition has already fired.
diff --git a/src/app/pages/client/bookmarks/BookmarksList.tsx b/src/app/pages/client/bookmarks/BookmarksList.tsx
index 6dae4fb5c..562a4920c 100644
--- a/src/app/pages/client/bookmarks/BookmarksList.tsx
+++ b/src/app/pages/client/bookmarks/BookmarksList.tsx
@@ -48,6 +48,7 @@ import { stopPropagation } from '$utils/keyboard';
import { BookmarkItemContent } from '$features/bookmarks/bookmarkDomain';
import {
useBookmarkActions,
+ useBookmarkDeletedList,
useBookmarkList,
useBookmarkLoading,
} from '$features/bookmarks/useBookmarks';
@@ -298,6 +299,45 @@ function BookmarkResultGroup({
);
}
+// ---------------------------------------------------------------------------
+// RemovedBookmarkRow
+// ---------------------------------------------------------------------------
+
+type RemovedBookmarkRowProps = {
+ item: BookmarkItemContent;
+ onRestore: (item: BookmarkItemContent) => void;
+};
+
+function RemovedBookmarkRow({ item, onRestore }: RemovedBookmarkRowProps) {
+ const mx = useMatrixClient();
+ const room = mx.getRoom(item.room_id) ?? undefined;
+ const roomName = room?.name ?? item.room_name ?? item.room_id;
+
+ return (
+
+
+
+
+ {roomName}
+
+ {item.body_preview && (
+
+ {item.body_preview}
+
+ )}
+
+ onRestore(item)} variant="Secondary" radii="400">
+ Restore
+
+
+
+ );
+}
+
// ---------------------------------------------------------------------------
// BookmarkFilterInput
// ---------------------------------------------------------------------------
@@ -379,8 +419,9 @@ export function BookmarksList() {
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
const bookmarks = useBookmarkList();
+ const deletedBookmarks = useBookmarkDeletedList();
const loading = useBookmarkLoading();
- const { remove } = useBookmarkActions();
+ const { remove, restore } = useBookmarkActions();
const [filterTerm, setFilterTerm] = useState();
const [removingItem, setRemovingItem] = useState();
@@ -432,6 +473,13 @@ export function BookmarksList() {
setRemovingItem(undefined);
}, [removingItem, remove]);
+ const handleRestore = useCallback(
+ async (item: BookmarkItemContent) => {
+ await restore(item);
+ },
+ [restore]
+ );
+
const handleFilter = useCallback((term: string) => {
setFilterTerm(term);
}, []);
@@ -530,6 +578,26 @@ export function BookmarksList() {
))}
)}
+
+ {deletedBookmarks.length > 0 && !filterTerm && (
+
+
+
+
+ {deletedBookmarks.map((item) => (
+
+ ))}
+
+
+ )}
diff --git a/src/app/state/bookmarks.ts b/src/app/state/bookmarks.ts
index 7a2375691..4d6c2b19f 100644
--- a/src/app/state/bookmarks.ts
+++ b/src/app/state/bookmarks.ts
@@ -4,6 +4,13 @@ import { BookmarkItemContent } from '../features/bookmarks/bookmarkDomain';
/** Ordered list of active bookmark items (mirrors the server index order). */
export const bookmarkListAtom = atom([]);
+/**
+ * Ordered list of deleted (tombstoned) bookmark items that are recoverable.
+ * Populated alongside bookmarkListAtom so the UI can show a "Recently Removed"
+ * section with a Restore button for each entry.
+ */
+export const bookmarkDeletedListAtom = atom([]);
+
/** True while a refresh from account data is in progress. */
export const bookmarkLoadingAtom = atom(false);
From d8da8691bea10b1c57bdf90a4f0fbe993fa7b8eb Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sun, 12 Apr 2026 17:29:09 -0400
Subject: [PATCH 087/253] fix: auto-format poll test imports for prettier
compliance
---
src/app/features/room/poll/pollEvent.test.ts | 25 +++++---------------
1 file changed, 6 insertions(+), 19 deletions(-)
diff --git a/src/app/features/room/poll/pollEvent.test.ts b/src/app/features/room/poll/pollEvent.test.ts
index b431d7b3e..f962a4a6a 100644
--- a/src/app/features/room/poll/pollEvent.test.ts
+++ b/src/app/features/room/poll/pollEvent.test.ts
@@ -1,11 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { Room, MatrixEvent } from '$types/matrix-sdk';
-import {
- extractPollData,
- extractVoteSelections,
- computeTally,
- formatExpiry,
-} from './PollEvent';
+import { extractPollData, extractVoteSelections, computeTally, formatExpiry } from './PollEvent';
// ---------------------------------------------------------------------------
// Helpers
@@ -106,10 +101,7 @@ function makeEndEvent(sender: string, ts: number): MatrixEvent {
* Build a minimal fake Room whose `relations.getAllChildEventsForEvent` returns
* the provided child events.
*/
-function makeRoom(
- childEvents: MatrixEvent[],
- maySendRedaction = false
-): Room {
+function makeRoom(childEvents: MatrixEvent[], maySendRedaction = false): Room {
return {
getUnfilteredTimelineSet: () => ({
relations: {
@@ -235,14 +227,7 @@ describe('computeTally', () => {
const children = [makeResponseEvent('@alice:test', ['ans-red'], 2_000)];
const room = makeRoom(children);
- const { tally, isEnded } = computeTally(
- room,
- '$poll:test',
- pollStart,
- ANSWERS,
- 1,
- MY_USER_ID
- );
+ const { tally, isEnded } = computeTally(room, '$poll:test', pollStart, ANSWERS, 1, MY_USER_ID);
expect(isEnded).toBe(false);
expect(tally.get('ans-red')?.size).toBe(1);
@@ -346,7 +331,9 @@ describe('computeTally', () => {
it('ignores decryption-failure response events', () => {
const pollStart = makePollStartEvent('$poll:test');
- const children = [makeResponseEvent('@alice:test', ['ans-red'], 2_000, /* decryptFailure= */ true)];
+ const children = [
+ makeResponseEvent('@alice:test', ['ans-red'], 2_000, /* decryptFailure= */ true),
+ ];
const room = makeRoom(children);
const { tally } = computeTally(room, '$poll:test', pollStart, ANSWERS, 1, MY_USER_ID);
From 4a15b741d59f89cdbb05412f43e585a04b16c3e0 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sun, 12 Apr 2026 17:30:26 -0400
Subject: [PATCH 088/253] fix: remove unused params from mock Room callbacks
---
src/app/features/room/poll/pollEvent.test.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/app/features/room/poll/pollEvent.test.ts b/src/app/features/room/poll/pollEvent.test.ts
index f962a4a6a..e4cce28ff 100644
--- a/src/app/features/room/poll/pollEvent.test.ts
+++ b/src/app/features/room/poll/pollEvent.test.ts
@@ -105,11 +105,11 @@ function makeRoom(childEvents: MatrixEvent[], maySendRedaction = false): Room {
return {
getUnfilteredTimelineSet: () => ({
relations: {
- getAllChildEventsForEvent: (_id: string) => childEvents,
+ getAllChildEventsForEvent: () => childEvents,
},
}),
currentState: {
- maySendRedactionForEvent: (_event: MatrixEvent, _sender: string) => maySendRedaction,
+ maySendRedactionForEvent: () => maySendRedaction,
},
} as unknown as Room;
}
From b0a809105f0bfcdd88e59d0bfab9252e48055733 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sun, 12 Apr 2026 17:33:26 -0400
Subject: [PATCH 089/253] fix(security): block prototype-polluting keys in
deepMerge
---
scripts/inject-client-config.js | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/scripts/inject-client-config.js b/scripts/inject-client-config.js
index b7c62c096..0b5fcd3ad 100644
--- a/scripts/inject-client-config.js
+++ b/scripts/inject-client-config.js
@@ -15,11 +15,15 @@ const formatError = (error) => {
const isPlainObject = (value) =>
typeof value === 'object' && value !== null && !Array.isArray(value);
+// Keys that could trigger prototype pollution via bracket assignment.
+const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
+
const deepMerge = (target, source) => {
if (!isPlainObject(target) || !isPlainObject(source)) return source;
const merged = { ...target };
Object.entries(source).forEach(([key, value]) => {
+ if (UNSAFE_KEYS.has(key)) return;
const targetValue = merged[key];
merged[key] =
isPlainObject(targetValue) && isPlainObject(value) ? deepMerge(targetValue, value) : value;
From 264e4ab9fab61345967318d64397a28ffe78aae6 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sun, 12 Apr 2026 17:47:21 -0400
Subject: [PATCH 090/253] fix(presence): restore missing experiment config
helpers and clean presence hook tests
---
.../settings/developer-tools/DevelopTools.tsx | 2 -
src/app/hooks/useClientConfig.ts | 94 +++++++++++++++++++
src/app/hooks/useUserPresence.test.tsx | 72 +++++++-------
3 files changed, 132 insertions(+), 36 deletions(-)
diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx
index a499faf9c..4e38f7868 100644
--- a/src/app/features/settings/developer-tools/DevelopTools.tsx
+++ b/src/app/features/settings/developer-tools/DevelopTools.tsx
@@ -14,7 +14,6 @@ import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback';
import { SettingsSectionPage } from '../SettingsSectionPage';
import { AccountData } from './AccountData';
import { SyncDiagnostics } from './SyncDiagnostics';
-import { ExperimentsPanel } from './ExperimentsPanel';
import { DebugLogViewer } from './DebugLogViewer';
import { SentrySettings } from './SentrySettings';
@@ -139,7 +138,6 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp
)}
{developerTools && }
- {developerTools && }
{developerTools && (
Encryption
diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts
index 0e7257532..3f5568e80 100644
--- a/src/app/hooks/useClientConfig.ts
+++ b/src/app/hooks/useClientConfig.ts
@@ -5,6 +5,31 @@ export type HashRouterConfig = {
basename?: string;
};
+export type ExperimentConfig = {
+ enabled?: boolean;
+ rolloutPercentage?: number;
+ variants?: string[];
+ controlVariant?: string;
+};
+
+export type ExperimentSelection = {
+ key: string;
+ enabled: boolean;
+ rolloutPercentage: number;
+ variant: string;
+ inExperiment: boolean;
+};
+
+export type SessionSyncConfig = {
+ phase1ForegroundResync?: boolean;
+ phase2VisibleHeartbeat?: boolean;
+ phase3AdaptiveBackoffJitter?: boolean;
+ foregroundDebounceMs?: number;
+ heartbeatIntervalMs?: number;
+ resumeHeartbeatSuppressMs?: number;
+ heartbeatMaxBackoffMs?: number;
+};
+
export type ClientConfig = {
defaultHomeserver?: number;
homeserverList?: string[];
@@ -14,6 +39,8 @@ export type ClientConfig = {
disableAccountSwitcher?: boolean;
hideUsernamePasswordFields?: boolean;
+ experiments?: Record;
+
pushNotificationDetails?: {
pushNotifyUrl?: string;
vapidPublicKey?: string;
@@ -43,6 +70,7 @@ export type ClientConfig = {
matrixToBaseUrl?: string;
settingsLinkBaseUrl?: string;
+ sessionSync?: SessionSyncConfig;
/** How long (ms) without input before auto-idling presence. 0 = disabled. */
presenceAutoIdleTimeoutMs?: number;
};
@@ -57,6 +85,72 @@ export function useClientConfig(): ClientConfig {
return config;
}
+const DEFAULT_CONTROL_VARIANT = 'control';
+
+const normalizeRolloutPercentage = (value?: number): number => {
+ if (typeof value !== 'number' || Number.isNaN(value)) return 100;
+ if (value < 0) return 0;
+ if (value > 100) return 100;
+ return value;
+};
+
+const hashToUInt32 = (input: string): number => {
+ let hash = 0;
+ for (let index = 0; index < input.length; index += 1) {
+ hash = (hash * 131 + input.charCodeAt(index)) % 4294967291;
+ }
+ return hash;
+};
+
+export const selectExperimentVariant = (
+ key: string,
+ experiment: ExperimentConfig | undefined,
+ subjectId: string | undefined
+): ExperimentSelection => {
+ const controlVariant = experiment?.controlVariant ?? DEFAULT_CONTROL_VARIANT;
+ const variants = (experiment?.variants?.filter((variant) => variant.length > 0) ?? []).filter(
+ (variant) => variant !== controlVariant
+ );
+ const enabled = experiment?.enabled === true;
+ const rolloutPercentage = normalizeRolloutPercentage(experiment?.rolloutPercentage);
+
+ if (!enabled || !subjectId || variants.length === 0 || rolloutPercentage === 0) {
+ return {
+ key,
+ enabled,
+ rolloutPercentage,
+ variant: controlVariant,
+ inExperiment: false,
+ };
+ }
+
+ const rolloutBucket = hashToUInt32(`${key}:rollout:${subjectId}`) % 10000;
+ const rolloutCutoff = Math.floor(rolloutPercentage * 100);
+ if (rolloutBucket >= rolloutCutoff) {
+ return {
+ key,
+ enabled,
+ rolloutPercentage,
+ variant: controlVariant,
+ inExperiment: false,
+ };
+ }
+
+ const variantIndex = hashToUInt32(`${key}:variant:${subjectId}`) % variants.length;
+ return {
+ key,
+ enabled,
+ rolloutPercentage,
+ variant: variants[variantIndex],
+ inExperiment: true,
+ };
+};
+
+export const useExperimentVariant = (key: string, subjectId?: string): ExperimentSelection => {
+ const clientConfig = useClientConfig();
+ return selectExperimentVariant(key, clientConfig.experiments?.[key], subjectId);
+};
+
export const clientDefaultServer = (clientConfig: ClientConfig): string =>
clientConfig.homeserverList?.[clientConfig.defaultHomeserver ?? 0] ?? 'matrix.org';
diff --git a/src/app/hooks/useUserPresence.test.tsx b/src/app/hooks/useUserPresence.test.tsx
index 125629137..c311563b6 100644
--- a/src/app/hooks/useUserPresence.test.tsx
+++ b/src/app/hooks/useUserPresence.test.tsx
@@ -6,21 +6,25 @@ import { useUserPresence, Presence } from './useUserPresence';
// Each test can override mockUser / mockGetPresence as needed.
let mockUser: ReturnType | null = null;
-let mockGetPresence: ReturnType;
-
-vi.mock('$hooks/useMatrixClient', () => ({
- useMatrixClient: () => mockMx,
-}));
+type PresenceResponse = {
+ presence: string;
+ status_msg?: string;
+ currently_active?: boolean;
+ last_active_ago?: number | null;
+};
+let mockGetPresence: () => Promise;
// Listeners registered via user.on() β captured so tests can emit events.
const userListeners = new Map void)[]>();
-const makeMockUser = (opts: {
- presence?: string;
- presenceStatusMsg?: string | undefined;
- currentlyActive?: boolean;
- lastActiveTs?: number;
-} = {}) => ({
+const makeMockUser = (
+ opts: {
+ presence?: string;
+ presenceStatusMsg?: string | undefined;
+ currentlyActive?: boolean;
+ lastActiveTs?: number;
+ } = {}
+) => ({
userId: '@alice:test',
presence: opts.presence ?? 'online',
presenceStatusMsg: opts.presenceStatusMsg,
@@ -36,26 +40,22 @@ const makeMockUser = (opts: {
const mockMx = {
getUser: vi.fn((): ReturnType | null => mockUser),
- getPresence: vi.fn(
- (): Promise<{
- presence: string;
- status_msg?: string;
- currently_active?: boolean;
- last_active_ago?: number | null;
- }> =>
- mockGetPresence()
- ),
+ getPresence: vi.fn((): Promise => mockGetPresence()),
on: vi.fn(),
removeListener: vi.fn(),
};
+vi.mock('$hooks/useMatrixClient', () => ({
+ useMatrixClient: () => mockMx,
+}));
+
const USER_ID = '@alice:test';
beforeEach(() => {
vi.clearAllMocks();
userListeners.clear();
mockUser = null;
- mockGetPresence = vi.fn().mockReturnValue(new Promise(() => {})); // pending by default
+ mockGetPresence = () => new Promise(() => {}); // pending by default
mockMx.getUser.mockImplementation(() => mockUser);
mockMx.getPresence.mockImplementation(() => mockGetPresence());
});
@@ -91,9 +91,10 @@ describe('useUserPresence', () => {
currently_active?: boolean;
last_active_ago?: number;
}) => void;
- mockGetPresence = vi
- .fn()
- .mockReturnValue(new Promise((res) => { resolvePresence = res; }));
+ mockGetPresence = () =>
+ new Promise((res) => {
+ resolvePresence = res;
+ });
const { result } = renderHook(() => useUserPresence(USER_ID));
@@ -116,9 +117,10 @@ describe('useUserPresence', () => {
it('fires the REST fallback when user object does not exist yet', async () => {
// user is null β REST should still be requested
let resolvePresence!: (v: { presence: string }) => void;
- mockGetPresence = vi
- .fn()
- .mockReturnValue(new Promise((res) => { resolvePresence = res; }));
+ mockGetPresence = () =>
+ new Promise((res) => {
+ resolvePresence = res;
+ });
const { result } = renderHook(() => useUserPresence(USER_ID));
@@ -140,9 +142,11 @@ describe('useUserPresence', () => {
it('ignores the REST response after the component unmounts (cancelled flag)', async () => {
let resolvePresence!: (v: { presence: string }) => void;
- mockGetPresence = vi
- .fn()
- .mockReturnValue(new Promise((res) => { resolvePresence = res; }));
+ mockGetPresence = vi.fn().mockReturnValue(
+ new Promise((res) => {
+ resolvePresence = res;
+ })
+ );
const { result, unmount } = renderHook(() => useUserPresence(USER_ID));
unmount();
@@ -157,12 +161,12 @@ describe('useUserPresence', () => {
it('updates presence when UserEvent.Presence fires on the user object', () => {
mockUser = makeMockUser({ presence: 'online', lastActiveTs: 1000 });
- mockGetPresence = vi.fn().mockReturnValue(new Promise(() => {}));
+ mockGetPresence = () => new Promise(() => {});
const { result } = renderHook(() => useUserPresence(USER_ID));
// Mutate mock user to simulate a presence change, then fire the registered listener
- mockUser!.presence = 'unavailable';
+ mockUser.presence = 'unavailable';
const handlers = userListeners.get('User.presence') ?? [];
act(() => {
@@ -174,7 +178,7 @@ describe('useUserPresence', () => {
it('resets to undefined when userId changes to a user not in the SDK', () => {
mockUser = makeMockUser({ presence: 'online', lastActiveTs: 1000 });
- mockGetPresence = vi.fn().mockReturnValue(new Promise(() => {}));
+ mockGetPresence = () => new Promise(() => {});
const { result, rerender } = renderHook(({ uid }) => useUserPresence(uid), {
initialProps: { uid: USER_ID },
@@ -190,7 +194,7 @@ describe('useUserPresence', () => {
});
it('silently ignores a REST error (presence not supported on this server)', async () => {
- mockGetPresence = vi.fn().mockReturnValue(Promise.reject(new Error('404 Not Found')));
+ mockGetPresence = () => Promise.reject(new Error('404 Not Found'));
const { result } = renderHook(() => useUserPresence(USER_ID));
From 878f2fcd2221ac0357f3281145b6473434d0334b Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sun, 12 Apr 2026 17:51:05 -0400
Subject: [PATCH 091/253] fix(presence): resolve missing deps and stabilize
presence hook tests
---
.../settings/developer-tools/DevelopTools.tsx | 2 -
src/app/hooks/useClientConfig.ts | 94 +++++++++++++++++++
src/app/hooks/useUserPresence.test.tsx | 72 +++++++-------
src/app/pages/client/ClientNonUIFeatures.tsx | 7 --
4 files changed, 132 insertions(+), 43 deletions(-)
diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx
index a499faf9c..4e38f7868 100644
--- a/src/app/features/settings/developer-tools/DevelopTools.tsx
+++ b/src/app/features/settings/developer-tools/DevelopTools.tsx
@@ -14,7 +14,6 @@ import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback';
import { SettingsSectionPage } from '../SettingsSectionPage';
import { AccountData } from './AccountData';
import { SyncDiagnostics } from './SyncDiagnostics';
-import { ExperimentsPanel } from './ExperimentsPanel';
import { DebugLogViewer } from './DebugLogViewer';
import { SentrySettings } from './SentrySettings';
@@ -139,7 +138,6 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp
)}
{developerTools && }
- {developerTools && }
{developerTools && (
Encryption
diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts
index e523f15a7..b6eb0dea3 100644
--- a/src/app/hooks/useClientConfig.ts
+++ b/src/app/hooks/useClientConfig.ts
@@ -5,6 +5,31 @@ export type HashRouterConfig = {
basename?: string;
};
+export type ExperimentConfig = {
+ enabled?: boolean;
+ rolloutPercentage?: number;
+ variants?: string[];
+ controlVariant?: string;
+};
+
+export type ExperimentSelection = {
+ key: string;
+ enabled: boolean;
+ rolloutPercentage: number;
+ variant: string;
+ inExperiment: boolean;
+};
+
+export type SessionSyncConfig = {
+ phase1ForegroundResync?: boolean;
+ phase2VisibleHeartbeat?: boolean;
+ phase3AdaptiveBackoffJitter?: boolean;
+ foregroundDebounceMs?: number;
+ heartbeatIntervalMs?: number;
+ resumeHeartbeatSuppressMs?: number;
+ heartbeatMaxBackoffMs?: number;
+};
+
export type ClientConfig = {
defaultHomeserver?: number;
homeserverList?: string[];
@@ -14,6 +39,8 @@ export type ClientConfig = {
disableAccountSwitcher?: boolean;
hideUsernamePasswordFields?: boolean;
+ experiments?: Record;
+
pushNotificationDetails?: {
pushNotifyUrl?: string;
vapidPublicKey?: string;
@@ -43,6 +70,7 @@ export type ClientConfig = {
matrixToBaseUrl?: string;
settingsLinkBaseUrl?: string;
+ sessionSync?: SessionSyncConfig;
};
const ClientConfigContext = createContext(null);
@@ -55,6 +83,72 @@ export function useClientConfig(): ClientConfig {
return config;
}
+const DEFAULT_CONTROL_VARIANT = 'control';
+
+const normalizeRolloutPercentage = (value?: number): number => {
+ if (typeof value !== 'number' || Number.isNaN(value)) return 100;
+ if (value < 0) return 0;
+ if (value > 100) return 100;
+ return value;
+};
+
+const hashToUInt32 = (input: string): number => {
+ let hash = 0;
+ for (let index = 0; index < input.length; index += 1) {
+ hash = (hash * 131 + input.charCodeAt(index)) % 4294967291;
+ }
+ return hash;
+};
+
+export const selectExperimentVariant = (
+ key: string,
+ experiment: ExperimentConfig | undefined,
+ subjectId: string | undefined
+): ExperimentSelection => {
+ const controlVariant = experiment?.controlVariant ?? DEFAULT_CONTROL_VARIANT;
+ const variants = (experiment?.variants?.filter((variant) => variant.length > 0) ?? []).filter(
+ (variant) => variant !== controlVariant
+ );
+ const enabled = experiment?.enabled === true;
+ const rolloutPercentage = normalizeRolloutPercentage(experiment?.rolloutPercentage);
+
+ if (!enabled || !subjectId || variants.length === 0 || rolloutPercentage === 0) {
+ return {
+ key,
+ enabled,
+ rolloutPercentage,
+ variant: controlVariant,
+ inExperiment: false,
+ };
+ }
+
+ const rolloutBucket = hashToUInt32(`${key}:rollout:${subjectId}`) % 10000;
+ const rolloutCutoff = Math.floor(rolloutPercentage * 100);
+ if (rolloutBucket >= rolloutCutoff) {
+ return {
+ key,
+ enabled,
+ rolloutPercentage,
+ variant: controlVariant,
+ inExperiment: false,
+ };
+ }
+
+ const variantIndex = hashToUInt32(`${key}:variant:${subjectId}`) % variants.length;
+ return {
+ key,
+ enabled,
+ rolloutPercentage,
+ variant: variants[variantIndex],
+ inExperiment: true,
+ };
+};
+
+export const useExperimentVariant = (key: string, subjectId?: string): ExperimentSelection => {
+ const clientConfig = useClientConfig();
+ return selectExperimentVariant(key, clientConfig.experiments?.[key], subjectId);
+};
+
export const clientDefaultServer = (clientConfig: ClientConfig): string =>
clientConfig.homeserverList?.[clientConfig.defaultHomeserver ?? 0] ?? 'matrix.org';
diff --git a/src/app/hooks/useUserPresence.test.tsx b/src/app/hooks/useUserPresence.test.tsx
index 125629137..c311563b6 100644
--- a/src/app/hooks/useUserPresence.test.tsx
+++ b/src/app/hooks/useUserPresence.test.tsx
@@ -6,21 +6,25 @@ import { useUserPresence, Presence } from './useUserPresence';
// Each test can override mockUser / mockGetPresence as needed.
let mockUser: ReturnType | null = null;
-let mockGetPresence: ReturnType;
-
-vi.mock('$hooks/useMatrixClient', () => ({
- useMatrixClient: () => mockMx,
-}));
+type PresenceResponse = {
+ presence: string;
+ status_msg?: string;
+ currently_active?: boolean;
+ last_active_ago?: number | null;
+};
+let mockGetPresence: () => Promise;
// Listeners registered via user.on() β captured so tests can emit events.
const userListeners = new Map void)[]>();
-const makeMockUser = (opts: {
- presence?: string;
- presenceStatusMsg?: string | undefined;
- currentlyActive?: boolean;
- lastActiveTs?: number;
-} = {}) => ({
+const makeMockUser = (
+ opts: {
+ presence?: string;
+ presenceStatusMsg?: string | undefined;
+ currentlyActive?: boolean;
+ lastActiveTs?: number;
+ } = {}
+) => ({
userId: '@alice:test',
presence: opts.presence ?? 'online',
presenceStatusMsg: opts.presenceStatusMsg,
@@ -36,26 +40,22 @@ const makeMockUser = (opts: {
const mockMx = {
getUser: vi.fn((): ReturnType | null => mockUser),
- getPresence: vi.fn(
- (): Promise<{
- presence: string;
- status_msg?: string;
- currently_active?: boolean;
- last_active_ago?: number | null;
- }> =>
- mockGetPresence()
- ),
+ getPresence: vi.fn((): Promise => mockGetPresence()),
on: vi.fn(),
removeListener: vi.fn(),
};
+vi.mock('$hooks/useMatrixClient', () => ({
+ useMatrixClient: () => mockMx,
+}));
+
const USER_ID = '@alice:test';
beforeEach(() => {
vi.clearAllMocks();
userListeners.clear();
mockUser = null;
- mockGetPresence = vi.fn().mockReturnValue(new Promise(() => {})); // pending by default
+ mockGetPresence = () => new Promise(() => {}); // pending by default
mockMx.getUser.mockImplementation(() => mockUser);
mockMx.getPresence.mockImplementation(() => mockGetPresence());
});
@@ -91,9 +91,10 @@ describe('useUserPresence', () => {
currently_active?: boolean;
last_active_ago?: number;
}) => void;
- mockGetPresence = vi
- .fn()
- .mockReturnValue(new Promise((res) => { resolvePresence = res; }));
+ mockGetPresence = () =>
+ new Promise((res) => {
+ resolvePresence = res;
+ });
const { result } = renderHook(() => useUserPresence(USER_ID));
@@ -116,9 +117,10 @@ describe('useUserPresence', () => {
it('fires the REST fallback when user object does not exist yet', async () => {
// user is null β REST should still be requested
let resolvePresence!: (v: { presence: string }) => void;
- mockGetPresence = vi
- .fn()
- .mockReturnValue(new Promise((res) => { resolvePresence = res; }));
+ mockGetPresence = () =>
+ new Promise((res) => {
+ resolvePresence = res;
+ });
const { result } = renderHook(() => useUserPresence(USER_ID));
@@ -140,9 +142,11 @@ describe('useUserPresence', () => {
it('ignores the REST response after the component unmounts (cancelled flag)', async () => {
let resolvePresence!: (v: { presence: string }) => void;
- mockGetPresence = vi
- .fn()
- .mockReturnValue(new Promise((res) => { resolvePresence = res; }));
+ mockGetPresence = vi.fn().mockReturnValue(
+ new Promise((res) => {
+ resolvePresence = res;
+ })
+ );
const { result, unmount } = renderHook(() => useUserPresence(USER_ID));
unmount();
@@ -157,12 +161,12 @@ describe('useUserPresence', () => {
it('updates presence when UserEvent.Presence fires on the user object', () => {
mockUser = makeMockUser({ presence: 'online', lastActiveTs: 1000 });
- mockGetPresence = vi.fn().mockReturnValue(new Promise(() => {}));
+ mockGetPresence = () => new Promise(() => {});
const { result } = renderHook(() => useUserPresence(USER_ID));
// Mutate mock user to simulate a presence change, then fire the registered listener
- mockUser!.presence = 'unavailable';
+ mockUser.presence = 'unavailable';
const handlers = userListeners.get('User.presence') ?? [];
act(() => {
@@ -174,7 +178,7 @@ describe('useUserPresence', () => {
it('resets to undefined when userId changes to a user not in the SDK', () => {
mockUser = makeMockUser({ presence: 'online', lastActiveTs: 1000 });
- mockGetPresence = vi.fn().mockReturnValue(new Promise(() => {}));
+ mockGetPresence = () => new Promise(() => {});
const { result, rerender } = renderHook(({ uid }) => useUserPresence(uid), {
initialProps: { uid: USER_ID },
@@ -190,7 +194,7 @@ describe('useUserPresence', () => {
});
it('silently ignores a REST error (presence not supported on this server)', async () => {
- mockGetPresence = vi.fn().mockReturnValue(Promise.reject(new Error('404 Not Found')));
+ mockGetPresence = () => Promise.reject(new Error('404 Not Found'));
const { result } = renderHook(() => useUserPresence(USER_ID));
diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx
index 260f1dc28..65aadac4f 100644
--- a/src/app/pages/client/ClientNonUIFeatures.tsx
+++ b/src/app/pages/client/ClientNonUIFeatures.tsx
@@ -56,7 +56,6 @@ import { useCallSignaling } from '$hooks/useCallSignaling';
import { getBlobCacheStats } from '$hooks/useBlobCache';
import { lastVisitedRoomIdAtom } from '$state/room/lastRoom';
import { useSettingsSyncEffect } from '$hooks/useSettingsSync';
-import { useInitBookmarks } from '$features/bookmarks/useInitBookmarks';
import { getInboxInvitesPath } from '../pathUtils';
import { BackgroundNotifications } from './BackgroundNotifications';
@@ -877,17 +876,11 @@ function SettingsSyncFeature() {
return null;
}
-function BookmarksFeature() {
- useInitBookmarks();
- return null;
-}
-
export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
useCallSignaling();
return (
<>
-
From e4d24b2df6ec91e374f3636105f66d6280827bc5 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Mon, 13 Apr 2026 01:48:18 -0400
Subject: [PATCH 092/253] test(bookmarks): format repository tests for prettier
compliance
---
src/app/features/bookmarks/bookmarkRepository.test.ts | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/src/app/features/bookmarks/bookmarkRepository.test.ts b/src/app/features/bookmarks/bookmarkRepository.test.ts
index d9724cf67..0489fe692 100644
--- a/src/app/features/bookmarks/bookmarkRepository.test.ts
+++ b/src/app/features/bookmarks/bookmarkRepository.test.ts
@@ -262,7 +262,9 @@ describe('removeBookmark', () => {
});
await expect(removeBookmark(mx, item.bookmark_id)).resolves.not.toThrow();
- const stored = (mx as any)._store[bookmarkItemEventType(item.bookmark_id)] as BookmarkItemContent;
+ const stored = (mx as any)._store[
+ bookmarkItemEventType(item.bookmark_id)
+ ] as BookmarkItemContent;
expect(stored.deleted).toBe(true);
});
From 05fa6575c70f9c77dac15b5f479d168452be7d43 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Mon, 13 Apr 2026 11:12:46 -0400
Subject: [PATCH 093/253] fix: address PR #589 review comments
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Add stable m.poll.* type aliases alongside unstable MSC3381 types
- Register stable poll types in useTimelineEventRenderer
- Fix datetime-local timezone bug in PollCreatorDialog (UTCβlocal)
- Add FocusOutline from folds for keyboard a11y on poll options
- Add MatrixEventEvent.Decrypted listener for encrypted poll responses
- Support multi-select polls with Checkbox component
---
.../features/room/poll/PollCreatorDialog.tsx | 10 +-
src/app/features/room/poll/PollEvent.css.ts | 54 +++---
src/app/features/room/poll/PollEvent.tsx | 25 ++-
.../timeline/useTimelineEventRenderer.tsx | 155 ++++++++++--------
src/types/matrix/room.ts | 6 +-
5 files changed, 149 insertions(+), 101 deletions(-)
diff --git a/src/app/features/room/poll/PollCreatorDialog.tsx b/src/app/features/room/poll/PollCreatorDialog.tsx
index f364f143f..c56bf5edd 100644
--- a/src/app/features/room/poll/PollCreatorDialog.tsx
+++ b/src/app/features/room/poll/PollCreatorDialog.tsx
@@ -69,11 +69,13 @@ export function PollCreatorDialog({ onCancel, onSubmit }: PollCreatorDialogProps
const [error, setError] = useState();
const lastInputRef = useRef(null);
- const minDatetime = useMemo(
- () => new Date(Date.now() + 60_000).toISOString().slice(0, 16),
+ const minDatetime = useMemo(() => {
+ const d = new Date(Date.now() + 60_000);
+ // datetime-local expects local time, not UTC β build YYYY-MM-DDTHH:MM manually
+ const pad = (n: number) => String(n).padStart(2, '0');
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
// eslint-disable-next-line react-hooks/exhaustive-deps
- [expiryPreset]
- );
+ }, [expiryPreset]);
const computeClosesAt = (): number | undefined => {
const now = Date.now();
diff --git a/src/app/features/room/poll/PollEvent.css.ts b/src/app/features/room/poll/PollEvent.css.ts
index 26e5433f9..103e87050 100644
--- a/src/app/features/room/poll/PollEvent.css.ts
+++ b/src/app/features/room/poll/PollEvent.css.ts
@@ -1,33 +1,41 @@
import { style } from '@vanilla-extract/css';
-import { config } from 'folds';
+import { config, FocusOutline } from 'folds';
// Vote button wrapping just the radio circle - minimal touch target
-export const RadioZone = style({
- all: 'unset',
- cursor: 'pointer',
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
- flexShrink: 0,
- padding: `${config.space.S100} 0`,
- selectors: {
- '&:disabled': {
- cursor: 'default',
+export const RadioZone = style([
+ FocusOutline,
+ {
+ all: 'unset',
+ cursor: 'pointer',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ flexShrink: 0,
+ padding: `${config.space.S100} 0`,
+ borderRadius: config.radii.R300,
+ selectors: {
+ '&:disabled': {
+ cursor: 'default',
+ },
},
},
-});
+]);
// Text + percent area - clickable to reveal voters
-export const AnswerTextButton = style({
- all: 'unset',
- cursor: 'pointer',
- display: 'flex',
- flex: 1,
- alignItems: 'center',
- gap: config.space.S200,
- minWidth: 0,
- padding: `${config.space.S100} 0`,
-});
+export const AnswerTextButton = style([
+ FocusOutline,
+ {
+ all: 'unset',
+ cursor: 'pointer',
+ display: 'flex',
+ flex: 1,
+ alignItems: 'center',
+ gap: config.space.S200,
+ minWidth: 0,
+ padding: `${config.space.S100} 0`,
+ borderRadius: config.radii.R300,
+ },
+]);
// Non-interactive version of the text area
export const AnswerTextRow = style({
diff --git a/src/app/features/room/poll/PollEvent.tsx b/src/app/features/room/poll/PollEvent.tsx
index 1565c1c30..678a9cfd2 100644
--- a/src/app/features/room/poll/PollEvent.tsx
+++ b/src/app/features/room/poll/PollEvent.tsx
@@ -3,6 +3,7 @@ import FocusTrap from 'focus-trap-react';
import {
Box,
Button,
+ Checkbox,
config,
Icon,
Icons,
@@ -21,6 +22,7 @@ import {
M_POLL_RESPONSE,
M_POLL_START,
MatrixEvent,
+ MatrixEventEvent,
Room,
RoomEvent,
} from '$types/matrix-sdk';
@@ -200,6 +202,20 @@ export function PollEvent({ room, mEvent, canEnd, outlined }: PollEventProps) {
};
}, [room, pollEventId]);
+ // Also re-compute when an encrypted poll response/end is decrypted
+ useEffect(() => {
+ const onDecrypted = (event: MatrixEvent) => {
+ if (M_POLL_RESPONSE.matches(event.getType()) || M_POLL_END.matches(event.getType())) {
+ const relTo = event.getContent()?.['m.relates_to']?.event_id;
+ if (relTo === pollEventId) incrementTick();
+ }
+ };
+ mx.on(MatrixEventEvent.Decrypted, onDecrypted);
+ return () => {
+ mx.off(MatrixEventEvent.Decrypted, onDecrypted);
+ };
+ }, [mx, pollEventId]);
+
// Re-render when the expiry countdown reaches zero
useEffect(() => {
if (!pollData?.closesAt) return undefined;
@@ -276,7 +292,8 @@ export function PollEvent({ room, mEvent, canEnd, outlined }: PollEventProps) {
if (!pollData) return null;
- const { question, answers, isDisclosed, closesAt } = pollData;
+ const { question, answers, isDisclosed, closesAt, maxSelections } = pollData;
+ const isMultiSelect = maxSelections > 1;
const voterLabel = `${totalVoters} ${totalVoters === 1 ? 'voter' : 'voters'}`;
let statusText: string;
@@ -383,7 +400,11 @@ export function PollEvent({ room, mEvent, canEnd, outlined }: PollEventProps) {
aria-pressed={isSelected}
aria-label={`Vote for ${answer.text}`}
>
-
+ {isMultiSelect ? (
+
+ ) : (
+
+ )}
{textZone}
diff --git a/src/app/hooks/timeline/useTimelineEventRenderer.tsx b/src/app/hooks/timeline/useTimelineEventRenderer.tsx
index dbfa13721..ec7478c66 100644
--- a/src/app/hooks/timeline/useTimelineEventRenderer.tsx
+++ b/src/app/hooks/timeline/useTimelineEventRenderer.tsx
@@ -299,6 +299,85 @@ export function useTimelineEventRenderer({
}: TimelineEventRendererOptions) {
const { t } = useTranslation();
+ // Shared poll start renderer β used for both unstable and stable event types
+ const renderPollStart = (
+ mEventId: string,
+ mEvent: MatrixEvent,
+ item: number,
+ timelineSet: EventTimelineSet,
+ collapse: boolean
+ ) => {
+ const { getSender, getAssociatedStatus, isRedacted, getUnsigned } = mEvent;
+ const reactionRelations = getEventReactions(timelineSet, mEventId);
+ const reactions = reactionRelations?.getSortedAnnotationsByKey();
+ const hasReactions = reactions && reactions.length > 0;
+ const highlighted = focusItem?.index === item && focusItem.highlight;
+ const senderId = getSender.call(mEvent) ?? '';
+ const senderDisplayName =
+ getMemberDisplayName(room, senderId, nicknames) ?? getMxIdLocalPart(senderId) ?? senderId;
+ const myUserId = mx.getUserId() ?? '';
+ const canEnd = myUserId === senderId || canRedact;
+
+ return (
+
+ ) : undefined
+ }
+ hideReadReceipts={hideReads}
+ showDeveloperTools={showDeveloperTools}
+ memberPowerTag={getMemberPowerTag(senderId)}
+ hour24Clock={hour24Clock}
+ dateFormatString={dateFormatString}
+ >
+ {isRedacted.call(mEvent) ? (
+
+ ) : (
+
+ )}
+
+ );
+ };
+
return useMatrixEventRenderer<[string, MatrixEvent, number, EventTimelineSet, boolean]>(
{
[MessageEvent.RoomMessage]: (mEventId, mEvent, item, timelineSet, collapse) => {
@@ -1080,81 +1159,15 @@ export function useTimelineEventRenderer({
);
},
- [MessageEvent.PollStart]: (mEventId, mEvent, item, timelineSet, collapse) => {
- const { getSender, getAssociatedStatus, isRedacted, getUnsigned } = mEvent;
- const reactionRelations = getEventReactions(timelineSet, mEventId);
- const reactions = reactionRelations?.getSortedAnnotationsByKey();
- const hasReactions = reactions && reactions.length > 0;
- const highlighted = focusItem?.index === item && focusItem.highlight;
- const senderId = getSender.call(mEvent) ?? '';
- const senderDisplayName =
- getMemberDisplayName(room, senderId, nicknames) ?? getMxIdLocalPart(senderId) ?? senderId;
- const myUserId = mx.getUserId() ?? '';
- const canEnd = myUserId === senderId || canRedact;
-
- return (
-
- ) : undefined
- }
- hideReadReceipts={hideReads}
- showDeveloperTools={showDeveloperTools}
- memberPowerTag={getMemberPowerTag(senderId)}
- hour24Clock={hour24Clock}
- dateFormatString={dateFormatString}
- >
- {isRedacted.call(mEvent) ? (
-
- ) : (
-
- )}
-
- );
- },
+ [MessageEvent.PollStart]: renderPollStart,
+ [MessageEvent.StablePollStart]: renderPollStart,
// Poll response and end events are not rendered individually β
// they update the poll via RoomEvent.Timeline listeners in PollEvent.
[MessageEvent.PollResponse]: () => null,
[MessageEvent.PollEnd]: () => null,
+ // Stable poll type aliases (m.poll.*)
+ [MessageEvent.StablePollResponse]: () => null,
+ [MessageEvent.StablePollEnd]: () => null,
},
(mEventId, mEvent, item, timelineSet, collapse) => {
const { getSender, getTs, getType } = mEvent;
diff --git a/src/types/matrix/room.ts b/src/types/matrix/room.ts
index 3c252b7f0..10f9fd859 100644
--- a/src/types/matrix/room.ts
+++ b/src/types/matrix/room.ts
@@ -58,10 +58,14 @@ export enum MessageEvent {
Sticker = 'm.sticker',
RoomRedaction = 'm.room.redaction',
Reaction = 'm.reaction',
- // MSC3381 Polls β unstable prefix (stable types not yet in a shipped room version)
+ // MSC3381 Polls β unstable prefix (still the most common in the wild)
PollStart = 'org.matrix.msc3381.poll.start',
PollResponse = 'org.matrix.msc3381.poll.response',
PollEnd = 'org.matrix.msc3381.poll.end',
+ // MSC3381 Polls β stable types (used by newer servers)
+ StablePollStart = 'm.poll.start',
+ StablePollResponse = 'm.poll.response',
+ StablePollEnd = 'm.poll.end',
}
export enum RoomType {
From a2d568350c03af53c64360d7947d2023ad09f73f Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Mon, 13 Apr 2026 16:27:43 -0400
Subject: [PATCH 094/253] fix(bookmarks): react to item-level account data
events and fix remove() optimistic update
- useInitBookmarks: also trigger loadBookmarks on bookmark item events
(prefix org.matrix.msc4438.bookmark.*), not just the index event.
Fixes cross-device sync not updating the UI when sliding sync
delivers individual item events.
- useBookmarks: remove() now moves the item to bookmarkDeletedListAtom
optimistically so the Recently Removed section updates immediately.
- Added regression tests for both fixes.
---
.../features/bookmarks/useBookmarks.test.tsx | 132 +++++++++++++++
src/app/features/bookmarks/useBookmarks.ts | 15 +-
.../bookmarks/useInitBookmarks.test.tsx | 155 ++++++++++++++++++
.../features/bookmarks/useInitBookmarks.ts | 10 +-
4 files changed, 307 insertions(+), 5 deletions(-)
create mode 100644 src/app/features/bookmarks/useBookmarks.test.tsx
create mode 100644 src/app/features/bookmarks/useInitBookmarks.test.tsx
diff --git a/src/app/features/bookmarks/useBookmarks.test.tsx b/src/app/features/bookmarks/useBookmarks.test.tsx
new file mode 100644
index 000000000..e09f446c2
--- /dev/null
+++ b/src/app/features/bookmarks/useBookmarks.test.tsx
@@ -0,0 +1,132 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import { createStore, Provider } from 'jotai';
+import { createElement, type ReactNode } from 'react';
+import { bookmarkListAtom, bookmarkDeletedListAtom } from '$state/bookmarks';
+import { useBookmarkActions } from './useBookmarks';
+import type { BookmarkItemContent } from './bookmarkDomain';
+
+// ---------------------------------------------------------------------------
+// Mocks
+// ---------------------------------------------------------------------------
+
+const { mockMx } = vi.hoisted(() => {
+ const store: Record = {};
+ return {
+ mockMx: {
+ getAccountData: vi.fn((type: string) => {
+ const content = store[type];
+ if (!content) return undefined;
+ return { getContent: () => content };
+ }),
+ setAccountData: vi.fn(async (type: string, content: unknown) => {
+ store[type] = content;
+ }),
+ store: { accountData: new Map() },
+ },
+ };
+});
+
+vi.mock('$hooks/useMatrixClient', () => ({
+ useMatrixClient: () => mockMx,
+}));
+
+// Mock the repository so removeBookmark doesn't try to read real account data
+vi.mock('./bookmarkRepository', async (importOriginal) => {
+ const orig = await importOriginal();
+ return {
+ ...orig,
+ removeBookmark: vi.fn(async () => {}),
+ addBookmark: vi.fn(async () => {}),
+ };
+});
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function makeItem(id: string): BookmarkItemContent {
+ return {
+ version: 1,
+ bookmark_id: id,
+ uri: `matrix:roomid/foo/e/${id}`,
+ room_id: '!room:s',
+ event_id: `$${id}:s`,
+ event_ts: 1_000,
+ bookmarked_ts: 2_000,
+ };
+}
+
+function makeWrapper(store: ReturnType) {
+ return function Wrapper({ children }: { children: ReactNode }) {
+ return createElement(Provider, { store }, children);
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe('useBookmarkActions.remove', () => {
+ let store: ReturnType;
+
+ beforeEach(() => {
+ store = createStore();
+ });
+
+ it('moves item from active list to deleted list optimistically', async () => {
+ const item = makeItem('bmk_1111');
+ store.set(bookmarkListAtom, [item]);
+ store.set(bookmarkDeletedListAtom, []);
+
+ const { result } = renderHook(() => useBookmarkActions(), {
+ wrapper: makeWrapper(store),
+ });
+
+ await act(async () => {
+ await result.current.remove('bmk_1111');
+ });
+
+ expect(store.get(bookmarkListAtom)).toHaveLength(0);
+
+ const deleted = store.get(bookmarkDeletedListAtom);
+ expect(deleted).toHaveLength(1);
+ expect(deleted[0].bookmark_id).toBe('bmk_1111');
+ expect(deleted[0].deleted).toBe(true);
+ });
+
+ it('does not duplicate item in deleted list if already present', async () => {
+ const item = makeItem('bmk_2222');
+ const deletedItem = { ...item, deleted: true as const };
+ store.set(bookmarkListAtom, [item]);
+ store.set(bookmarkDeletedListAtom, [deletedItem]);
+
+ const { result } = renderHook(() => useBookmarkActions(), {
+ wrapper: makeWrapper(store),
+ });
+
+ await act(async () => {
+ await result.current.remove('bmk_2222');
+ });
+
+ expect(store.get(bookmarkDeletedListAtom)).toHaveLength(1);
+ });
+
+ it('handles removing a non-existent item gracefully', async () => {
+ store.set(bookmarkListAtom, [makeItem('bmk_3333')]);
+ store.set(bookmarkDeletedListAtom, []);
+
+ const { result } = renderHook(() => useBookmarkActions(), {
+ wrapper: makeWrapper(store),
+ });
+
+ await act(async () => {
+ await result.current.remove('bmk_nonexistent');
+ });
+
+ // Original item untouched
+ expect(store.get(bookmarkListAtom)).toHaveLength(1);
+ // Nothing added to deleted list since the item wasn't found
+ expect(store.get(bookmarkDeletedListAtom)).toHaveLength(0);
+ });
+});
diff --git a/src/app/features/bookmarks/useBookmarks.ts b/src/app/features/bookmarks/useBookmarks.ts
index df9346dfd..c6fac5746 100644
--- a/src/app/features/bookmarks/useBookmarks.ts
+++ b/src/app/features/bookmarks/useBookmarks.ts
@@ -79,11 +79,20 @@ export function useBookmarkActions() {
const remove = useCallback(
async (bookmarkId: string) => {
- // Optimistic update
- setList((prev) => prev.filter((b) => b.bookmark_id !== bookmarkId));
+ // Optimistic update: move from active list to deleted list
+ setList((prev) => {
+ const removed = prev.find((b) => b.bookmark_id === bookmarkId);
+ if (removed) {
+ setDeletedList((del) => {
+ if (del.some((b) => b.bookmark_id === bookmarkId)) return del;
+ return [{ ...removed, deleted: true }, ...del];
+ });
+ }
+ return prev.filter((b) => b.bookmark_id !== bookmarkId);
+ });
await removeBookmark(mx, bookmarkId);
},
- [mx, setList]
+ [mx, setList, setDeletedList]
);
const restore = useCallback(
diff --git a/src/app/features/bookmarks/useInitBookmarks.test.tsx b/src/app/features/bookmarks/useInitBookmarks.test.tsx
new file mode 100644
index 000000000..893b5d64c
--- /dev/null
+++ b/src/app/features/bookmarks/useInitBookmarks.test.tsx
@@ -0,0 +1,155 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { renderHook } from '@testing-library/react';
+import { createStore, Provider } from 'jotai';
+import { createElement, type ReactNode } from 'react';
+import { bookmarkListAtom, bookmarkDeletedListAtom } from '$state/bookmarks';
+import { useInitBookmarks } from './useInitBookmarks';
+import type { BookmarkItemContent, BookmarkIndexContent } from './bookmarkDomain';
+
+// ---------------------------------------------------------------------------
+// Mocks
+// ---------------------------------------------------------------------------
+
+const BOOKMARKS_INDEX = 'org.matrix.msc4438.bookmarks.index';
+const BOOKMARK_PREFIX = 'org.matrix.msc4438.bookmark.';
+
+const { accountDataCB, syncStateCB, mockMx } = vi.hoisted(() => {
+ const adCB: { current: ((event: { getType: () => string }) => void) | null } = { current: null };
+ const ssCB: { current: ((state: string, prev: string) => void) | null } = { current: null };
+
+ const item: BookmarkItemContent = {
+ version: 1,
+ bookmark_id: 'bmk_aabb',
+ uri: 'matrix:roomid/foo/e/bar',
+ room_id: '!room:s',
+ event_id: '$ev:s',
+ event_ts: 1_000,
+ bookmarked_ts: 2_000,
+ };
+ const deletedItem: BookmarkItemContent = {
+ version: 1,
+ bookmark_id: 'bmk_ccdd',
+ uri: 'matrix:roomid/baz/e/qux',
+ room_id: '!room2:s',
+ event_id: '$ev2:s',
+ event_ts: 3_000,
+ bookmarked_ts: 4_000,
+ deleted: true,
+ };
+ const index: BookmarkIndexContent = {
+ version: 1,
+ revision: 1,
+ updated_ts: 5_000,
+ bookmark_ids: ['bmk_aabb', 'bmk_ccdd'],
+ };
+
+ const store: Record = {
+ ['org.matrix.msc4438.bookmarks.index']: index,
+ ['org.matrix.msc4438.bookmark.bmk_aabb']: item,
+ ['org.matrix.msc4438.bookmark.bmk_ccdd']: deletedItem,
+ };
+
+ const mx = {
+ getAccountData: vi.fn((type: string) => {
+ const content = store[type];
+ if (!content) return undefined;
+ return { getContent: () => content };
+ }),
+ setAccountData: vi.fn(),
+ store: { accountData: new Map(Object.entries(store)) },
+ };
+
+ return { accountDataCB: adCB, syncStateCB: ssCB, mockMx: mx };
+});
+
+vi.mock('$hooks/useMatrixClient', () => ({
+ useMatrixClient: () => mockMx,
+}));
+
+vi.mock('$hooks/useAccountDataCallback', () => ({
+ useAccountDataCallback: (
+ _mx: unknown,
+ cb: (event: { getType: () => string }) => void
+ ) => {
+ accountDataCB.current = cb;
+ },
+}));
+
+vi.mock('$hooks/useSyncState', () => ({
+ useSyncState: (_mx: unknown, cb: (state: string, prev: string) => void) => {
+ syncStateCB.current = cb;
+ },
+}));
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function makeStore() {
+ return createStore();
+}
+
+function makeWrapper(store: ReturnType) {
+ return function Wrapper({ children }: { children: ReactNode }) {
+ return createElement(Provider, { store }, children);
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe('useInitBookmarks', () => {
+ let store: ReturnType;
+
+ beforeEach(() => {
+ store = makeStore();
+ accountDataCB.current = null;
+ syncStateCB.current = null;
+ });
+
+ it('loads bookmarks on mount', () => {
+ renderHook(() => useInitBookmarks(), { wrapper: makeWrapper(store) });
+
+ const list = store.get(bookmarkListAtom);
+ const deleted = store.get(bookmarkDeletedListAtom);
+ expect(list).toHaveLength(1);
+ expect(list[0].bookmark_id).toBe('bmk_aabb');
+ expect(deleted).toHaveLength(1);
+ expect(deleted[0].bookmark_id).toBe('bmk_ccdd');
+ });
+
+ it('reloads when BookmarksIndex account data event fires', () => {
+ renderHook(() => useInitBookmarks(), { wrapper: makeWrapper(store) });
+
+ // Clear the atom to prove the callback re-populates it
+ store.set(bookmarkListAtom, []);
+
+ accountDataCB.current!({ getType: () => BOOKMARKS_INDEX });
+
+ expect(store.get(bookmarkListAtom)).toHaveLength(1);
+ });
+
+ it('reloads when a bookmark item account data event fires', () => {
+ renderHook(() => useInitBookmarks(), { wrapper: makeWrapper(store) });
+
+ store.set(bookmarkListAtom, []);
+
+ accountDataCB.current!({
+ getType: () => `${BOOKMARK_PREFIX}bmk_aabb`,
+ });
+
+ expect(store.get(bookmarkListAtom)).toHaveLength(1);
+ });
+
+ it('ignores unrelated account data events', () => {
+ renderHook(() => useInitBookmarks(), { wrapper: makeWrapper(store) });
+
+ store.set(bookmarkListAtom, []);
+
+ accountDataCB.current!({ getType: () => 'm.room.message' });
+
+ // Should still be empty β callback should not have triggered a reload
+ expect(store.get(bookmarkListAtom)).toHaveLength(0);
+ });
+});
diff --git a/src/app/features/bookmarks/useInitBookmarks.ts b/src/app/features/bookmarks/useInitBookmarks.ts
index 3b6cb2247..480e20083 100644
--- a/src/app/features/bookmarks/useInitBookmarks.ts
+++ b/src/app/features/bookmarks/useInitBookmarks.ts
@@ -57,12 +57,18 @@ export function useInitBookmarks(): void {
)
);
- // React to index updates pushed by other devices mid-session.
+ // React to bookmark account data changes pushed by other devices mid-session.
+ // The index event fires when the bookmark list changes; individual item events
+ // fire when a bookmark is added, removed, or soft-deleted.
useAccountDataCallback(
mx,
useCallback(
(event: MatrixEvent) => {
- if (event.getType() === (AccountDataEvent.BookmarksIndex as string)) {
+ const type = event.getType();
+ if (
+ type === (AccountDataEvent.BookmarksIndex as string) ||
+ type.startsWith(AccountDataEvent.BookmarkItemPrefix as string)
+ ) {
loadBookmarks();
}
},
From 64f531b4c3c2874fe61a4d239968b32a8f2aa193 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Thu, 9 Apr 2026 20:05:50 -0400
Subject: [PATCH 095/253] perf: memoize VList timeline items to prevent mass
re-renders
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- useTimelineSync: add mutationVersion counter, incremented only on
mutations (reactions, edits, local-echo, thread updates) via a new
triggerMutation() callback. Live event arrivals do NOT bump it β the
eventsLength change already signals those.
- useProcessedTimeline: add stableRefsCache (useRef