From 7c28e2bda2287c74d84f1df732a7929141a5f0a2 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sat, 21 Mar 2026 17:39:43 -0400
Subject: [PATCH 001/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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/191] 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 4eeaa380192efac2b1f44bfdc63bbb255b86a133 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sun, 12 Apr 2026 10:01:36 -0400
Subject: [PATCH 095/191] feat(room-nav): show topic/last-message preview for
space and home rooms
---
src/app/features/room-nav/RoomNavItem.tsx | 10 +-
.../features/settings/cosmetics/Themes.tsx | 31 ++++++
src/app/hooks/useRoomLastMessage.ts | 95 +++++++++++++++++++
src/app/pages/client/home/Home.tsx | 4 +
src/app/pages/client/space/Space.tsx | 4 +
src/app/state/settings.ts | 4 +
6 files changed, 147 insertions(+), 1 deletion(-)
create mode 100644 src/app/hooks/useRoomLastMessage.ts
diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx
index 22886c224..9689e2852 100644
--- a/src/app/features/room-nav/RoomNavItem.tsx
+++ b/src/app/features/room-nav/RoomNavItem.tsx
@@ -70,6 +70,7 @@ import { useAutoDiscoveryInfo } from '$hooks/useAutoDiscoveryInfo';
import { livekitSupport } from '$hooks/useLivekitSupport';
import { Presence, useUserPresence } from '$hooks/useUserPresence';
import { AvatarPresence, PresenceBadge } from '$components/presence';
+import { useRoomLastMessage } from '$hooks/useRoomLastMessage';
import { RoomNavUser } from './RoomNavUser';
/**
@@ -258,6 +259,8 @@ type RoomNavItemProps = {
showAvatar?: boolean;
direct?: boolean;
customDMCards?: boolean;
+ roomTopicPreview?: boolean;
+ roomMessagePreview?: boolean;
};
export function RoomNavItem({
@@ -266,6 +269,8 @@ export function RoomNavItem({
showAvatar,
direct,
customDMCards,
+ roomTopicPreview = false,
+ roomMessagePreview = false,
notificationMode,
linkPath,
}: RoomNavItemProps) {
@@ -287,8 +292,11 @@ export function RoomNavItem({
const matrixRoomName = useRoomName(room);
const roomName = (dmUserId && nicknames[dmUserId]) || matrixRoomName;
const presence = useUserPresence(dmUserId ?? '');
+ const lastMessage = useRoomLastMessage(!direct && roomMessagePreview ? room : undefined, mx);
const getRoomTopic = useRoomTopic(room);
- const roomTopic = direct ? ((customDMCards && getRoomTopic) ?? presence?.status) : undefined;
+ const roomTopic = direct
+ ? ((customDMCards && getRoomTopic) ?? presence?.status)
+ : (roomTopicPreview && getRoomTopic) || (roomMessagePreview ? lastMessage : undefined);
const { navigateRoom } = useRoomNavigate();
const navigate = useNavigate();
diff --git a/src/app/features/settings/cosmetics/Themes.tsx b/src/app/features/settings/cosmetics/Themes.tsx
index f543a19ea..0af66fb93 100644
--- a/src/app/features/settings/cosmetics/Themes.tsx
+++ b/src/app/features/settings/cosmetics/Themes.tsx
@@ -487,6 +487,11 @@ export function Appearance() {
settingsAtom,
'closeFoldersByDefault'
);
+ const [roomTopicPreview, setRoomTopicPreview] = useSetting(settingsAtom, 'roomTopicPreview');
+ const [roomMessagePreview, setRoomMessagePreview] = useSetting(
+ settingsAtom,
+ 'roomMessagePreview'
+ );
return (
@@ -529,6 +534,32 @@ export function Appearance() {
/>
+
+
+ }
+ />
+
+
+
+
+ }
+ />
+
+
eventToPreviewText(ev) !== undefined);
+ if (!match) return undefined;
+ const text = eventToPreviewText(match);
+ if (!text) return undefined;
+
+ const senderId = match.getSender();
+ let prefix: string;
+ if (senderId === mx.getUserId()) {
+ prefix = 'You';
+ } else {
+ prefix = room.getMember(senderId ?? '')?.name ?? senderId ?? 'Unknown';
+ }
+ return `${prefix}: ${text}`;
+}
+
+/**
+ * Reactively returns a human-readable preview of the last message in a room's
+ * live timeline, prefixed with "You:" or the sender's display name.
+ * Listens to Timeline and Decrypted events so the preview updates as messages
+ * arrive or are decrypted.
+ * Pass `undefined` for room to disable (returns `undefined`).
+ */
+export function useRoomLastMessage(
+ room: Room | undefined,
+ mx: MatrixClient | undefined
+): string | undefined {
+ const [text, setText] = useState(() =>
+ room && mx ? getLastMessageText(room, mx) : undefined
+ );
+
+ useEffect(() => {
+ if (!room || !mx) {
+ setText(undefined);
+ return undefined;
+ }
+ setText(getLastMessageText(room, mx));
+
+ const update = () => setText(getLastMessageText(room, mx));
+ room.on(RoomEventEnum.Timeline, update);
+ room.on(RoomEventEnum.LocalEchoUpdated, update);
+
+ // Re-check when any event in this room is decrypted (encrypted β plaintext).
+ const onDecrypted = (ev: MatrixEvent) => {
+ if (ev.getRoomId() === room.roomId) update();
+ };
+ mx.on(MatrixEventEvent.Decrypted, onDecrypted);
+
+ return () => {
+ room.off(RoomEventEnum.Timeline, update);
+ room.off(RoomEventEnum.LocalEchoUpdated, update);
+ mx.off(MatrixEventEvent.Decrypted, onDecrypted);
+ };
+ }, [room, mx]);
+
+ return text;
+}
diff --git a/src/app/pages/client/home/Home.tsx b/src/app/pages/client/home/Home.tsx
index c25d99e30..5ceda0a1e 100644
--- a/src/app/pages/client/home/Home.tsx
+++ b/src/app/pages/client/home/Home.tsx
@@ -199,6 +199,8 @@ export function Home() {
const notificationPreferences = useRoomsNotificationPreferencesContext();
const roomToUnread = useAtomValue(roomToUnreadAtom);
const navigate = useNavigate();
+ const [roomTopicPreview] = useSetting(settingsAtom, 'roomTopicPreview');
+ const [roomMessagePreview] = useSetting(settingsAtom, 'roomMessagePreview');
const selectedRoomId = useSelectedRoom();
const createRoomSelected = useHomeCreateSelected();
@@ -344,6 +346,8 @@ export function Home() {
Date: Sun, 12 Apr 2026 11:50:31 -0400
Subject: [PATCH 096/191] fix(sliding-sync): increase LIST_TIMELINE_LIMIT to 5
for message previews
With timeline_limit: 1, if the latest event is a reaction or edit, the SDK
drops it from getLiveTimeline() because it cannot resolve the parent event
from a single-event batch. This leaves the timeline empty and breaks the
room message preview. Fetching 5 events ensures the parent message is
present alongside reactions/edits so the SDK places them correctly and
getLastMessageText finds a displayable preview.
---
src/client/slidingSync.ts | 9 ++++++---
1 file changed, 6 insertions(+), 3 deletions(-)
diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts
index 43fdf39ea..9e3590098 100644
--- a/src/client/slidingSync.ts
+++ b/src/client/slidingSync.ts
@@ -34,9 +34,12 @@ export const LIST_SEARCH = 'search';
export const LIST_ROOM_SEARCH = 'room_search';
// Dynamic list key used for space-scoped room views.
export const LIST_SPACE = 'space';
-// One event of timeline per list room is enough to compute unread counts;
-// the full history is loaded when the user opens the room.
-const LIST_TIMELINE_LIMIT = 1;
+// A small number of timeline events per list room. Unread counts come from
+// the server-side notification_count field, so a full history isn't needed.
+// We fetch a few events (rather than 1) so that reactions and edits β which
+// the SDK excludes from the main timeline when their parent event is absent β
+// don't leave the timeline empty and break message previews.
+const LIST_TIMELINE_LIMIT = 5;
const DEFAULT_LIST_PAGE_SIZE = 250;
const DEFAULT_POLL_TIMEOUT_MS = 20000;
const DEFAULT_MAX_ROOMS = 5000;
From 9036ec9ea7ff4632c5ad4d06b2de3056cfc88215 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sun, 12 Apr 2026 12:10:58 -0400
Subject: [PATCH 097/191] chore: add changeset for room-message-preview
---
.changeset/room-message-preview.md | 5 +++++
1 file changed, 5 insertions(+)
create mode 100644 .changeset/room-message-preview.md
diff --git a/.changeset/room-message-preview.md b/.changeset/room-message-preview.md
new file mode 100644
index 000000000..4f8d1cef8
--- /dev/null
+++ b/.changeset/room-message-preview.md
@@ -0,0 +1,5 @@
+---
+'@sable/client': minor
+---
+
+feat(room-nav): show topic and last-message preview for rooms in the sidebar, fetching enough timeline events to handle reactions and edits correctly
From db9c1a41cf2cd84f115773efaa6e84a156f962ee Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sat, 11 Apr 2026 22:17:21 -0400
Subject: [PATCH 098/191] feat(dm-list): show latest message preview below room
name
---
src/app/features/room-nav/RoomNavItem.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx
index 9689e2852..3ded50a4a 100644
--- a/src/app/features/room-nav/RoomNavItem.tsx
+++ b/src/app/features/room-nav/RoomNavItem.tsx
@@ -292,10 +292,10 @@ export function RoomNavItem({
const matrixRoomName = useRoomName(room);
const roomName = (dmUserId && nicknames[dmUserId]) || matrixRoomName;
const presence = useUserPresence(dmUserId ?? '');
- const lastMessage = useRoomLastMessage(!direct && roomMessagePreview ? room : undefined, mx);
+ const lastMessage = useRoomLastMessage(roomMessagePreview ? room : undefined, mx);
const getRoomTopic = useRoomTopic(room);
const roomTopic = direct
- ? ((customDMCards && getRoomTopic) ?? presence?.status)
+ ? ((customDMCards && getRoomTopic) ?? lastMessage ?? presence?.status)
: (roomTopicPreview && getRoomTopic) || (roomMessagePreview ? lastMessage : undefined);
const { navigateRoom } = useRoomNavigate();
From 216aa6a77d67141e4cb46e9e7ad1c4dbdb2f7692 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sat, 11 Apr 2026 23:13:41 -0400
Subject: [PATCH 099/191] chore: add changeset for dm message preview
---
.changeset/feat-dm-message-preview.md | 5 +++++
1 file changed, 5 insertions(+)
create mode 100644 .changeset/feat-dm-message-preview.md
diff --git a/.changeset/feat-dm-message-preview.md b/.changeset/feat-dm-message-preview.md
new file mode 100644
index 000000000..ab8e37801
--- /dev/null
+++ b/.changeset/feat-dm-message-preview.md
@@ -0,0 +1,5 @@
+---
+'@sable/client': minor
+---
+
+feat(dm-list): show last-message preview below DM room name
From b848ac23aaa4df18a98630c6d74863a4ab05056f Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sun, 12 Apr 2026 00:18:31 -0400
Subject: [PATCH 100/191] feat(dm-list): add toggle to hide DM message preview
---
src/app/features/room-nav/RoomNavItem.tsx | 5 ++++-
src/app/features/settings/cosmetics/Themes.tsx | 9 +++++++++
src/app/pages/client/direct/Direct.tsx | 2 ++
src/app/state/settings.ts | 2 ++
4 files changed, 17 insertions(+), 1 deletion(-)
diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx
index 3ded50a4a..be950e3e1 100644
--- a/src/app/features/room-nav/RoomNavItem.tsx
+++ b/src/app/features/room-nav/RoomNavItem.tsx
@@ -261,6 +261,7 @@ type RoomNavItemProps = {
customDMCards?: boolean;
roomTopicPreview?: boolean;
roomMessagePreview?: boolean;
+ dmMessagePreview?: boolean;
};
export function RoomNavItem({
@@ -271,6 +272,7 @@ export function RoomNavItem({
customDMCards,
roomTopicPreview = false,
roomMessagePreview = false,
+ dmMessagePreview = true,
notificationMode,
linkPath,
}: RoomNavItemProps) {
@@ -292,7 +294,8 @@ export function RoomNavItem({
const matrixRoomName = useRoomName(room);
const roomName = (dmUserId && nicknames[dmUserId]) || matrixRoomName;
const presence = useUserPresence(dmUserId ?? '');
- const lastMessage = useRoomLastMessage(roomMessagePreview ? room : undefined, mx);
+ const showPreview = direct ? dmMessagePreview : roomMessagePreview;
+ const lastMessage = useRoomLastMessage(showPreview ? room : undefined, mx);
const getRoomTopic = useRoomTopic(room);
const roomTopic = direct
? ((customDMCards && getRoomTopic) ?? lastMessage ?? presence?.status)
diff --git a/src/app/features/settings/cosmetics/Themes.tsx b/src/app/features/settings/cosmetics/Themes.tsx
index 0af66fb93..7dc978dcd 100644
--- a/src/app/features/settings/cosmetics/Themes.tsx
+++ b/src/app/features/settings/cosmetics/Themes.tsx
@@ -482,6 +482,7 @@ function PageZoomInput() {
export function Appearance() {
const [twitterEmoji, setTwitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
const [customDMCards, setCustomDMCards] = useSetting(settingsAtom, 'customDMCards');
+ const [dmMessagePreview, setDmMessagePreview] = useSetting(settingsAtom, 'dmMessagePreview');
const [showEasterEggs, setShowEasterEggs] = useSetting(settingsAtom, 'showEasterEggs');
const [closeFoldersByDefault, setCloseFoldersByDefault] = useSetting(
settingsAtom,
@@ -532,6 +533,14 @@ export function Appearance() {
description="Show a custom DM card instead of the DM-ed's details"
after={}
/>
+
+ }
+ />
diff --git a/src/app/pages/client/direct/Direct.tsx b/src/app/pages/client/direct/Direct.tsx
index 11eae40c3..3b78f43aa 100644
--- a/src/app/pages/client/direct/Direct.tsx
+++ b/src/app/pages/client/direct/Direct.tsx
@@ -178,6 +178,7 @@ export function Direct() {
const roomToUnread = useAtomValue(roomToUnreadAtom);
const navigate = useNavigate();
const [customDMCards] = useSetting(settingsAtom, 'customDMCards');
+ const [dmMessagePreview] = useSetting(settingsAtom, 'dmMessagePreview');
const createDirectSelected = useDirectCreateSelected();
@@ -296,6 +297,7 @@ export function Direct() {
showAvatar
direct
customDMCards={customDMCards}
+ dmMessagePreview={dmMessagePreview}
linkPath={getDirectRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
notificationMode={getRoomNotificationMode(
notificationPreferences,
diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts
index d60f3e829..36dba8cb0 100644
--- a/src/app/state/settings.ts
+++ b/src/app/state/settings.ts
@@ -120,6 +120,7 @@ export interface Settings {
closeFoldersByDefault: boolean;
roomTopicPreview: boolean;
roomMessagePreview: boolean;
+ dmMessagePreview: boolean;
// furry stuff
renderAnimals: boolean;
@@ -223,6 +224,7 @@ const defaultSettings: Settings = {
closeFoldersByDefault: false,
roomTopicPreview: false,
roomMessagePreview: false,
+ dmMessagePreview: true,
// furry stuff
renderAnimals: true,
From ec10020bd83a4be97df9bf556225d86249ec91c0 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sun, 12 Apr 2026 09:32:44 -0400
Subject: [PATCH 101/191] fix(settings): give DM Message Preview its own card
in Visual Tweaks
---
src/app/features/settings/cosmetics/Themes.tsx | 3 +++
1 file changed, 3 insertions(+)
diff --git a/src/app/features/settings/cosmetics/Themes.tsx b/src/app/features/settings/cosmetics/Themes.tsx
index 7dc978dcd..0df6e5ade 100644
--- a/src/app/features/settings/cosmetics/Themes.tsx
+++ b/src/app/features/settings/cosmetics/Themes.tsx
@@ -533,6 +533,9 @@ export function Appearance() {
description="Show a custom DM card instead of the DM-ed's details"
after={}
/>
+
+
+
Date: Mon, 13 Apr 2026 22:50:43 -0400
Subject: [PATCH 102/191] refactor(sliding-sync): gate listTimelineLimit behind
message preview settings
LIST_TIMELINE_LIMIT is now configurable via SlidingSyncConfig.listTimelineLimit
(default: 1). When dmMessagePreview or roomMessagePreview is enabled, the limit
is bumped to 5 so reactions/edits don't leave the preview empty. Users with both
preview settings disabled keep the lightweight limit of 1.
---
src/app/pages/client/ClientRoot.tsx | 15 +++++++++++----
src/client/slidingSync.ts | 25 ++++++++++++++-----------
2 files changed, 25 insertions(+), 15 deletions(-)
diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx
index 1a653e950..754c28bef 100644
--- a/src/app/pages/client/ClientRoot.tsx
+++ b/src/app/pages/client/ClientRoot.tsx
@@ -48,6 +48,7 @@ import { useSyncNicknames } from '$hooks/useNickname';
import { useAppVisibility } from '$hooks/useAppVisibility';
import { getHomePath } from '$pages/pathUtils';
import { useClientConfig } from '$hooks/useClientConfig';
+import { getSettings } from '$state/settings';
import { pushSessionToSW } from '../../../sw-session';
import { SyncStatus } from './SyncStatus';
import { SpecVersions } from './SpecVersions';
@@ -212,12 +213,18 @@ export function ClientRoot({ children }: ClientRootProps) {
const [startState, startMatrix] = useAsyncCallback(
useCallback(
- (m) =>
- startClient(m, {
+ (m) => {
+ const s = getSettings();
+ const needsPreviewTimeline = s.dmMessagePreview || s.roomMessagePreview;
+ return startClient(m, {
baseUrl: activeSession?.baseUrl,
- slidingSync: clientConfig.slidingSync,
+ slidingSync: {
+ ...clientConfig.slidingSync,
+ listTimelineLimit: needsPreviewTimeline ? 5 : undefined,
+ },
sessionSlidingSyncOptIn: activeSession?.slidingSyncOptIn,
- }),
+ });
+ },
[activeSession?.baseUrl, activeSession?.slidingSyncOptIn, clientConfig.slidingSync]
)
);
diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts
index 9e3590098..802157123 100644
--- a/src/client/slidingSync.ts
+++ b/src/client/slidingSync.ts
@@ -36,10 +36,9 @@ export const LIST_ROOM_SEARCH = 'room_search';
export const LIST_SPACE = 'space';
// A small number of timeline events per list room. Unread counts come from
// the server-side notification_count field, so a full history isn't needed.
-// We fetch a few events (rather than 1) so that reactions and edits β which
-// the SDK excludes from the main timeline when their parent event is absent β
-// don't leave the timeline empty and break message previews.
-const LIST_TIMELINE_LIMIT = 5;
+// When message previews are enabled, a higher limit (e.g. 5) avoids empty
+// timelines caused by reactions/edits whose parent event is absent.
+const DEFAULT_LIST_TIMELINE_LIMIT = 1;
const DEFAULT_LIST_PAGE_SIZE = 250;
const DEFAULT_POLL_TIMEOUT_MS = 20000;
const DEFAULT_MAX_ROOMS = 5000;
@@ -53,7 +52,7 @@ const LIST_SORT_ORDER = ['by_recency', 'by_name'];
// Encrypted rooms get [*,*] required_state; unencrypted rooms also request lazy members.
const UNENCRYPTED_SUBSCRIPTION_KEY = 'unencrypted';
// Timeline limit for the active-room subscription (full history load).
-// List entries always use LIST_TIMELINE_LIMIT=1 for lightweight previews.
+// List entries use a small timeline limit (default 1) for lightweight previews.
const ACTIVE_ROOM_TIMELINE_LIMIT = 50;
export type PartialSlidingSyncRequest = {
@@ -67,6 +66,7 @@ export type SlidingSyncConfig = {
proxyBaseUrl?: string;
bootstrapClassicOnColdCache?: boolean;
listPageSize?: number;
+ listTimelineLimit?: number;
timelineLimit?: number;
pollTimeoutMs?: number;
maxRooms?: number;
@@ -147,7 +147,7 @@ const buildUnencryptedSubscription = (timelineLimit: number): MSC3575RoomSubscri
],
});
-const buildLists = (pageSize: number, includeInviteList: boolean): Map => {
+const buildLists = (pageSize: number, includeInviteList: boolean, listTimelineLimit: number): Map => {
const lists = new Map();
const listRequiredState = buildListRequiredState();
@@ -159,7 +159,7 @@ const buildLists = (pageSize: number, includeInviteList: boolean): Map void;
@@ -303,12 +305,13 @@ export class SlidingSyncManager {
this.maxRooms = clampPositive(config.maxRooms, DEFAULT_MAX_ROOMS);
this.listPageSize = listPageSize;
const includeInviteList = config.includeInviteList !== false;
+ this.listTimelineLimit = clampPositive(config.listTimelineLimit, DEFAULT_LIST_TIMELINE_LIMIT);
const roomTimelineLimit = clampPositive(config.timelineLimit, ACTIVE_ROOM_TIMELINE_LIMIT);
this.roomTimelineLimit = roomTimelineLimit;
const defaultSubscription = buildEncryptedSubscription(roomTimelineLimit);
- const lists = buildLists(listPageSize, includeInviteList);
+ const lists = buildLists(listPageSize, includeInviteList, this.listTimelineLimit);
this.listKeys = Array.from(lists.keys());
this.slidingSync = new SlidingSync(proxyBaseUrl, lists, defaultSubscription, mx, pollTimeoutMs);
@@ -720,7 +723,7 @@ export class SlidingSyncManager {
list = {
ranges: [[0, 20]],
sort: LIST_SORT_ORDER,
- timeline_limit: LIST_TIMELINE_LIMIT,
+ timeline_limit: this.listTimelineLimit,
required_state: buildListRequiredState(),
...updateArgs,
};
From 581f2cecbd09c1891ff0656028f304f30f0b6a04 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Tue, 31 Mar 2026 10:46:35 -0400
Subject: [PATCH 103/191] feat(dev-tools): add rotate-sessions developer tool
---
.../settings/developer-tools/DevelopTools.tsx | 82 ++++++++++++++++++-
1 file changed, 81 insertions(+), 1 deletion(-)
diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx
index c8ffeb12d..6478d9582 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';
@@ -10,6 +11,7 @@ import { AccountDataEditor, AccountDataSubmitCallback } from '$components/Accoun
import { copyToClipboard } from '$utils/dom';
import { SequenceCardStyle } from '$features/settings/styles.css';
import { SettingsSectionPage } from '../SettingsSectionPage';
+import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback';
import { AccountData } from './AccountData';
import { SyncDiagnostics } from './SyncDiagnostics';
import { DebugLogViewer } from './DebugLogViewer';
@@ -25,6 +27,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 +138,57 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp
)}
{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 && (
Date: Tue, 31 Mar 2026 12:06:31 -0400
Subject: [PATCH 104/191] chore: add changeset for devtool-rotate-sessions
---
.changeset/devtool-rotate-sessions.md | 5 +++++
src/app/features/settings/developer-tools/DevelopTools.tsx | 2 +-
2 files changed, 6 insertions(+), 1 deletion(-)
create mode 100644 .changeset/devtool-rotate-sessions.md
diff --git a/.changeset/devtool-rotate-sessions.md b/.changeset/devtool-rotate-sessions.md
new file mode 100644
index 000000000..2730ad8e6
--- /dev/null
+++ b/.changeset/devtool-rotate-sessions.md
@@ -0,0 +1,5 @@
+---
+default: patch
+---
+
+Add rotate-sessions developer tool to force session rotation for testing
diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx
index 6478d9582..c9d934aec 100644
--- a/src/app/features/settings/developer-tools/DevelopTools.tsx
+++ b/src/app/features/settings/developer-tools/DevelopTools.tsx
@@ -10,8 +10,8 @@ import { useMatrixClient } from '$hooks/useMatrixClient';
import { AccountDataEditor, AccountDataSubmitCallback } from '$components/AccountDataEditor';
import { copyToClipboard } from '$utils/dom';
import { SequenceCardStyle } from '$features/settings/styles.css';
-import { SettingsSectionPage } from '../SettingsSectionPage';
import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback';
+import { SettingsSectionPage } from '../SettingsSectionPage';
import { AccountData } from './AccountData';
import { SyncDiagnostics } from './SyncDiagnostics';
import { DebugLogViewer } from './DebugLogViewer';
From 4b29d2990ea447f2a005f2fa184d2cfae91085da Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Tue, 14 Apr 2026 15:20:39 -0400
Subject: [PATCH 105/191] fix: use || instead of ?? for DM preview fallback
chain The nullish coalescing operator (??) only falls through on
null/undefined, but (customDMCards && getRoomTopic) can evaluate to false or
empty string, which blocked the fallback to lastMessage. Using || ensures all
falsy values correctly fall through to show the message preview. This caused
DM message previews to not appear in /direct while the same rooms showed
previews in space views (where customDMCards was undefined).
---
src/app/features/room-nav/RoomNavItem.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx
index be950e3e1..68ead11d8 100644
--- a/src/app/features/room-nav/RoomNavItem.tsx
+++ b/src/app/features/room-nav/RoomNavItem.tsx
@@ -298,7 +298,7 @@ export function RoomNavItem({
const lastMessage = useRoomLastMessage(showPreview ? room : undefined, mx);
const getRoomTopic = useRoomTopic(room);
const roomTopic = direct
- ? ((customDMCards && getRoomTopic) ?? lastMessage ?? presence?.status)
+ ? (customDMCards && getRoomTopic) || lastMessage || presence?.status
: (roomTopicPreview && getRoomTopic) || (roomMessagePreview ? lastMessage : undefined);
const { navigateRoom } = useRoomNavigate();
From 2202bec58b871386f23e3cfbd54fd521b39dbb10 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Tue, 14 Apr 2026 22:53:32 -0400
Subject: [PATCH 106/191] fix(bookmarks): add Fragment key, guard missing
eventId, fix removeBookmark doc comment
- Wrap grouped bookmark list items in instead of un-keyed <>
- Return null from MessageBookmarkItem when mEvent.getId() is undefined
- Update removeBookmark doc comment to match item-first deletion ordering
---
src/app/features/bookmarks/bookmarkRepository.ts | 11 +++++++----
src/app/features/room/message/Message.tsx | 5 +++--
src/app/pages/client/bookmarks/BookmarksList.tsx | 7 +++----
3 files changed, 13 insertions(+), 10 deletions(-)
diff --git a/src/app/features/bookmarks/bookmarkRepository.ts b/src/app/features/bookmarks/bookmarkRepository.ts
index bd9cda928..1b6cf0208 100644
--- a/src/app/features/bookmarks/bookmarkRepository.ts
+++ b/src/app/features/bookmarks/bookmarkRepository.ts
@@ -74,12 +74,15 @@ export async function addBookmark(mx: MatrixClient, item: BookmarkItemContent):
* Remove a bookmark.
*
* MSC4438 Β§Removing a bookmark:
- * 1. Remove the ID from the index.
- * 2. Soft-delete the item (set deleted: true).
+ * 1. Soft-delete the item first (set deleted: true).
+ * 2. Remove the ID from the index.
+ * 3. Increment revision and update timestamp.
+ * 4. Write the updated index.
*
* 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.
+ * used. This implementation intentionally tombstones the item before updating
+ * the index to mirror addBookmark()'s item-first ordering and avoid transient
+ * orphan recovery/resurrection if a removal only partially completes.
*/
export async function removeBookmark(mx: MatrixClient, bookmarkId: string): Promise {
// Tombstone the item event directly β bypass readItem()'s validation so that
diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx
index 6652e1844..ec17635fe 100644
--- a/src/app/features/room/message/Message.tsx
+++ b/src/app/features/room/message/Message.tsx
@@ -224,10 +224,11 @@ export const MessageBookmarkItem = as<
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 eventId = mEvent.getId();
+ const isBookmarked = useIsBookmarked(room.roomId, eventId ?? '');
const { add, remove } = useBookmarkActions();
+ if (!eventId) return null;
if (!bookmarksExperiment.inExperiment && !enableMessageBookmarks) return null;
const handleClick = async () => {
diff --git a/src/app/pages/client/bookmarks/BookmarksList.tsx b/src/app/pages/client/bookmarks/BookmarksList.tsx
index 562a4920c..a24b661c8 100644
--- a/src/app/pages/client/bookmarks/BookmarksList.tsx
+++ b/src/app/pages/client/bookmarks/BookmarksList.tsx
@@ -1,4 +1,4 @@
-import { FormEventHandler, useCallback, useMemo, useRef, useState } from 'react';
+import { FormEventHandler, Fragment, useCallback, useMemo, useRef, useState } from 'react';
import {
Avatar,
Box,
@@ -561,10 +561,9 @@ export function BookmarksList() {
{groupedByRoom.length > 0 && (
{groupedByRoom.map((group, i) => (
- <>
+
{i > 0 && }
- >
+
))}
)}
From cd5192bdd6a8aa7725f2b8094a3733ce9dcc170d Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Tue, 14 Apr 2026 22:55:00 -0400
Subject: [PATCH 107/191] fix(dev-tools): address review feedback for
rotate-sessions
- Import KnownMembership from $types/matrix-sdk instead of matrix-js-sdk/lib/types
- Use Promise.allSettled to handle partial failures and report accurate count
- Add window.confirm confirmation before discarding sessions
- Clarify changeset: 'encryption sessions' not 'sessions'
---
.changeset/devtool-rotate-sessions.md | 2 +-
build.log | 114 ++++++++++++++++++
full_output.txt | 0
lint_output.txt | 62 ++++++++++
.../settings/developer-tools/DevelopTools.tsx | 16 ++-
test_file.txt | 1 +
6 files changed, 191 insertions(+), 4 deletions(-)
create mode 100644 build.log
create mode 100644 full_output.txt
create mode 100644 lint_output.txt
create mode 100644 test_file.txt
diff --git a/.changeset/devtool-rotate-sessions.md b/.changeset/devtool-rotate-sessions.md
index 2730ad8e6..cae1bda0c 100644
--- a/.changeset/devtool-rotate-sessions.md
+++ b/.changeset/devtool-rotate-sessions.md
@@ -2,4 +2,4 @@
default: patch
---
-Add rotate-sessions developer tool to force session rotation for testing
+Add rotate-encryption-sessions developer tool to force Megolm session rotation for testing
diff --git a/build.log b/build.log
new file mode 100644
index 000000000..1f5016d8d
--- /dev/null
+++ b/build.log
@@ -0,0 +1,114 @@
+
+> sable@1.14.0 build /Users/evie/git/Sable
+> vite build
+
+vite v7.3.1 building client environment for production...
+transforming...
+β 8032 modules transformed.
+rendering chunks...
+computing gzip size...
+[vite-plugin-static-copy] Copied 10 items.
+dist/.assetsignore 0.02 kB
+dist/assets/index-Ds5yF1gS.js.br 0.07 kB
+dist/assets/index-Ds5yF1gS.js.map.br 0.41 kB
+dist/wrangler.json.br 0.42 kB
+dist/assets/cinny-logo-maskable-36x36-CT1Bq-jJ.png 0.79 kB
+dist/index.html.br 0.88 kB
+dist/assets/cinny-logo-maskable-DXG8PKa6.svg.br 0.96 kB
+dist/wrangler.json 1.00 kB β gzip: 0.54 kB
+dist/assets/cinny-logo-maskable-48x48-DfxQ4FEO.png 1.08 kB
+dist/assets/cinny-logo-maskable-57x57-B_a6RKlt.png 1.28 kB
+dist/assets/cinny-logo-maskable-60x60-uAvO2kwU.png 1.35 kB
+dist/assets/cinny-logo-maskable-72x72-ChMts4zW.png 1.59 kB
+dist/assets/cinny-logo-maskable-96x96-DeladQJ2.png 2.08 kB
+dist/assets/cinny-logo-maskable-114x114-BRz0LILn.png 2.45 kB
+dist/assets/cinny-logo-maskable-DXG8PKa6.svg 2.56 kB β gzip: 1.12 kB
+dist/assets/cinny-logo-maskable-120x120-BBcwUWQZ.png 2.59 kB
+dist/assets/cinny-logo-maskable-144x144-CitAVeb0.png 3.08 kB
+dist/assets/cinny-logo-maskable-152x152-Di3nKfIW.png 3.26 kB
+dist/assets/cinny-logo-maskable-167x167-Dk9KG9Yi.png 3.59 kB
+dist/assets/cinny-logo-maskable-180x180-RAgvvHf4.png 3.79 kB
+dist/assets/cinny-logo-maskable-192x192-BHxZuLYc.png 4.06 kB
+dist/assets/favicon-CemZgig7.png 4.15 kB
+dist/index.html 4.28 kB β gzip: 1.17 kB
+dist/assets/arborium-4uGfF3G6.js.br 4.54 kB
+dist/assets/arborium-4uGfF3G6.js.map.br 4.98 kB
+dist/assets/space-mono-vietnamese-700-italic-BwPKnf-l.woff 5.24 kB
+dist/assets/space-mono-vietnamese-400-normal-B0PMp_xB.woff 5.41 kB
+dist/assets/space-mono-vietnamese-700-normal-D-KrLuLr.woff 5.45 kB
+dist/assets/cinny-logo-maskable-256x256-B_icl17M.png 5.58 kB
+dist/assets/space-mono-vietnamese-400-italic-DvlTUS1j.woff 5.88 kB
+dist/assets/space-mono-vietnamese-400-normal-BNOj0Qhp.woff2 7.27 kB
+dist/assets/space-mono-vietnamese-700-normal-DWQgDHuA.woff2 7.33 kB
+dist/assets/space-mono-vietnamese-700-italic-i2bR4MHS.woff2 7.39 kB
+dist/assets/cinny-logo-maskable-384x384-DA-2uwBp.png 8.52 kB
+dist/assets/space-mono-vietnamese-400-italic-CyQIvI4V.woff2 9.69 kB
+dist/assets/notification-EtLMRd0T.ogg 11.30 kB
+dist/assets/cinny-logo-maskable-512x512-dB91iLyU.png 11.87 kB
+dist/assets/space-mono-latin-400-normal-_3DlpgIW.woff 12.31 kB
+dist/assets/space-mono-latin-700-normal-D7A851RN.woff 12.42 kB
+dist/assets/space-mono-latin-ext-700-normal-B_E7P90g.woff 12.48 kB
+dist/assets/space-mono-latin-ext-400-normal-D4cJI_B-.woff 12.61 kB
+dist/assets/space-mono-latin-ext-400-italic-DYA_DB_l.woff 12.89 kB
+dist/assets/nunito-vietnamese-wght-normal-U01xdrZh.woff2 13.10 kB
+dist/assets/space-mono-latin-ext-700-italic-CbHMtIk0.woff 13.14 kB
+dist/assets/space-mono-latin-700-italic-B8C1HgwN.woff 13.65 kB
+dist/assets/space-mono-latin-400-italic-zmx7Qf09.woff 13.82 kB
+dist/assets/nunito-vietnamese-wght-italic-5K55R7rt.woff2 14.77 kB
+dist/assets/space-mono-latin-ext-700-normal-B2s3bDs2.woff2 15.81 kB
+dist/assets/space-mono-latin-ext-400-normal-DTLbW2xa.woff2 15.83 kB
+dist/assets/space-mono-latin-400-normal-Rg4St2Dn.woff2 16.52 kB
+dist/assets/space-mono-latin-700-normal-mWgeinG7.woff2 16.72 kB
+dist/assets/space-mono-latin-ext-400-italic-x3PrlAeq.woff2 17.20 kB
+dist/assets/space-mono-latin-ext-700-italic-CkCrmjWu.woff2 17.74 kB
+dist/assets/space-mono-latin-400-italic-YylcN9Ay.woff2 18.30 kB
+dist/assets/space-mono-latin-700-italic-vNvENeTh.woff2 18.64 kB
+dist/assets/index-C5lozGWL.css.br 20.47 kB
+dist/assets/nunito-cyrillic-wght-normal-CY6AOgYE.woff2 20.78 kB
+dist/assets/nunito-cyrillic-wght-italic-AGUkry7S.woff2 22.90 kB
+dist/assets/nunito-cyrillic-ext-wght-normal-D4X5GqEv.woff2 28.93 kB
+dist/assets/nunito-cyrillic-ext-wght-italic-C7FdRbwB.woff2 31.51 kB
+dist/assets/invite-DROg5x7-.ogg 32.67 kB
+dist/assets/nunito-latin-ext-wght-normal-CXYtwYOx.woff2 35.59 kB
+dist/assets/nunito-latin-ext-wght-italic-CmZo11nB.woff2 39.07 kB
+dist/assets/nunito-latin-wght-normal-BzFMHfZw.woff2 39.13 kB
+dist/assets/index-ByL9e3j1.js.br 39.36 kB
+dist/assets/nunito-latin-wght-italic-ZB3Aladm.woff2 41.76 kB
+dist/assets/pdf-DEJ3BJS6.js.br 130.24 kB
+dist/assets/ringtone-4rwYiCEg.webm 139.95 kB
+dist/assets/index-ByL9e3j1.js.map.br 140.69 kB
+dist/assets/pdf-DEJ3BJS6.js.map.br 290.20 kB
+dist/assets/index-BTNFNN8W.js.map.br 302.21 kB
+dist/assets/Twemoji.Mozilla.v15.1.0-CM1RS90w.woff2 491.74 kB
+dist/assets/index-BTNFNN8W.js.br 951.42 kB
+dist/assets/matrix_sdk_crypto_wasm_bg-dMeGppz-.wasm.br 1,333.88 kB
+dist/assets/Twemoji.Mozilla.v15.1.0-DHQZm25T.ttf 1,451.58 kB
+dist/assets/matrix_sdk_crypto_wasm_bg-dMeGppz-.wasm 5,513.88 kB β gzip: 1,831.16 kB
+dist/assets/index-C5lozGWL.css 156.09 kB β gzip: 25.73 kB
+dist/assets/index-Ds5yF1gS.js 0.08 kB β gzip: 0.09 kB β map: 0.96 kB
+dist/assets/arborium-4uGfF3G6.js 20.65 kB β gzip: 5.16 kB β map: 21.44 kB
+dist/assets/index-ByL9e3j1.js 272.42 kB β gzip: 47.99 kB β map: 950.66 kB
+dist/assets/pdf-DEJ3BJS6.js 755.04 kB β gzip: 165.12 kB β map: 1,505.59 kB
+dist/assets/index-BTNFNN8W.js 6,603.34 kB β gzip: 1,316.26 kB β map: 1,618.30 kB
+
+(!) Some chunks are larger than 500 kB after minification. Consider:
+- Using dynamic import() to code-split the application
+- Use build.rollupOptions.output.manualChunks to improve chunking: https://rollupjs.org/configuration-options/#output-manualchunks
+- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.
+
+PWA v1.2.0
+Building src/sw.ts service worker ("es" format)...
+vite v7.3.1 building client environment for production...
+transforming...
+β 61 modules transformed.
+rendering chunks...
+computing gzip size...
+dist/sw.mjs 37.52 kB β gzip: 11.63 kB β map: 232.66 kB
+
+PWA v1.2.0
+mode injectManifest
+format: es
+precache 9 entries (14223.46 KiB)
+files generated
+ dist/sw.js
+ dist/sw.js.map
diff --git a/full_output.txt b/full_output.txt
new file mode 100644
index 000000000..e69de29bb
diff --git a/lint_output.txt b/lint_output.txt
new file mode 100644
index 000000000..d0e6288a3
--- /dev/null
+++ b/lint_output.txt
@@ -0,0 +1,62 @@
+
+> sable@1.14.0 lint /Users/evie/git/Sable
+> eslint .
+
+
+/Users/evie/git/Sable/src/app/components/url-preview/UrlPreviewCard.tsx
+ 36:5 warning Unexpected console statement no-console
+ 97:9 warning Unexpected console statement no-console
+ 104:11 warning Unexpected console statement no-console
+
+/Users/evie/git/Sable/src/app/features/room/RoomInput.tsx
+ 1678:19 warning Unexpected console statement no-console
+
+/Users/evie/git/Sable/src/app/features/settings/Persona/PerMessageProfileEditor.tsx
+ 47:3 warning Unexpected console statement no-console
+
+/Users/evie/git/Sable/src/app/pages/client/ClientNonUIFeatures.tsx
+ 833:9 warning Unexpected console statement no-console
+
+/Users/evie/git/Sable/src/app/plugins/call/CallEmbed.ts
+ 317:7 warning Unexpected console statement no-console
+ 423:11 warning Unexpected console statement no-console
+
+/Users/evie/git/Sable/src/app/utils/debugLogger.ts
+ 87:9 warning Unexpected console statement no-console
+
+/Users/evie/git/Sable/src/index.tsx
+ 54:9 warning Unexpected confirm no-alert
+
+/Users/evie/git/Sable/src/sw.ts
+ 110:9 warning Unexpected console statement no-console
+ 117:9 warning Unexpected console statement no-console
+ 182:5 warning Unexpected console statement no-console
+ 189:5 warning Unexpected console statement no-console
+ 222:5 warning Unexpected console statement no-console
+ 230:7 warning Unexpected console statement no-console
+ 276:7 warning Unexpected console statement no-console
+ 281:5 warning Unexpected console statement no-console
+ 412:11 warning Unexpected console statement no-console
+ 421:9 warning Unexpected console statement no-console
+ 448:5 warning Unexpected console statement no-console
+ 458:5 warning Unexpected console statement no-console
+ 872:7 warning Unexpected console statement no-console
+ 914:3 warning Unexpected console statement no-console
+ 920:3 warning Unexpected console statement no-console
+ 922:5 warning Unexpected console statement no-console
+ 927:3 warning Unexpected console statement no-console
+ 954:5 warning Unexpected console statement no-console
+ 979:3 warning Unexpected console statement no-console
+ 980:3 warning Unexpected console statement no-console
+ 1015:3 warning Unexpected console statement no-console
+ 1024:7 warning Unexpected console statement no-console
+ 1031:9 warning Unexpected console statement no-console
+ 1049:11 warning Unexpected console statement no-console
+ 1055:7 warning Unexpected console statement no-console
+
+/Users/evie/git/Sable/src/sw/pushNotification.ts
+ 48:5 warning Unexpected console statement no-console
+ 169:7 warning Unexpected console statement no-console
+
+β 37 problems (0 errors, 37 warnings)
+
diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx
index c9d934aec..e9ef1ee89 100644
--- a/src/app/features/settings/developer-tools/DevelopTools.tsx
+++ b/src/app/features/settings/developer-tools/DevelopTools.tsx
@@ -1,6 +1,6 @@
import { useCallback, useState } from 'react';
import { Box, Text, Scroll, Switch, Button, Spinner, color } from 'folds';
-import { KnownMembership } from 'matrix-js-sdk/lib/types';
+import { KnownMembership } from '$types/matrix-sdk';
import { PageContent } from '$components/page';
import { SequenceCard } from '$components/sequence-card';
import { SettingTile } from '$components/setting-tile';
@@ -33,6 +33,14 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp
[]
>(
useCallback(async () => {
+ if (
+ !window.confirm(
+ 'This will discard all current Megolm encryption sessions and start new ones. Continue?'
+ )
+ ) {
+ throw new Error('Cancelled');
+ }
+
const crypto = mx.getCrypto();
if (!crypto) throw new Error('Crypto module not available');
@@ -43,8 +51,10 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp
room.getMyMembership() === KnownMembership.Join && mx.isRoomEncrypted(room.roomId)
);
- await Promise.all(encryptedRooms.map((room) => crypto.forceDiscardSession(room.roomId)));
- const rotated = encryptedRooms.length;
+ const results = await Promise.allSettled(
+ encryptedRooms.map((room) => crypto.forceDiscardSession(room.roomId))
+ );
+ const rotated = results.filter((r) => r.status === 'fulfilled').length;
// Proactively start session creation + key sharing with all devices
// (including bridge bots). fire-and-forget per room.
diff --git a/test_file.txt b/test_file.txt
new file mode 100644
index 000000000..9daeafb98
--- /dev/null
+++ b/test_file.txt
@@ -0,0 +1 @@
+test
From 8cdf36a0bf8e0da1958bd7655795526f821d60be Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Tue, 14 Apr 2026 22:55:09 -0400
Subject: [PATCH 108/191] chore: remove accidentally committed scratch files
---
build.log | 114 ------------------------------------------------
full_output.txt | 0
lint_output.txt | 62 --------------------------
test_file.txt | 1 -
4 files changed, 177 deletions(-)
delete mode 100644 build.log
delete mode 100644 full_output.txt
delete mode 100644 lint_output.txt
delete mode 100644 test_file.txt
diff --git a/build.log b/build.log
deleted file mode 100644
index 1f5016d8d..000000000
--- a/build.log
+++ /dev/null
@@ -1,114 +0,0 @@
-
-> sable@1.14.0 build /Users/evie/git/Sable
-> vite build
-
-vite v7.3.1 building client environment for production...
-transforming...
-β 8032 modules transformed.
-rendering chunks...
-computing gzip size...
-[vite-plugin-static-copy] Copied 10 items.
-dist/.assetsignore 0.02 kB
-dist/assets/index-Ds5yF1gS.js.br 0.07 kB
-dist/assets/index-Ds5yF1gS.js.map.br 0.41 kB
-dist/wrangler.json.br 0.42 kB
-dist/assets/cinny-logo-maskable-36x36-CT1Bq-jJ.png 0.79 kB
-dist/index.html.br 0.88 kB
-dist/assets/cinny-logo-maskable-DXG8PKa6.svg.br 0.96 kB
-dist/wrangler.json 1.00 kB β gzip: 0.54 kB
-dist/assets/cinny-logo-maskable-48x48-DfxQ4FEO.png 1.08 kB
-dist/assets/cinny-logo-maskable-57x57-B_a6RKlt.png 1.28 kB
-dist/assets/cinny-logo-maskable-60x60-uAvO2kwU.png 1.35 kB
-dist/assets/cinny-logo-maskable-72x72-ChMts4zW.png 1.59 kB
-dist/assets/cinny-logo-maskable-96x96-DeladQJ2.png 2.08 kB
-dist/assets/cinny-logo-maskable-114x114-BRz0LILn.png 2.45 kB
-dist/assets/cinny-logo-maskable-DXG8PKa6.svg 2.56 kB β gzip: 1.12 kB
-dist/assets/cinny-logo-maskable-120x120-BBcwUWQZ.png 2.59 kB
-dist/assets/cinny-logo-maskable-144x144-CitAVeb0.png 3.08 kB
-dist/assets/cinny-logo-maskable-152x152-Di3nKfIW.png 3.26 kB
-dist/assets/cinny-logo-maskable-167x167-Dk9KG9Yi.png 3.59 kB
-dist/assets/cinny-logo-maskable-180x180-RAgvvHf4.png 3.79 kB
-dist/assets/cinny-logo-maskable-192x192-BHxZuLYc.png 4.06 kB
-dist/assets/favicon-CemZgig7.png 4.15 kB
-dist/index.html 4.28 kB β gzip: 1.17 kB
-dist/assets/arborium-4uGfF3G6.js.br 4.54 kB
-dist/assets/arborium-4uGfF3G6.js.map.br 4.98 kB
-dist/assets/space-mono-vietnamese-700-italic-BwPKnf-l.woff 5.24 kB
-dist/assets/space-mono-vietnamese-400-normal-B0PMp_xB.woff 5.41 kB
-dist/assets/space-mono-vietnamese-700-normal-D-KrLuLr.woff 5.45 kB
-dist/assets/cinny-logo-maskable-256x256-B_icl17M.png 5.58 kB
-dist/assets/space-mono-vietnamese-400-italic-DvlTUS1j.woff 5.88 kB
-dist/assets/space-mono-vietnamese-400-normal-BNOj0Qhp.woff2 7.27 kB
-dist/assets/space-mono-vietnamese-700-normal-DWQgDHuA.woff2 7.33 kB
-dist/assets/space-mono-vietnamese-700-italic-i2bR4MHS.woff2 7.39 kB
-dist/assets/cinny-logo-maskable-384x384-DA-2uwBp.png 8.52 kB
-dist/assets/space-mono-vietnamese-400-italic-CyQIvI4V.woff2 9.69 kB
-dist/assets/notification-EtLMRd0T.ogg 11.30 kB
-dist/assets/cinny-logo-maskable-512x512-dB91iLyU.png 11.87 kB
-dist/assets/space-mono-latin-400-normal-_3DlpgIW.woff 12.31 kB
-dist/assets/space-mono-latin-700-normal-D7A851RN.woff 12.42 kB
-dist/assets/space-mono-latin-ext-700-normal-B_E7P90g.woff 12.48 kB
-dist/assets/space-mono-latin-ext-400-normal-D4cJI_B-.woff 12.61 kB
-dist/assets/space-mono-latin-ext-400-italic-DYA_DB_l.woff 12.89 kB
-dist/assets/nunito-vietnamese-wght-normal-U01xdrZh.woff2 13.10 kB
-dist/assets/space-mono-latin-ext-700-italic-CbHMtIk0.woff 13.14 kB
-dist/assets/space-mono-latin-700-italic-B8C1HgwN.woff 13.65 kB
-dist/assets/space-mono-latin-400-italic-zmx7Qf09.woff 13.82 kB
-dist/assets/nunito-vietnamese-wght-italic-5K55R7rt.woff2 14.77 kB
-dist/assets/space-mono-latin-ext-700-normal-B2s3bDs2.woff2 15.81 kB
-dist/assets/space-mono-latin-ext-400-normal-DTLbW2xa.woff2 15.83 kB
-dist/assets/space-mono-latin-400-normal-Rg4St2Dn.woff2 16.52 kB
-dist/assets/space-mono-latin-700-normal-mWgeinG7.woff2 16.72 kB
-dist/assets/space-mono-latin-ext-400-italic-x3PrlAeq.woff2 17.20 kB
-dist/assets/space-mono-latin-ext-700-italic-CkCrmjWu.woff2 17.74 kB
-dist/assets/space-mono-latin-400-italic-YylcN9Ay.woff2 18.30 kB
-dist/assets/space-mono-latin-700-italic-vNvENeTh.woff2 18.64 kB
-dist/assets/index-C5lozGWL.css.br 20.47 kB
-dist/assets/nunito-cyrillic-wght-normal-CY6AOgYE.woff2 20.78 kB
-dist/assets/nunito-cyrillic-wght-italic-AGUkry7S.woff2 22.90 kB
-dist/assets/nunito-cyrillic-ext-wght-normal-D4X5GqEv.woff2 28.93 kB
-dist/assets/nunito-cyrillic-ext-wght-italic-C7FdRbwB.woff2 31.51 kB
-dist/assets/invite-DROg5x7-.ogg 32.67 kB
-dist/assets/nunito-latin-ext-wght-normal-CXYtwYOx.woff2 35.59 kB
-dist/assets/nunito-latin-ext-wght-italic-CmZo11nB.woff2 39.07 kB
-dist/assets/nunito-latin-wght-normal-BzFMHfZw.woff2 39.13 kB
-dist/assets/index-ByL9e3j1.js.br 39.36 kB
-dist/assets/nunito-latin-wght-italic-ZB3Aladm.woff2 41.76 kB
-dist/assets/pdf-DEJ3BJS6.js.br 130.24 kB
-dist/assets/ringtone-4rwYiCEg.webm 139.95 kB
-dist/assets/index-ByL9e3j1.js.map.br 140.69 kB
-dist/assets/pdf-DEJ3BJS6.js.map.br 290.20 kB
-dist/assets/index-BTNFNN8W.js.map.br 302.21 kB
-dist/assets/Twemoji.Mozilla.v15.1.0-CM1RS90w.woff2 491.74 kB
-dist/assets/index-BTNFNN8W.js.br 951.42 kB
-dist/assets/matrix_sdk_crypto_wasm_bg-dMeGppz-.wasm.br 1,333.88 kB
-dist/assets/Twemoji.Mozilla.v15.1.0-DHQZm25T.ttf 1,451.58 kB
-dist/assets/matrix_sdk_crypto_wasm_bg-dMeGppz-.wasm 5,513.88 kB β gzip: 1,831.16 kB
-dist/assets/index-C5lozGWL.css 156.09 kB β gzip: 25.73 kB
-dist/assets/index-Ds5yF1gS.js 0.08 kB β gzip: 0.09 kB β map: 0.96 kB
-dist/assets/arborium-4uGfF3G6.js 20.65 kB β gzip: 5.16 kB β map: 21.44 kB
-dist/assets/index-ByL9e3j1.js 272.42 kB β gzip: 47.99 kB β map: 950.66 kB
-dist/assets/pdf-DEJ3BJS6.js 755.04 kB β gzip: 165.12 kB β map: 1,505.59 kB
-dist/assets/index-BTNFNN8W.js 6,603.34 kB β gzip: 1,316.26 kB β map: 1,618.30 kB
-
-(!) Some chunks are larger than 500 kB after minification. Consider:
-- Using dynamic import() to code-split the application
-- Use build.rollupOptions.output.manualChunks to improve chunking: https://rollupjs.org/configuration-options/#output-manualchunks
-- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.
-
-PWA v1.2.0
-Building src/sw.ts service worker ("es" format)...
-vite v7.3.1 building client environment for production...
-transforming...
-β 61 modules transformed.
-rendering chunks...
-computing gzip size...
-dist/sw.mjs 37.52 kB β gzip: 11.63 kB β map: 232.66 kB
-
-PWA v1.2.0
-mode injectManifest
-format: es
-precache 9 entries (14223.46 KiB)
-files generated
- dist/sw.js
- dist/sw.js.map
diff --git a/full_output.txt b/full_output.txt
deleted file mode 100644
index e69de29bb..000000000
diff --git a/lint_output.txt b/lint_output.txt
deleted file mode 100644
index d0e6288a3..000000000
--- a/lint_output.txt
+++ /dev/null
@@ -1,62 +0,0 @@
-
-> sable@1.14.0 lint /Users/evie/git/Sable
-> eslint .
-
-
-/Users/evie/git/Sable/src/app/components/url-preview/UrlPreviewCard.tsx
- 36:5 warning Unexpected console statement no-console
- 97:9 warning Unexpected console statement no-console
- 104:11 warning Unexpected console statement no-console
-
-/Users/evie/git/Sable/src/app/features/room/RoomInput.tsx
- 1678:19 warning Unexpected console statement no-console
-
-/Users/evie/git/Sable/src/app/features/settings/Persona/PerMessageProfileEditor.tsx
- 47:3 warning Unexpected console statement no-console
-
-/Users/evie/git/Sable/src/app/pages/client/ClientNonUIFeatures.tsx
- 833:9 warning Unexpected console statement no-console
-
-/Users/evie/git/Sable/src/app/plugins/call/CallEmbed.ts
- 317:7 warning Unexpected console statement no-console
- 423:11 warning Unexpected console statement no-console
-
-/Users/evie/git/Sable/src/app/utils/debugLogger.ts
- 87:9 warning Unexpected console statement no-console
-
-/Users/evie/git/Sable/src/index.tsx
- 54:9 warning Unexpected confirm no-alert
-
-/Users/evie/git/Sable/src/sw.ts
- 110:9 warning Unexpected console statement no-console
- 117:9 warning Unexpected console statement no-console
- 182:5 warning Unexpected console statement no-console
- 189:5 warning Unexpected console statement no-console
- 222:5 warning Unexpected console statement no-console
- 230:7 warning Unexpected console statement no-console
- 276:7 warning Unexpected console statement no-console
- 281:5 warning Unexpected console statement no-console
- 412:11 warning Unexpected console statement no-console
- 421:9 warning Unexpected console statement no-console
- 448:5 warning Unexpected console statement no-console
- 458:5 warning Unexpected console statement no-console
- 872:7 warning Unexpected console statement no-console
- 914:3 warning Unexpected console statement no-console
- 920:3 warning Unexpected console statement no-console
- 922:5 warning Unexpected console statement no-console
- 927:3 warning Unexpected console statement no-console
- 954:5 warning Unexpected console statement no-console
- 979:3 warning Unexpected console statement no-console
- 980:3 warning Unexpected console statement no-console
- 1015:3 warning Unexpected console statement no-console
- 1024:7 warning Unexpected console statement no-console
- 1031:9 warning Unexpected console statement no-console
- 1049:11 warning Unexpected console statement no-console
- 1055:7 warning Unexpected console statement no-console
-
-/Users/evie/git/Sable/src/sw/pushNotification.ts
- 48:5 warning Unexpected console statement no-console
- 169:7 warning Unexpected console statement no-console
-
-β 37 problems (0 errors, 37 warnings)
-
diff --git a/test_file.txt b/test_file.txt
deleted file mode 100644
index 9daeafb98..000000000
--- a/test_file.txt
+++ /dev/null
@@ -1 +0,0 @@
-test
From e5bdd7cf17cfb0b2830a22b35ae9ebd9e00e7994 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Tue, 14 Apr 2026 22:58:14 -0400
Subject: [PATCH 109/191] fix(presence): address review feedback for presence
sidebar badges
- Guard useUserPresence client-level listener for empty userId
- Hide badge when Invisible mode is active (presenceMode === 'offline')
- Hide badge in Invisible menu row
- Import KnownMembership from $types/matrix-sdk
---
src/app/features/settings/developer-tools/DevelopTools.tsx | 2 +-
src/app/hooks/useUserPresence.ts | 2 +-
src/app/pages/client/sidebar/AccountSwitcherTab.tsx | 4 ++--
3 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx
index 4e38f7868..6bfd0f6cb 100644
--- a/src/app/features/settings/developer-tools/DevelopTools.tsx
+++ b/src/app/features/settings/developer-tools/DevelopTools.tsx
@@ -1,6 +1,6 @@
import { useCallback, useState } from 'react';
import { Box, Text, Scroll, Switch, Button, Spinner, color } from 'folds';
-import { KnownMembership } from 'matrix-js-sdk/lib/types';
+import { KnownMembership } from '$types/matrix-sdk';
import { PageContent } from '$components/page';
import { SequenceCard } from '$components/sequence-card';
import { SettingTile } from '$components/setting-tile';
diff --git a/src/app/hooks/useUserPresence.ts b/src/app/hooks/useUserPresence.ts
index 8c9b85959..4643a6c02 100644
--- a/src/app/hooks/useUserPresence.ts
+++ b/src/app/hooks/useUserPresence.ts
@@ -68,7 +68,7 @@ export const useUserPresence = (userId: string): UserPresence | undefined => {
// 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) {
+ if (!user && userId) {
const onClientEvent = (event: MatrixEvent) => {
if (event.getSender() !== userId || event.getType() !== 'm.presence') return;
const u = mx.getUser(userId);
diff --git a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx
index 395edcfe7..073293175 100644
--- a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx
+++ b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx
@@ -184,7 +184,7 @@ export function AccountSwitcherTab() {
const [sendPresence, setSendPresence] = useSetting(settingsAtom, 'sendPresence');
const [presenceMode, setPresenceMode] = useSetting(settingsAtom, 'presenceMode');
let myOwnPresenceBadge: ReactNode;
- if (sendPresence) {
+ if (sendPresence && presenceMode !== 'offline') {
myOwnPresenceBadge =
presenceMode === 'dnd' ? (
// DND: solid red badge (broadcasts as online with status_msg 'dnd')
@@ -393,7 +393,7 @@ export function AccountSwitcherTab() {
const badge =
mode === 'dnd' ? (
- ) : (
+ ) : mode === 'offline' ? undefined : (
);
return (
From 3f0387686bf94583b6ea5c3b25219c2fd9d36b98 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Tue, 14 Apr 2026 23:00:02 -0400
Subject: [PATCH 110/191] fix(presence): address review feedback for
presence-auto-idle
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Fix changeset frontmatter: '@sable/client': minor β default: minor
- Update presenceMode docstring to clarify dnd broadcasts as online+status_msg
- Import KnownMembership from $types/matrix-sdk
- Gate heartbeat effect on mx being defined to avoid no-op timers
- Add mx to heartbeat effect dependency array
---
.changeset/presence-auto-idle.md | 2 +-
src/app/features/settings/developer-tools/DevelopTools.tsx | 2 +-
src/app/hooks/useAppVisibility.ts | 3 ++-
src/app/state/settings.ts | 6 +++++-
4 files changed, 9 insertions(+), 4 deletions(-)
diff --git a/.changeset/presence-auto-idle.md b/.changeset/presence-auto-idle.md
index 0cdedfdac..56889390c 100644
--- a/.changeset/presence-auto-idle.md
+++ b/.changeset/presence-auto-idle.md
@@ -1,5 +1,5 @@
---
-'@sable/client': minor
+default: minor
---
feat(presence): add auto-idle presence after configurable inactivity timeout with Discord-style status picker
diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx
index 4e38f7868..6bfd0f6cb 100644
--- a/src/app/features/settings/developer-tools/DevelopTools.tsx
+++ b/src/app/features/settings/developer-tools/DevelopTools.tsx
@@ -1,6 +1,6 @@
import { useCallback, useState } from 'react';
import { Box, Text, Scroll, Switch, Button, Spinner, color } from 'folds';
-import { KnownMembership } from 'matrix-js-sdk/lib/types';
+import { KnownMembership } from '$types/matrix-sdk';
import { PageContent } from '$components/page';
import { SequenceCard } from '$components/sequence-card';
import { SettingTile } from '$components/setting-tile';
diff --git a/src/app/hooks/useAppVisibility.ts b/src/app/hooks/useAppVisibility.ts
index 144f132a9..b1d25add0 100644
--- a/src/app/hooks/useAppVisibility.ts
+++ b/src/app/hooks/useAppVisibility.ts
@@ -171,7 +171,7 @@ export function useAppVisibility(mx: MatrixClient | undefined, activeSession?: S
]);
useEffect(() => {
- if (!phase2VisibleHeartbeat) return undefined;
+ if (!phase2VisibleHeartbeat || !mx) return undefined;
// Reset adaptive backoff/suppression so a config or session change starts fresh.
heartbeatFailuresRef.current = 0;
@@ -230,6 +230,7 @@ export function useAppVisibility(mx: MatrixClient | undefined, activeSession?: S
}, [
heartbeatIntervalMs,
heartbeatMaxBackoffMs,
+ mx,
phase2VisibleHeartbeat,
phase3AdaptiveBackoffJitter,
pushSessionNow,
diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts
index 0d4c16bc8..935b420ba 100644
--- a/src/app/state/settings.ts
+++ b/src/app/state/settings.ts
@@ -93,7 +93,11 @@ export interface Settings {
// Sable features!
sendPresence: boolean;
- /** Which Matrix presence state to broadcast when sendPresence is true. */
+ /**
+ * Which presence mode to use when sendPresence is true.
+ * Matrix presence states are sent as-is; the app-specific `dnd` mode is
+ * broadcast as `presence=online` with a `status_msg`.
+ */
presenceMode: 'online' | 'unavailable' | 'dnd' | 'offline';
mobileGestures: boolean;
rightSwipeAction: RightSwipeAction;
From 4cb00a58022c1341086bc63d582cd080befa29ab Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Tue, 14 Apr 2026 23:02:46 -0400
Subject: [PATCH 111/191] fix(room-nav): address review feedback for message
preview
- Filter reaction and edit events from last-message preview
- Strip reply fallback prefix from preview text
- Pass dmMessagePreview setting to RoomNavItem in Space view
- Fix changeset frontmatter to use default: minor
---
.changeset/feat-dm-message-preview.md | 2 +-
.changeset/room-message-preview.md | 2 +-
src/app/hooks/useRoomLastMessage.ts | 20 +++++++++++++++++++-
src/app/pages/client/space/Space.tsx | 2 ++
4 files changed, 23 insertions(+), 3 deletions(-)
diff --git a/.changeset/feat-dm-message-preview.md b/.changeset/feat-dm-message-preview.md
index ab8e37801..46cbcff81 100644
--- a/.changeset/feat-dm-message-preview.md
+++ b/.changeset/feat-dm-message-preview.md
@@ -1,5 +1,5 @@
---
-'@sable/client': minor
+default: minor
---
feat(dm-list): show last-message preview below DM room name
diff --git a/.changeset/room-message-preview.md b/.changeset/room-message-preview.md
index 4f8d1cef8..3f8587b85 100644
--- a/.changeset/room-message-preview.md
+++ b/.changeset/room-message-preview.md
@@ -1,5 +1,5 @@
---
-'@sable/client': minor
+default: minor
---
feat(room-nav): show topic and last-message preview for rooms in the sidebar, fetching enough timeline events to handle reactions and edits correctly
diff --git a/src/app/hooks/useRoomLastMessage.ts b/src/app/hooks/useRoomLastMessage.ts
index b4c829f10..1e87d0092 100644
--- a/src/app/hooks/useRoomLastMessage.ts
+++ b/src/app/hooks/useRoomLastMessage.ts
@@ -9,18 +9,36 @@ import {
} from '$types/matrix-sdk';
import { MessageEvent } from '$types/matrix/room';
+/**
+ * Strip the legacy reply fallback (lines starting with `> `) that some
+ * clients prepend when replying to a message.
+ */
+function stripReplyFallback(body: string): string {
+ const lines = body.split('\n');
+ let i = 0;
+ while (i < lines.length && lines[i].startsWith('> ')) i++;
+ // Skip the blank separator line that follows the fallback block.
+ if (i > 0 && i < lines.length && lines[i] === '') i++;
+ return lines.slice(i).join('\n');
+}
+
function eventToPreviewText(ev: MatrixEvent): string | undefined {
if (ev.isRedacted()) return undefined;
const type = ev.getType();
+ // Skip reactions and edits β they aren't standalone messages.
+ if (type === MessageEvent.Reaction) return undefined;
+ const relType = ev.getContent()?.['m.relates_to']?.rel_type;
+ if (relType === 'm.replace') return undefined;
+
if (type === MessageEvent.RoomMessageEncrypted) return 'π Encrypted message';
if (type === MessageEvent.RoomMessage) {
const content = ev.getContent();
const { msgtype } = content;
if (msgtype === MsgType.Text || msgtype === MsgType.Emote || msgtype === MsgType.Notice) {
- return content.body;
+ return stripReplyFallback(content.body);
}
if (msgtype === MsgType.Image) return 'π· Image';
if (msgtype === MsgType.Video) return 'πΉ Video';
diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx
index 72efaaea6..2ccd15171 100644
--- a/src/app/pages/client/space/Space.tsx
+++ b/src/app/pages/client/space/Space.tsx
@@ -535,6 +535,7 @@ export function Space() {
const [subspaceHierarchyLimit] = useSetting(settingsAtom, 'subspaceHierarchyLimit');
const [roomTopicPreview] = useSetting(settingsAtom, 'roomTopicPreview');
const [roomMessagePreview] = useSetting(settingsAtom, 'roomMessagePreview');
+ const [dmMessagePreview] = useSetting(settingsAtom, 'dmMessagePreview');
/**
* Creates an SVG used for connecting spaces to their subrooms.
* @param virtualizedItems - The virtualized item list that will be used to render elements in the nav
@@ -830,6 +831,7 @@ export function Space() {
direct={mDirects.has(roomId)}
roomTopicPreview={roomTopicPreview}
roomMessagePreview={roomMessagePreview}
+ dmMessagePreview={dmMessagePreview}
linkPath={getToLink(roomId)}
notificationMode={getRoomNotificationMode(
notificationPreferences,
From 0231581f09bb41e7387cac52249da20100b861db Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Tue, 14 Apr 2026 23:23:16 -0400
Subject: [PATCH 112/191] fix(presence): 5min default, wire visibility reset,
add tests
- Change presenceAutoIdleTimeoutMs from 600000 (10min) to 300000 (5min)
- Wire appEvents.onVisibilityChange so returning to the app resets auto-idle
- Add comprehensive usePresenceAutoIdle unit tests (10 tests)
---
config.json | 2 +-
src/app/hooks/usePresenceAutoIdle.test.tsx | 238 +++++++++++++++++++++
src/app/hooks/usePresenceAutoIdle.ts | 10 +
3 files changed, 249 insertions(+), 1 deletion(-)
create mode 100644 src/app/hooks/usePresenceAutoIdle.test.tsx
diff --git a/config.json b/config.json
index b930f457e..6410de4de 100644
--- a/config.json
+++ b/config.json
@@ -19,7 +19,7 @@
"enabled": true
},
- "presenceAutoIdleTimeoutMs": 600000,
+ "presenceAutoIdleTimeoutMs": 300000,
"featuredCommunities": {
"openAsDefault": false,
diff --git a/src/app/hooks/usePresenceAutoIdle.test.tsx b/src/app/hooks/usePresenceAutoIdle.test.tsx
new file mode 100644
index 000000000..0ebfd744a
--- /dev/null
+++ b/src/app/hooks/usePresenceAutoIdle.test.tsx
@@ -0,0 +1,238 @@
+import { act, renderHook } from '@testing-library/react';
+import { Provider, useAtomValue } from 'jotai';
+import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
+import { usePresenceAutoIdle } from './usePresenceAutoIdle';
+import { presenceAutoIdledAtom } from '$state/settings';
+import { appEvents } from '$utils/appEvents';
+import type { ReactNode } from 'react';
+
+// -------- mock setup --------
+
+const userListeners = new Map void)[]>();
+
+const makeMockUser = () => ({
+ userId: '@alice:test',
+ presence: 'online',
+ 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(),
+});
+
+let mockUser: ReturnType | null = null;
+
+const makeMockMx = () => ({
+ getUserId: vi.fn(() => '@alice:test'),
+ getUser: vi.fn(() => mockUser),
+});
+
+let mockMx: ReturnType;
+
+const wrapper = ({ children }: { children: ReactNode }) => {children};
+
+// Helper to read the atom value alongside the hook under test.
+function useAutoIdledReader(
+ mx: ReturnType,
+ presenceMode: string,
+ sendPresence: boolean,
+ timeoutMs: number
+) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ usePresenceAutoIdle(mx as any, presenceMode, sendPresence, timeoutMs);
+ return useAtomValue(presenceAutoIdledAtom);
+}
+
+// -------- lifecycle --------
+
+beforeEach(() => {
+ vi.useFakeTimers();
+ vi.clearAllMocks();
+ userListeners.clear();
+ mockUser = makeMockUser();
+ mockMx = makeMockMx();
+ appEvents.onVisibilityChange = null;
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+ appEvents.onVisibilityChange = null;
+});
+
+// -------- tests --------
+
+describe('usePresenceAutoIdle', () => {
+ it('sets auto-idle after the timeout elapses', () => {
+ const { result } = renderHook(
+ () => useAutoIdledReader(mockMx, 'online', true, 5000),
+ { wrapper }
+ );
+
+ expect(result.current).toBe(false);
+
+ act(() => {
+ vi.advanceTimersByTime(5000);
+ });
+
+ expect(result.current).toBe(true);
+ });
+
+ it('resets auto-idle when user activity is detected', () => {
+ const { result } = renderHook(
+ () => useAutoIdledReader(mockMx, 'online', true, 5000),
+ { wrapper }
+ );
+
+ // Go idle.
+ act(() => {
+ vi.advanceTimersByTime(5000);
+ });
+ expect(result.current).toBe(true);
+
+ // Simulate user activity.
+ act(() => {
+ document.dispatchEvent(new Event('mousemove'));
+ });
+ expect(result.current).toBe(false);
+ });
+
+ it('resets auto-idle when app becomes visible via appEvents', () => {
+ const { result } = renderHook(
+ () => useAutoIdledReader(mockMx, 'online', true, 5000),
+ { wrapper }
+ );
+
+ act(() => {
+ vi.advanceTimersByTime(5000);
+ });
+ expect(result.current).toBe(true);
+
+ // Simulate app returning to foreground.
+ act(() => {
+ appEvents.onVisibilityChange?.(true);
+ });
+ expect(result.current).toBe(false);
+ });
+
+ it('does not go idle when presenceMode is not online', () => {
+ const { result } = renderHook(
+ () => useAutoIdledReader(mockMx, 'dnd', true, 5000),
+ { wrapper }
+ );
+
+ act(() => {
+ vi.advanceTimersByTime(10000);
+ });
+ expect(result.current).toBe(false);
+ });
+
+ it('does not go idle when sendPresence is false', () => {
+ const { result } = renderHook(
+ () => useAutoIdledReader(mockMx, 'online', false, 5000),
+ { wrapper }
+ );
+
+ act(() => {
+ vi.advanceTimersByTime(10000);
+ });
+ expect(result.current).toBe(false);
+ });
+
+ it('does not go idle when timeoutMs is 0', () => {
+ const { result } = renderHook(
+ () => useAutoIdledReader(mockMx, 'online', true, 0),
+ { wrapper }
+ );
+
+ act(() => {
+ vi.advanceTimersByTime(10000);
+ });
+ expect(result.current).toBe(false);
+ });
+
+ it('restarts the idle timer on activity before timeout', () => {
+ const { result } = renderHook(
+ () => useAutoIdledReader(mockMx, 'online', true, 5000),
+ { wrapper }
+ );
+
+ // Advance partially, then trigger activity.
+ act(() => {
+ vi.advanceTimersByTime(3000);
+ });
+ expect(result.current).toBe(false);
+
+ act(() => {
+ document.dispatchEvent(new Event('keydown'));
+ });
+
+ // Original timeout would have fired at 5000ms, but we reset.
+ act(() => {
+ vi.advanceTimersByTime(3000);
+ });
+ expect(result.current).toBe(false);
+
+ // Now the full 5000ms from last activity should trigger idle.
+ act(() => {
+ vi.advanceTimersByTime(2000);
+ });
+ expect(result.current).toBe(true);
+ });
+
+ it('clears auto-idle when presenceMode changes away from online', () => {
+ const { result, rerender } = renderHook(
+ ({ mode }) => useAutoIdledReader(mockMx, mode, true, 5000),
+ { wrapper, initialProps: { mode: 'online' } }
+ );
+
+ act(() => {
+ vi.advanceTimersByTime(5000);
+ });
+ expect(result.current).toBe(true);
+
+ rerender({ mode: 'dnd' });
+ expect(result.current).toBe(false);
+ });
+
+ it('clears auto-idle when another device sets presence to online', () => {
+ const { result } = renderHook(
+ () => useAutoIdledReader(mockMx, 'online', true, 5000),
+ { wrapper }
+ );
+
+ act(() => {
+ vi.advanceTimersByTime(5000);
+ });
+ expect(result.current).toBe(true);
+
+ // Simulate User.presence event from another device.
+ const handlers = userListeners.get('User.presence') ?? [];
+ expect(handlers.length).toBeGreaterThan(0);
+
+ act(() => {
+ handlers.forEach((h) =>
+ h({}, { userId: '@alice:test', presence: 'online' })
+ );
+ });
+ expect(result.current).toBe(false);
+ });
+
+ it('restores previous appEvents.onVisibilityChange on cleanup', () => {
+ const prev = vi.fn();
+ appEvents.onVisibilityChange = prev;
+
+ const { unmount } = renderHook(
+ () => useAutoIdledReader(mockMx, 'online', true, 5000),
+ { wrapper }
+ );
+
+ // Our handler should be installed.
+ expect(appEvents.onVisibilityChange).not.toBe(prev);
+
+ unmount();
+
+ // Previous handler should be restored.
+ expect(appEvents.onVisibilityChange).toBe(prev);
+ });
+});
diff --git a/src/app/hooks/usePresenceAutoIdle.ts b/src/app/hooks/usePresenceAutoIdle.ts
index dd11e729b..abf25edba 100644
--- a/src/app/hooks/usePresenceAutoIdle.ts
+++ b/src/app/hooks/usePresenceAutoIdle.ts
@@ -2,6 +2,7 @@ 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 { appEvents } from '$utils/appEvents';
import { createDebugLogger } from '$utils/debugLogger';
const debugLog = createDebugLogger('PresenceAutoIdle');
@@ -70,9 +71,18 @@ export function usePresenceAutoIdle(
document.addEventListener(ev, handleActivity, { passive: true })
);
+ // When the app returns to the foreground, treat it as activity so the user
+ // isn't shown as idle the moment they switch back to the tab/PWA.
+ const prevOnVisibilityChange = appEvents.onVisibilityChange;
+ appEvents.onVisibilityChange = (isVisible: boolean) => {
+ prevOnVisibilityChange?.(isVisible);
+ if (isVisible) handleActivity();
+ };
+
return () => {
ACTIVITY_EVENTS.forEach((ev) => document.removeEventListener(ev, handleActivity));
clearTimer();
+ appEvents.onVisibilityChange = prevOnVisibilityChange;
};
}, [clearTimer, presenceMode, sendPresence, setAutoIdled, timeoutMs]);
From 1f9dae9d3ba4930e519c56386f10e93499834003 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Tue, 14 Apr 2026 23:25:13 -0400
Subject: [PATCH 113/191] test(room-nav): add useRoomLastMessage unit tests (28
tests)
- Test stripReplyFallback: plain text, quoted lines, no separator, multi-line
- Test eventToPreviewText: all msg types, encrypted, sticker, reactions, edits, reply fallback
- Test getLastMessageText: You prefix, display name, userId fallback, skip reactions, empty timeline
- Test useRoomLastMessage hook: undefined room, initial render, Timeline event updates
- Export pure functions for testability
---
src/app/hooks/useRoomLastMessage.test.tsx | 246 ++++++++++++++++++++++
src/app/hooks/useRoomLastMessage.ts | 6 +-
2 files changed, 249 insertions(+), 3 deletions(-)
create mode 100644 src/app/hooks/useRoomLastMessage.test.tsx
diff --git a/src/app/hooks/useRoomLastMessage.test.tsx b/src/app/hooks/useRoomLastMessage.test.tsx
new file mode 100644
index 000000000..4e3065583
--- /dev/null
+++ b/src/app/hooks/useRoomLastMessage.test.tsx
@@ -0,0 +1,246 @@
+import { act, renderHook } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import {
+ stripReplyFallback,
+ eventToPreviewText,
+ getLastMessageText,
+ useRoomLastMessage,
+} from './useRoomLastMessage';
+
+// -------- helpers --------
+
+function makeEvent(overrides: {
+ type?: string;
+ content?: Record;
+ sender?: string;
+ roomId?: string;
+ redacted?: boolean;
+}) {
+ return {
+ getType: () => overrides.type ?? 'm.room.message',
+ getContent: () => overrides.content ?? { msgtype: 'm.text', body: 'hello' },
+ getSender: () => overrides.sender ?? '@alice:test',
+ getRoomId: () => overrides.roomId ?? '!room:test',
+ isRedacted: () => overrides.redacted ?? false,
+ } as never;
+}
+
+// -------- stripReplyFallback --------
+
+describe('stripReplyFallback', () => {
+ it('returns the body unchanged when there is no fallback', () => {
+ expect(stripReplyFallback('hello world')).toBe('hello world');
+ });
+
+ it('strips lines starting with > and the blank separator', () => {
+ const body = '> reply line 1\n> reply line 2\n\nactual message';
+ expect(stripReplyFallback(body)).toBe('actual message');
+ });
+
+ it('strips fallback with no separator line', () => {
+ const body = '> quoted\nrest';
+ expect(stripReplyFallback(body)).toBe('rest');
+ });
+
+ it('returns empty string when the entire body is a fallback', () => {
+ expect(stripReplyFallback('> only quote\n')).toBe('');
+ });
+
+ it('handles multi-line actual message after fallback', () => {
+ const body = '> quote\n\nline 1\nline 2';
+ expect(stripReplyFallback(body)).toBe('line 1\nline 2');
+ });
+});
+
+// -------- eventToPreviewText --------
+
+describe('eventToPreviewText', () => {
+ it('returns body for m.text message', () => {
+ const ev = makeEvent({ content: { msgtype: 'm.text', body: 'hi' } });
+ expect(eventToPreviewText(ev)).toBe('hi');
+ });
+
+ it('returns body for m.emote message', () => {
+ const ev = makeEvent({ content: { msgtype: 'm.emote', body: 'waves' } });
+ expect(eventToPreviewText(ev)).toBe('waves');
+ });
+
+ it('returns body for m.notice message', () => {
+ const ev = makeEvent({ content: { msgtype: 'm.notice', body: 'notice' } });
+ expect(eventToPreviewText(ev)).toBe('notice');
+ });
+
+ it('returns image icon for m.image', () => {
+ const ev = makeEvent({ content: { msgtype: 'm.image', body: 'photo.png' } });
+ expect(eventToPreviewText(ev)).toBe('π· Image');
+ });
+
+ it('returns video icon for m.video', () => {
+ const ev = makeEvent({ content: { msgtype: 'm.video', body: 'clip.mp4' } });
+ expect(eventToPreviewText(ev)).toBe('πΉ Video');
+ });
+
+ it('returns audio icon for m.audio', () => {
+ const ev = makeEvent({ content: { msgtype: 'm.audio', body: 'song.mp3' } });
+ expect(eventToPreviewText(ev)).toBe('π΅ Audio');
+ });
+
+ it('returns file icon for m.file', () => {
+ const ev = makeEvent({ content: { msgtype: 'm.file', body: 'doc.pdf' } });
+ expect(eventToPreviewText(ev)).toBe('π File');
+ });
+
+ it('returns encrypted placeholder for encrypted events', () => {
+ const ev = makeEvent({ type: 'm.room.encrypted', content: {} });
+ expect(eventToPreviewText(ev)).toBe('π Encrypted message');
+ });
+
+ it('returns sticker text', () => {
+ const ev = makeEvent({ type: 'm.sticker', content: { body: 'party' } });
+ expect(eventToPreviewText(ev)).toBe('π party');
+ });
+
+ it('returns undefined for redacted events', () => {
+ const ev = makeEvent({ redacted: true });
+ expect(eventToPreviewText(ev)).toBeUndefined();
+ });
+
+ it('returns undefined for reaction events', () => {
+ const ev = makeEvent({ type: 'm.reaction', content: {} });
+ expect(eventToPreviewText(ev)).toBeUndefined();
+ });
+
+ it('returns undefined for edit events (m.replace)', () => {
+ const ev = makeEvent({
+ content: {
+ msgtype: 'm.text',
+ body: 'edited',
+ 'm.relates_to': { rel_type: 'm.replace', event_id: '$orig' },
+ },
+ });
+ expect(eventToPreviewText(ev)).toBeUndefined();
+ });
+
+ it('strips reply fallback from text body', () => {
+ const ev = makeEvent({
+ content: { msgtype: 'm.text', body: '> quoted\n\nreal message' },
+ });
+ expect(eventToPreviewText(ev)).toBe('real message');
+ });
+
+ it('returns undefined for unknown event types', () => {
+ const ev = makeEvent({ type: 'm.room.power_levels', content: {} });
+ expect(eventToPreviewText(ev)).toBeUndefined();
+ });
+});
+
+// -------- getLastMessageText --------
+
+describe('getLastMessageText', () => {
+ const makeMx = (userId = '@alice:test') =>
+ ({ getUserId: () => userId }) as never;
+
+ const makeRoom = (events: ReturnType[], members?: Record) =>
+ ({
+ roomId: '!room:test',
+ getLiveTimeline: () => ({
+ getEvents: () => events,
+ }),
+ getMember: (id: string) => (members?.[id] ? { name: members[id] } : null),
+ }) as never;
+
+ it('returns "You: text" when the sender is the current user', () => {
+ const ev = makeEvent({ sender: '@alice:test', content: { msgtype: 'm.text', body: 'hi' } });
+ expect(getLastMessageText(makeRoom([ev]), makeMx())).toBe('You: hi');
+ });
+
+ it('returns "DisplayName: text" for another user', () => {
+ const ev = makeEvent({ sender: '@bob:test', content: { msgtype: 'm.text', body: 'hey' } });
+ const room = makeRoom([ev], { '@bob:test': 'Bob' });
+ expect(getLastMessageText(room, makeMx())).toBe('Bob: hey');
+ });
+
+ it('falls back to userId when no display name is available', () => {
+ const ev = makeEvent({ sender: '@bob:test', content: { msgtype: 'm.text', body: 'hey' } });
+ const room = makeRoom([ev]);
+ expect(getLastMessageText(room, makeMx())).toBe('@bob:test: hey');
+ });
+
+ it('skips reactions and picks the last real message', () => {
+ const msg = makeEvent({ content: { msgtype: 'm.text', body: 'real' } });
+ const reaction = makeEvent({ type: 'm.reaction', content: {} });
+ expect(getLastMessageText(makeRoom([msg, reaction]), makeMx())).toBe('You: real');
+ });
+
+ it('returns undefined when there are no displayable events', () => {
+ const reaction = makeEvent({ type: 'm.reaction', content: {} });
+ expect(getLastMessageText(makeRoom([reaction]), makeMx())).toBeUndefined();
+ });
+
+ it('returns undefined for an empty timeline', () => {
+ expect(getLastMessageText(makeRoom([]), makeMx())).toBeUndefined();
+ });
+});
+
+// -------- useRoomLastMessage hook --------
+
+describe('useRoomLastMessage', () => {
+ const makeMx = (userId = '@alice:test') => ({
+ getUserId: () => userId,
+ on: vi.fn(),
+ off: vi.fn(),
+ });
+
+ const roomListeners = new Map void)[]>();
+
+ const makeRoom = (events: ReturnType[]) => ({
+ roomId: '!room:test',
+ getLiveTimeline: () => ({ getEvents: () => events }),
+ getMember: () => null,
+ on: vi.fn().mockImplementation((event: string, handler: (...args: unknown[]) => void) => {
+ const list = roomListeners.get(event) ?? [];
+ list.push(handler);
+ roomListeners.set(event, list);
+ }),
+ off: vi.fn(),
+ });
+
+ beforeEach(() => {
+ roomListeners.clear();
+ });
+
+ it('returns undefined when room is undefined', () => {
+ const mx = makeMx();
+ const { result } = renderHook(() => useRoomLastMessage(undefined, mx as never));
+ expect(result.current).toBeUndefined();
+ });
+
+ it('returns the last message preview on mount', () => {
+ const ev = makeEvent({ content: { msgtype: 'm.text', body: 'hello' } });
+ const room = makeRoom([ev]);
+ const mx = makeMx();
+ const { result } = renderHook(() => useRoomLastMessage(room as never, mx as never));
+ expect(result.current).toBe('You: hello');
+ });
+
+ it('updates when a Timeline event fires', () => {
+ const ev1 = makeEvent({ content: { msgtype: 'm.text', body: 'first' } });
+ const events = [ev1];
+ const room = makeRoom(events);
+ const mx = makeMx();
+
+ const { result } = renderHook(() => useRoomLastMessage(room as never, mx as never));
+ expect(result.current).toBe('You: first');
+
+ // Simulate a new message arriving.
+ const ev2 = makeEvent({ content: { msgtype: 'm.text', body: 'second' } });
+ events.push(ev2);
+
+ const timelineHandlers = roomListeners.get('Room.timeline') ?? [];
+ act(() => {
+ timelineHandlers.forEach((h) => h());
+ });
+
+ expect(result.current).toBe('You: second');
+ });
+});
diff --git a/src/app/hooks/useRoomLastMessage.ts b/src/app/hooks/useRoomLastMessage.ts
index 1e87d0092..7f773ec97 100644
--- a/src/app/hooks/useRoomLastMessage.ts
+++ b/src/app/hooks/useRoomLastMessage.ts
@@ -13,7 +13,7 @@ import { MessageEvent } from '$types/matrix/room';
* Strip the legacy reply fallback (lines starting with `> `) that some
* clients prepend when replying to a message.
*/
-function stripReplyFallback(body: string): string {
+export function stripReplyFallback(body: string): string {
const lines = body.split('\n');
let i = 0;
while (i < lines.length && lines[i].startsWith('> ')) i++;
@@ -22,7 +22,7 @@ function stripReplyFallback(body: string): string {
return lines.slice(i).join('\n');
}
-function eventToPreviewText(ev: MatrixEvent): string | undefined {
+export function eventToPreviewText(ev: MatrixEvent): string | undefined {
if (ev.isRedacted()) return undefined;
const type = ev.getType();
@@ -53,7 +53,7 @@ function eventToPreviewText(ev: MatrixEvent): string | undefined {
return undefined;
}
-function getLastMessageText(room: Room, mx: MatrixClient): string | undefined {
+export function getLastMessageText(room: Room, mx: MatrixClient): string | undefined {
const events = room.getLiveTimeline().getEvents();
const match = [...events].reverse().find((ev) => eventToPreviewText(ev) !== undefined);
if (!match) return undefined;
From 9d9dce7aef716cc7828a91a4517ed6b2fb0aea79 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Tue, 14 Apr 2026 23:28:37 -0400
Subject: [PATCH 114/191] fix(polls): handle encrypted poll events in timeline
filter
- Use getEffectiveEvent() to check decrypted type for encrypted poll response/end events
- Add poll start types to isStandardRendered list in useProcessedTimeline
---
src/app/hooks/timeline/useProcessedTimeline.ts | 15 +++++++++++----
1 file changed, 11 insertions(+), 4 deletions(-)
diff --git a/src/app/hooks/timeline/useProcessedTimeline.ts b/src/app/hooks/timeline/useProcessedTimeline.ts
index f44719e88..6f9460043 100644
--- a/src/app/hooks/timeline/useProcessedTimeline.ts
+++ b/src/app/hooks/timeline/useProcessedTimeline.ts
@@ -99,11 +99,16 @@ export function useProcessedTimeline({
// 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.
+ // Also check the effective (decrypted) type for encrypted events that have been decrypted.
+ const effectiveType =
+ type === 'm.room.encrypted'
+ ? ((mEvent.getEffectiveEvent()?.type as string) ?? type)
+ : type;
if (
- type === 'org.matrix.msc3381.poll.response' ||
- type === 'org.matrix.msc3381.poll.end' ||
- type === 'm.poll.response' ||
- type === 'm.poll.end'
+ effectiveType === 'org.matrix.msc3381.poll.response' ||
+ effectiveType === 'org.matrix.msc3381.poll.end' ||
+ effectiveType === 'm.poll.response' ||
+ effectiveType === 'm.poll.end'
)
return acc;
@@ -117,6 +122,8 @@ export function useProcessedTimeline({
'm.room.topic',
'm.room.avatar',
'org.matrix.msc3401.call.member',
+ 'org.matrix.msc3381.poll.start',
+ 'm.poll.start',
].includes(type);
if (!isStandardRendered) {
From 29e407699526d67322ac7bb8bb9321f383573872 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Wed, 15 Apr 2026 00:23:43 -0400
Subject: [PATCH 115/191] refactor: align presence-auto-idle with
sw-push-session-recovery
- Remove activeSession param from useAppVisibility, use mx methods instead
- Switch appEvents to multi-subscriber Set-based pattern
- Update usePresenceAutoIdle to use subscription-based visibility handler
- Update tests for new appEvents API
---
src/app/hooks/useAppVisibility.ts | 20 +++++++---------
src/app/hooks/usePresenceAutoIdle.test.tsx | 25 +++++++++----------
src/app/hooks/usePresenceAutoIdle.ts | 8 +++----
src/app/utils/appEvents.ts | 28 ++++++++++++++++++++--
4 files changed, 50 insertions(+), 31 deletions(-)
diff --git a/src/app/hooks/useAppVisibility.ts b/src/app/hooks/useAppVisibility.ts
index b1d25add0..e3000ecdf 100644
--- a/src/app/hooks/useAppVisibility.ts
+++ b/src/app/hooks/useAppVisibility.ts
@@ -1,6 +1,5 @@
import { useCallback, useEffect, useRef } from 'react';
import { MatrixClient } from '$types/matrix-sdk';
-import { Session } from '$state/sessions';
import { appEvents } from '../utils/appEvents';
import { useClientConfig, useExperimentVariant } from './useClientConfig';
import { createDebugLogger } from '../utils/debugLogger';
@@ -13,11 +12,11 @@ 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) {
+export function useAppVisibility(mx: MatrixClient | undefined) {
const clientConfig = useClientConfig();
const sessionSyncConfig = clientConfig.sessionSync;
- const sessionSyncVariant = useExperimentVariant('sessionSyncStrategy', activeSession?.userId);
+ const sessionSyncVariant = useExperimentVariant('sessionSyncStrategy', mx?.getUserId() ?? undefined);
// Derive phase flags from experiment variant; fall back to direct config when not in experiment.
const inSessionSync = sessionSyncVariant.inExperiment;
@@ -55,9 +54,9 @@ export function useAppVisibility(mx: MatrixClient | undefined, activeSession?: S
const pushSessionNow = useCallback(
(reason: 'foreground' | 'focus' | 'heartbeat'): 'sent' | 'skipped' => {
- const baseUrl = activeSession?.baseUrl;
- const accessToken = activeSession?.accessToken;
- const userId = activeSession?.userId;
+ const baseUrl = mx?.getHomeserverUrl();
+ const accessToken = mx?.getAccessToken();
+ const userId = mx?.getUserId();
const canPush =
!!mx &&
typeof baseUrl === 'string' &&
@@ -88,9 +87,6 @@ export function useAppVisibility(mx: MatrixClient | undefined, activeSession?: S
return 'sent';
},
[
- activeSession?.accessToken,
- activeSession?.baseUrl,
- activeSession?.userId,
mx,
phase1ForegroundResync,
phase2VisibleHeartbeat,
@@ -106,9 +102,9 @@ export function useAppVisibility(mx: MatrixClient | undefined, activeSession?: S
`App visibility changed: ${isVisible ? 'visible (foreground)' : 'hidden (background)'}`,
{ visibilityState: document.visibilityState }
);
- appEvents.onVisibilityChange?.(isVisible);
+ appEvents.emitVisibilityChange(isVisible);
if (!isVisible) {
- appEvents.onVisibilityHidden?.();
+ appEvents.emitVisibilityHidden();
return;
}
@@ -171,7 +167,7 @@ export function useAppVisibility(mx: MatrixClient | undefined, activeSession?: S
]);
useEffect(() => {
- if (!phase2VisibleHeartbeat || !mx) return undefined;
+ if (!phase2VisibleHeartbeat) return undefined;
// Reset adaptive backoff/suppression so a config or session change starts fresh.
heartbeatFailuresRef.current = 0;
diff --git a/src/app/hooks/usePresenceAutoIdle.test.tsx b/src/app/hooks/usePresenceAutoIdle.test.tsx
index 0ebfd744a..043598c55 100644
--- a/src/app/hooks/usePresenceAutoIdle.test.tsx
+++ b/src/app/hooks/usePresenceAutoIdle.test.tsx
@@ -52,12 +52,10 @@ beforeEach(() => {
userListeners.clear();
mockUser = makeMockUser();
mockMx = makeMockMx();
- appEvents.onVisibilityChange = null;
});
afterEach(() => {
vi.useRealTimers();
- appEvents.onVisibilityChange = null;
});
// -------- tests --------
@@ -110,7 +108,7 @@ describe('usePresenceAutoIdle', () => {
// Simulate app returning to foreground.
act(() => {
- appEvents.onVisibilityChange?.(true);
+ appEvents.emitVisibilityChange(true);
});
expect(result.current).toBe(false);
});
@@ -218,21 +216,24 @@ describe('usePresenceAutoIdle', () => {
expect(result.current).toBe(false);
});
- it('restores previous appEvents.onVisibilityChange on cleanup', () => {
- const prev = vi.fn();
- appEvents.onVisibilityChange = prev;
-
- const { unmount } = renderHook(
+ it('unsubscribes from appEvents.onVisibilityChange on cleanup', () => {
+ const { result, unmount } = renderHook(
() => useAutoIdledReader(mockMx, 'online', true, 5000),
{ wrapper }
);
- // Our handler should be installed.
- expect(appEvents.onVisibilityChange).not.toBe(prev);
+ // Go idle.
+ act(() => {
+ vi.advanceTimersByTime(5000);
+ });
+ expect(result.current).toBe(true);
unmount();
- // Previous handler should be restored.
- expect(appEvents.onVisibilityChange).toBe(prev);
+ // After unmount, emitting visibility change should have no effect.
+ // (No error thrown means the handler was properly unsubscribed.)
+ act(() => {
+ appEvents.emitVisibilityChange(true);
+ });
});
});
diff --git a/src/app/hooks/usePresenceAutoIdle.ts b/src/app/hooks/usePresenceAutoIdle.ts
index abf25edba..dc5af7e21 100644
--- a/src/app/hooks/usePresenceAutoIdle.ts
+++ b/src/app/hooks/usePresenceAutoIdle.ts
@@ -73,16 +73,14 @@ export function usePresenceAutoIdle(
// When the app returns to the foreground, treat it as activity so the user
// isn't shown as idle the moment they switch back to the tab/PWA.
- const prevOnVisibilityChange = appEvents.onVisibilityChange;
- appEvents.onVisibilityChange = (isVisible: boolean) => {
- prevOnVisibilityChange?.(isVisible);
+ const unsubVisibility = appEvents.onVisibilityChange((isVisible: boolean) => {
if (isVisible) handleActivity();
- };
+ });
return () => {
ACTIVITY_EVENTS.forEach((ev) => document.removeEventListener(ev, handleActivity));
clearTimer();
- appEvents.onVisibilityChange = prevOnVisibilityChange;
+ unsubVisibility();
};
}, [clearTimer, presenceMode, sendPresence, setAutoIdled, timeoutMs]);
diff --git a/src/app/utils/appEvents.ts b/src/app/utils/appEvents.ts
index 2834c5b6f..2430f5324 100644
--- a/src/app/utils/appEvents.ts
+++ b/src/app/utils/appEvents.ts
@@ -1,5 +1,29 @@
+export type VisibilityChangeHandler = (isVisible: boolean) => void;
+type VisibilityHiddenHandler = () => void;
+
+const visibilityChangeHandlers = new Set();
+const visibilityHiddenHandlers = new Set();
+
export const appEvents = {
- onVisibilityHidden: null as (() => void) | null,
+ onVisibilityHidden(handler: VisibilityHiddenHandler): () => void {
+ visibilityHiddenHandlers.add(handler);
+ return () => {
+ visibilityHiddenHandlers.delete(handler);
+ };
+ },
+
+ emitVisibilityHidden(): void {
+ visibilityHiddenHandlers.forEach((h) => h());
+ },
+
+ onVisibilityChange(handler: VisibilityChangeHandler): () => void {
+ visibilityChangeHandlers.add(handler);
+ return () => {
+ visibilityChangeHandlers.delete(handler);
+ };
+ },
- onVisibilityChange: null as ((isVisible: boolean) => void) | null,
+ emitVisibilityChange(isVisible: boolean): void {
+ visibilityChangeHandlers.forEach((h) => h(isVisible));
+ },
};
From 69179c15648c1e1d46b4bf7f80eea9f7b83103a7 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Wed, 15 Apr 2026 10:58:40 -0400
Subject: [PATCH 116/191] style: fix lint errors from merge
---
src/app/hooks/useAppVisibility.ts | 12 ++--
src/app/hooks/usePresenceAutoIdle.test.tsx | 66 ++++++++--------------
2 files changed, 30 insertions(+), 48 deletions(-)
diff --git a/src/app/hooks/useAppVisibility.ts b/src/app/hooks/useAppVisibility.ts
index e3000ecdf..af2bd2e69 100644
--- a/src/app/hooks/useAppVisibility.ts
+++ b/src/app/hooks/useAppVisibility.ts
@@ -16,7 +16,10 @@ export function useAppVisibility(mx: MatrixClient | undefined) {
const clientConfig = useClientConfig();
const sessionSyncConfig = clientConfig.sessionSync;
- const sessionSyncVariant = useExperimentVariant('sessionSyncStrategy', mx?.getUserId() ?? undefined);
+ const sessionSyncVariant = useExperimentVariant(
+ 'sessionSyncStrategy',
+ mx?.getUserId() ?? undefined
+ );
// Derive phase flags from experiment variant; fall back to direct config when not in experiment.
const inSessionSync = sessionSyncVariant.inExperiment;
@@ -86,12 +89,7 @@ export function useAppVisibility(mx: MatrixClient | undefined) {
});
return 'sent';
},
- [
- mx,
- phase1ForegroundResync,
- phase2VisibleHeartbeat,
- phase3AdaptiveBackoffJitter,
- ]
+ [mx, phase1ForegroundResync, phase2VisibleHeartbeat, phase3AdaptiveBackoffJitter]
);
useEffect(() => {
diff --git a/src/app/hooks/usePresenceAutoIdle.test.tsx b/src/app/hooks/usePresenceAutoIdle.test.tsx
index 043598c55..8e2f6d138 100644
--- a/src/app/hooks/usePresenceAutoIdle.test.tsx
+++ b/src/app/hooks/usePresenceAutoIdle.test.tsx
@@ -1,10 +1,10 @@
import { act, renderHook } from '@testing-library/react';
import { Provider, useAtomValue } from 'jotai';
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
-import { usePresenceAutoIdle } from './usePresenceAutoIdle';
import { presenceAutoIdledAtom } from '$state/settings';
import { appEvents } from '$utils/appEvents';
import type { ReactNode } from 'react';
+import { usePresenceAutoIdle } from './usePresenceAutoIdle';
// -------- mock setup --------
@@ -39,7 +39,6 @@ function useAutoIdledReader(
sendPresence: boolean,
timeoutMs: number
) {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
usePresenceAutoIdle(mx as any, presenceMode, sendPresence, timeoutMs);
return useAtomValue(presenceAutoIdledAtom);
}
@@ -62,10 +61,9 @@ afterEach(() => {
describe('usePresenceAutoIdle', () => {
it('sets auto-idle after the timeout elapses', () => {
- const { result } = renderHook(
- () => useAutoIdledReader(mockMx, 'online', true, 5000),
- { wrapper }
- );
+ const { result } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 5000), {
+ wrapper,
+ });
expect(result.current).toBe(false);
@@ -77,10 +75,9 @@ describe('usePresenceAutoIdle', () => {
});
it('resets auto-idle when user activity is detected', () => {
- const { result } = renderHook(
- () => useAutoIdledReader(mockMx, 'online', true, 5000),
- { wrapper }
- );
+ const { result } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 5000), {
+ wrapper,
+ });
// Go idle.
act(() => {
@@ -96,10 +93,9 @@ describe('usePresenceAutoIdle', () => {
});
it('resets auto-idle when app becomes visible via appEvents', () => {
- const { result } = renderHook(
- () => useAutoIdledReader(mockMx, 'online', true, 5000),
- { wrapper }
- );
+ const { result } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 5000), {
+ wrapper,
+ });
act(() => {
vi.advanceTimersByTime(5000);
@@ -114,10 +110,7 @@ describe('usePresenceAutoIdle', () => {
});
it('does not go idle when presenceMode is not online', () => {
- const { result } = renderHook(
- () => useAutoIdledReader(mockMx, 'dnd', true, 5000),
- { wrapper }
- );
+ const { result } = renderHook(() => useAutoIdledReader(mockMx, 'dnd', true, 5000), { wrapper });
act(() => {
vi.advanceTimersByTime(10000);
@@ -126,10 +119,9 @@ describe('usePresenceAutoIdle', () => {
});
it('does not go idle when sendPresence is false', () => {
- const { result } = renderHook(
- () => useAutoIdledReader(mockMx, 'online', false, 5000),
- { wrapper }
- );
+ const { result } = renderHook(() => useAutoIdledReader(mockMx, 'online', false, 5000), {
+ wrapper,
+ });
act(() => {
vi.advanceTimersByTime(10000);
@@ -138,10 +130,7 @@ describe('usePresenceAutoIdle', () => {
});
it('does not go idle when timeoutMs is 0', () => {
- const { result } = renderHook(
- () => useAutoIdledReader(mockMx, 'online', true, 0),
- { wrapper }
- );
+ const { result } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 0), { wrapper });
act(() => {
vi.advanceTimersByTime(10000);
@@ -150,10 +139,9 @@ describe('usePresenceAutoIdle', () => {
});
it('restarts the idle timer on activity before timeout', () => {
- const { result } = renderHook(
- () => useAutoIdledReader(mockMx, 'online', true, 5000),
- { wrapper }
- );
+ const { result } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 5000), {
+ wrapper,
+ });
// Advance partially, then trigger activity.
act(() => {
@@ -194,10 +182,9 @@ describe('usePresenceAutoIdle', () => {
});
it('clears auto-idle when another device sets presence to online', () => {
- const { result } = renderHook(
- () => useAutoIdledReader(mockMx, 'online', true, 5000),
- { wrapper }
- );
+ const { result } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 5000), {
+ wrapper,
+ });
act(() => {
vi.advanceTimersByTime(5000);
@@ -209,18 +196,15 @@ describe('usePresenceAutoIdle', () => {
expect(handlers.length).toBeGreaterThan(0);
act(() => {
- handlers.forEach((h) =>
- h({}, { userId: '@alice:test', presence: 'online' })
- );
+ handlers.forEach((h) => h({}, { userId: '@alice:test', presence: 'online' }));
});
expect(result.current).toBe(false);
});
it('unsubscribes from appEvents.onVisibilityChange on cleanup', () => {
- const { result, unmount } = renderHook(
- () => useAutoIdledReader(mockMx, 'online', true, 5000),
- { wrapper }
- );
+ const { result, unmount } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 5000), {
+ wrapper,
+ });
// Go idle.
act(() => {
From d7fd6406de89d693fd74168a1a7787b1f3a2159d Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Wed, 15 Apr 2026 11:58:45 -0400
Subject: [PATCH 117/191] fix(presence): address review feedback
- Fix test mock path to match relative import
- Only send status_msg when explicitly setting DND (avoid clearing user status)
- Guard lastActiveTs null check to prevent false presence badges
- Remove unrelated enableMessageBookmarks leak from PR scope
- Revert DevelopTools rotate-sessions changes (belongs in PR #670)
- Add in-memory presence REST cache + in-flight dedupe to prevent N+1 floods
- Only log 5xx server errors in presence fetch (suppress 404/network)
- Close status picker menu after selection for UX consistency
- Guard heartbeat effect on mx being defined
---
.../settings/developer-tools/DevelopTools.tsx | 82 +------------------
src/app/hooks/useAppVisibility.ts | 2 +-
src/app/hooks/useUserPresence.test.tsx | 5 +-
src/app/hooks/useUserPresence.ts | 72 ++++++++++++----
src/app/pages/client/ClientNonUIFeatures.tsx | 2 +-
.../client/sidebar/AccountSwitcherTab.tsx | 1 +
.../pages/client/sidebar/DirectDMsList.tsx | 13 ++-
src/app/state/settings.ts | 6 --
8 files changed, 76 insertions(+), 107 deletions(-)
diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx
index 6bfd0f6cb..c8ffeb12d 100644
--- a/src/app/features/settings/developer-tools/DevelopTools.tsx
+++ b/src/app/features/settings/developer-tools/DevelopTools.tsx
@@ -1,6 +1,5 @@
import { useCallback, useState } from 'react';
-import { Box, Text, Scroll, Switch, Button, Spinner, color } from 'folds';
-import { KnownMembership } from '$types/matrix-sdk';
+import { Box, Text, Scroll, Switch, Button } from 'folds';
import { PageContent } from '$components/page';
import { SequenceCard } from '$components/sequence-card';
import { SettingTile } from '$components/setting-tile';
@@ -10,7 +9,6 @@ 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';
@@ -27,33 +25,6 @@ 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.
@@ -138,57 +109,6 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp
)}
{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 && (
{
- if (!phase2VisibleHeartbeat) return undefined;
+ if (!phase2VisibleHeartbeat || !mx) return undefined;
// Reset adaptive backoff/suppression so a config or session change starts fresh.
heartbeatFailuresRef.current = 0;
diff --git a/src/app/hooks/useUserPresence.test.tsx b/src/app/hooks/useUserPresence.test.tsx
index c311563b6..78f334d71 100644
--- a/src/app/hooks/useUserPresence.test.tsx
+++ b/src/app/hooks/useUserPresence.test.tsx
@@ -1,6 +1,6 @@
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
-import { useUserPresence, Presence } from './useUserPresence';
+import { useUserPresence, Presence, clearPresenceCache } from './useUserPresence';
// ------- mock setup -------
@@ -45,7 +45,7 @@ const mockMx = {
removeListener: vi.fn(),
};
-vi.mock('$hooks/useMatrixClient', () => ({
+vi.mock('./useMatrixClient', () => ({
useMatrixClient: () => mockMx,
}));
@@ -54,6 +54,7 @@ const USER_ID = '@alice:test';
beforeEach(() => {
vi.clearAllMocks();
userListeners.clear();
+ clearPresenceCache();
mockUser = null;
mockGetPresence = () => new Promise(() => {}); // pending by default
mockMx.getUser.mockImplementation(() => mockUser);
diff --git a/src/app/hooks/useUserPresence.ts b/src/app/hooks/useUserPresence.ts
index 71a3d5b43..6cabf4546 100644
--- a/src/app/hooks/useUserPresence.ts
+++ b/src/app/hooks/useUserPresence.ts
@@ -22,6 +22,60 @@ const getUserPresence = (user: User): UserPresence => ({
lastActiveTs: user.getLastActiveTs(),
});
+// In-memory presence REST cache to avoid N+1 /presence/{userId}/status floods.
+// Multiple hook instances for the same user share a single in-flight request.
+const PRESENCE_CACHE_TTL_MS = 60_000;
+const presenceCache = new Map();
+const presenceInflight = new Map>();
+
+/** Visible for testing β clears the in-memory REST presence cache. */
+export function clearPresenceCache(): void {
+ presenceCache.clear();
+ presenceInflight.clear();
+}
+
+function fetchPresenceOnce(
+ mx: { getPresence: (userId: string) => Promise<{ presence: string; status_msg?: string; currently_active?: boolean; last_active_ago?: number | null }> },
+ userId: string
+): Promise {
+ const cached = presenceCache.get(userId);
+ if (cached && Date.now() - cached.fetchedAt < PRESENCE_CACHE_TTL_MS) {
+ return Promise.resolve(cached.data);
+ }
+
+ const existing = presenceInflight.get(userId);
+ if (existing) return existing;
+
+ const promise = mx
+ .getPresence(userId)
+ .then((resp) => {
+ const data: UserPresence = {
+ 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,
+ };
+ presenceCache.set(userId, { data, fetchedAt: Date.now() });
+ return data;
+ })
+ .catch((err: unknown) => {
+ // Suppress expected failures (404/403 = presence not supported, network errors).
+ // Only log unexpected server errors (5xx) for debugging.
+ const status = (err as { httpStatus?: number })?.httpStatus;
+ if (status && status >= 500) {
+ console.warn('[useUserPresence] REST fetch failed for', userId, err);
+ }
+ return undefined;
+ })
+ .finally(() => {
+ presenceInflight.delete(userId);
+ });
+
+ presenceInflight.set(userId, promise);
+ return promise;
+}
+
export const useUserPresence = (userId: string): UserPresence | undefined => {
const mx = useMatrixClient();
const user = mx.getUser(userId);
@@ -38,20 +92,10 @@ export const useUserPresence = (userId: string): UserPresence | undefined => {
// 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;
- 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.
- });
+ fetchPresenceOnce(mx, userId).then((data) => {
+ if (cancelled || !data) return;
+ setPresence(data);
+ });
}
const updatePresence: UserEventHandlerMap[UserEvent.Presence] = (event, u) => {
diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx
index e4a8037ac..7c71db23a 100644
--- a/src/app/pages/client/ClientNonUIFeatures.tsx
+++ b/src/app/pages/client/ClientNonUIFeatures.tsx
@@ -871,7 +871,7 @@ function PresenceFeature() {
// their presence events because the extension is still enabled above.
mx.setPresence({
presence: effectiveState,
- status_msg: sendPresence && effectiveMode === 'dnd' ? 'dnd' : '',
+ ...(sendPresence && effectiveMode === 'dnd' ? { status_msg: 'dnd' } : {}),
}).catch(() => {
// Server doesn't support presence β ignore.
});
diff --git a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx
index 737bcf7c4..191a585dd 100644
--- a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx
+++ b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx
@@ -421,6 +421,7 @@ export function AccountSwitcherTab() {
setAutoIdled(false);
// Re-enable presence broadcasting if the master toggle was off
if (!sendPresence) setSendPresence(true);
+ setMenuAnchor(undefined);
}}
>
diff --git a/src/app/pages/client/sidebar/DirectDMsList.tsx b/src/app/pages/client/sidebar/DirectDMsList.tsx
index 60063d0a3..4d142985d 100644
--- a/src/app/pages/client/sidebar/DirectDMsList.tsx
+++ b/src/app/pages/client/sidebar/DirectDMsList.tsx
@@ -62,11 +62,20 @@ function DMItem({ room, selected }: DMItemProps) {
const groupDMOnline =
isGroupDM &&
[member0Presence, member1Presence, member2Presence].some(
- (p) => p && p.lastActiveTs !== 0 && p.presence === Presence.Online
+ (p) =>
+ p &&
+ p.lastActiveTs != null &&
+ p.lastActiveTs !== 0 &&
+ p.presence === Presence.Online
);
let presenceBadge: ReactNode;
- if (!isGroupDM && singleDMPresence && singleDMPresence.lastActiveTs !== 0) {
+ if (
+ !isGroupDM &&
+ singleDMPresence &&
+ singleDMPresence.lastActiveTs != null &&
+ singleDMPresence.lastActiveTs !== 0
+ ) {
presenceBadge = ;
} else if (isGroupDM && groupDMOnline) {
presenceBadge = ;
diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts
index 935b420ba..adcf90c71 100644
--- a/src/app/state/settings.ts
+++ b/src/app/state/settings.ts
@@ -125,9 +125,6 @@ export interface Settings {
showPersonaSetting: boolean;
closeFoldersByDefault: boolean;
- // experimental
- enableMessageBookmarks: boolean;
-
// furry stuff
renderAnimals: boolean;
}
@@ -230,9 +227,6 @@ const defaultSettings: Settings = {
showPersonaSetting: false,
closeFoldersByDefault: false,
- // experimental
- enableMessageBookmarks: false,
-
// furry stuff
renderAnimals: true,
};
From 82bde318b88160c38912a45788942205d14770fe Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Wed, 15 Apr 2026 12:02:12 -0400
Subject: [PATCH 118/191] fix(dev-tools): add error handling for
prepareToEncrypt
---
.../features/settings/developer-tools/DevelopTools.tsx | 10 ++++++++--
1 file changed, 8 insertions(+), 2 deletions(-)
diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx
index e9ef1ee89..24445bdcc 100644
--- a/src/app/features/settings/developer-tools/DevelopTools.tsx
+++ b/src/app/features/settings/developer-tools/DevelopTools.tsx
@@ -57,8 +57,14 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp
const rotated = results.filter((r) => r.status === 'fulfilled').length;
// Proactively start session creation + key sharing with all devices
- // (including bridge bots). fire-and-forget per room.
- encryptedRooms.forEach((room) => crypto.prepareToEncrypt(room));
+ // (including bridge bots). fire-and-forget per room, but surface failures.
+ encryptedRooms.forEach((room) => {
+ void Promise.resolve()
+ .then(() => crypto.prepareToEncrypt(room))
+ .catch((error) => {
+ console.error('Failed to prepare room encryption', room.roomId, error);
+ });
+ });
return { rotated, total: encryptedRooms.length };
}, [mx])
From 7680897e73708217dd3ddc09596556eb81600416 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Wed, 15 Apr 2026 13:33:54 -0400
Subject: [PATCH 119/191] fix(polls): strip invalid vote selections instead of
discarding entire vote Per MSC3381, invalid individual answer selections
should be stripped but remaining valid selections should still be counted.
Changed from valid.every() (discard all) to valid.filter() (strip invalid,
keep valid).
---
src/app/features/room/poll/PollEvent.tsx | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/src/app/features/room/poll/PollEvent.tsx b/src/app/features/room/poll/PollEvent.tsx
index 678a9cfd2..d1260e1b6 100644
--- a/src/app/features/room/poll/PollEvent.tsx
+++ b/src/app/features/room/poll/PollEvent.tsx
@@ -148,16 +148,16 @@ export function computeTally(
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;
+ // Per MSC3381, strip invalid answer IDs but keep the remaining valid ones.
+ const valid = selections.slice(0, maxSelections).filter((s) => validAnswerIds.has(s));
+ if (valid.length === 0) 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;
+ myVote = myEntry.selections.slice(0, maxSelections).filter((s) => validAnswerIds.has(s));
}
return { tally, myVote, isEnded };
From 850e025e7c370f73bef8a302520508c8194dbbd1 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Wed, 15 Apr 2026 13:34:33 -0400
Subject: [PATCH 120/191] fix(room-nav): use effective event type for decrypted
message previews Use getEffectiveEvent()?.type instead of getType() to get
the decrypted event type. getType() returns the wire type (m.room.encrypted)
even after decryption, causing previews to always show 'Encrypted message'
instead of the actual message content.
---
src/app/hooks/useRoomLastMessage.test.tsx | 17 +++++++++++++++--
src/app/hooks/useRoomLastMessage.ts | 12 ++++++++----
2 files changed, 23 insertions(+), 6 deletions(-)
diff --git a/src/app/hooks/useRoomLastMessage.test.tsx b/src/app/hooks/useRoomLastMessage.test.tsx
index 4e3065583..5f685f342 100644
--- a/src/app/hooks/useRoomLastMessage.test.tsx
+++ b/src/app/hooks/useRoomLastMessage.test.tsx
@@ -15,13 +15,17 @@ function makeEvent(overrides: {
sender?: string;
roomId?: string;
redacted?: boolean;
+ effectiveType?: string;
}) {
+ const type = overrides.type ?? 'm.room.message';
+ const content = overrides.content ?? { msgtype: 'm.text', body: 'hello' };
return {
- getType: () => overrides.type ?? 'm.room.message',
- getContent: () => overrides.content ?? { msgtype: 'm.text', body: 'hello' },
+ getType: () => type,
+ getContent: () => content,
getSender: () => overrides.sender ?? '@alice:test',
getRoomId: () => overrides.roomId ?? '!room:test',
isRedacted: () => overrides.redacted ?? false,
+ getEffectiveEvent: () => ({ type: overrides.effectiveType ?? type, content }),
} as never;
}
@@ -95,6 +99,15 @@ describe('eventToPreviewText', () => {
expect(eventToPreviewText(ev)).toBe('π Encrypted message');
});
+ it('returns decrypted content when event has been decrypted', () => {
+ const ev = makeEvent({
+ type: 'm.room.encrypted',
+ content: { msgtype: 'm.text', body: 'decrypted text' },
+ effectiveType: 'm.room.message',
+ });
+ expect(eventToPreviewText(ev)).toBe('decrypted text');
+ });
+
it('returns sticker text', () => {
const ev = makeEvent({ type: 'm.sticker', content: { body: 'party' } });
expect(eventToPreviewText(ev)).toBe('π party');
diff --git a/src/app/hooks/useRoomLastMessage.ts b/src/app/hooks/useRoomLastMessage.ts
index 7f773ec97..c8f27e2a3 100644
--- a/src/app/hooks/useRoomLastMessage.ts
+++ b/src/app/hooks/useRoomLastMessage.ts
@@ -25,17 +25,21 @@ export function stripReplyFallback(body: string): string {
export function eventToPreviewText(ev: MatrixEvent): string | undefined {
if (ev.isRedacted()) return undefined;
- const type = ev.getType();
+ // After decryption, getType() still returns 'm.room.encrypted' (the wire type).
+ // Use the effective event type to get the decrypted type when available.
+ const effectiveType = (ev.getEffectiveEvent()?.type as string | undefined) ?? ev.getType();
+ const type = effectiveType;
+ const content = ev.getContent();
// Skip reactions and edits β they aren't standalone messages.
if (type === MessageEvent.Reaction) return undefined;
- const relType = ev.getContent()?.['m.relates_to']?.rel_type;
+ const relType = content?.['m.relates_to']?.rel_type;
if (relType === 'm.replace') return undefined;
+ // Only show encrypted placeholder if the event is still encrypted (not yet decrypted).
if (type === MessageEvent.RoomMessageEncrypted) return 'π Encrypted message';
if (type === MessageEvent.RoomMessage) {
- const content = ev.getContent();
const { msgtype } = content;
if (msgtype === MsgType.Text || msgtype === MsgType.Emote || msgtype === MsgType.Notice) {
return stripReplyFallback(content.body);
@@ -47,7 +51,7 @@ export function eventToPreviewText(ev: MatrixEvent): string | undefined {
}
if (type === MessageEvent.Sticker) {
- return `π ${ev.getContent().body ?? 'Sticker'}`;
+ return `π ${content.body ?? 'Sticker'}`;
}
return undefined;
From 7bb22d20e932e1a403adbf357642e396c609a4d6 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Wed, 15 Apr 2026 17:52:48 -0400
Subject: [PATCH 121/191] fix(nav): check DM membership before space parents in
useRoomNavigate
Mirrors the fix already applied in useNotificationJumper: when a room
belongs to both the direct-message list and a space, prefer the /direct
route over the space route. Previously useRoomNavigate checked orphan
space parents first, which caused bookmark jumps and room-nav clicks on
DMs-in-spaces to open the room via the space path instead of the direct
path.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
src/app/hooks/useRoomNavigate.ts | 25 +++++++++++++++----------
1 file changed, 15 insertions(+), 10 deletions(-)
diff --git a/src/app/hooks/useRoomNavigate.ts b/src/app/hooks/useRoomNavigate.ts
index 51555125c..c2918d5ca 100644
--- a/src/app/hooks/useRoomNavigate.ts
+++ b/src/app/hooks/useRoomNavigate.ts
@@ -37,7 +37,20 @@ export const useRoomNavigate = () => {
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId);
const openSpaceTimeline = developerTools && spaceSelectedId === roomId;
- const orphanParents = openSpaceTimeline ? [roomId] : getOrphanParents(roomToParents, roomId);
+ // Developer-mode: view the space's own timeline (must be checked first).
+ if (openSpaceTimeline) {
+ navigate(getSpaceRoomPath(roomIdOrAlias, roomId, eventId), opts);
+ return;
+ }
+
+ // DMs take priority over space membership so direct chats always open
+ // via the direct route, even when the room also belongs to a space.
+ if (mDirects.has(roomId)) {
+ navigate(getDirectRoomPath(roomIdOrAlias, eventId), opts);
+ return;
+ }
+
+ const orphanParents = getOrphanParents(roomToParents, roomId);
if (orphanParents.length > 0) {
let parentSpace: string;
if (spaceSelectedId && orphanParents.includes(spaceSelectedId)) {
@@ -48,15 +61,7 @@ export const useRoomNavigate = () => {
const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(mx, parentSpace);
- navigate(
- getSpaceRoomPath(pSpaceIdOrAlias, openSpaceTimeline ? roomId : roomIdOrAlias, eventId),
- opts
- );
- return;
- }
-
- if (mDirects.has(roomId)) {
- navigate(getDirectRoomPath(roomIdOrAlias, eventId), opts);
+ navigate(getSpaceRoomPath(pSpaceIdOrAlias, roomIdOrAlias, eventId), opts);
return;
}
From 42c59adc3f2617d0975aedac9ea87030cbc8cc91 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Wed, 15 Apr 2026 18:00:19 -0400
Subject: [PATCH 122/191] chore(prompts): add rebuild integration and review
upstream PRs prompts
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.github/prompts/rebuild integration.prompt.md | 12 ++++++++++++
.../review open PRs against `upstream`.prompt.md | 10 ++++++++++
2 files changed, 22 insertions(+)
create mode 100644 .github/prompts/rebuild integration.prompt.md
create mode 100644 .github/prompts/review open PRs against `upstream`.prompt.md
diff --git a/.github/prompts/rebuild integration.prompt.md b/.github/prompts/rebuild integration.prompt.md
new file mode 100644
index 000000000..000673e52
--- /dev/null
+++ b/.github/prompts/rebuild integration.prompt.md
@@ -0,0 +1,12 @@
+---
+name: rebuild integration
+description: When asked to rebuild integration, or if there are large numbers of changes to branches
+---
+
+
+
+Please rebuild the `integration` branch, by deleting `integration` and then creating a new `integration` branch from `dev`, after updating `dev` from `upstream/dev` (and push `dev` to `origin/dev`). This is needed because there are large numbers of changes to branches, and rebuilding the integration branch will help to ensure that it is up to date with the latest changes.
+
+Please prompt for which branches to include, and always include `personal/config`, as it is needed for the integration branch to work properly. If there are any other branches that need to be included, please prompt for those as well.
+
+We should also ensure that any necessary tests are run after rebuilding the integration branch, to verify that everything is working correctly. Please let me know if you have any questions or need any assistance with this process.
\ No newline at end of file
diff --git a/.github/prompts/review open PRs against `upstream`.prompt.md b/.github/prompts/review open PRs against `upstream`.prompt.md
new file mode 100644
index 000000000..7c85531be
--- /dev/null
+++ b/.github/prompts/review open PRs against `upstream`.prompt.md
@@ -0,0 +1,10 @@
+---
+name: review open PRs against `upstream`
+description: When asked to review open PRs against `upstream`
+---
+
+
+
+Please look for all of my open/pending PRs against `upstream`, and review them for any issues, such as merge conflicts, failing checks, comments, or outdated code. If you find any problems, please provide feedback on how to resolve them. And/or implement the necessary changes to get the PRs ready for merging.
+
+Once done, please provide a summary of the status of each PR, including any actions taken or needed to get them merged.
\ No newline at end of file
From cb243020b391fa96f84c4a592a918b3d079d2e52 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Wed, 15 Apr 2026 18:17:56 -0400
Subject: [PATCH 123/191] chore: fix lint and format issues
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
src/app/hooks/useUserPresence.ts | 12 +++++++++---
src/app/pages/client/sidebar/DirectDMsList.tsx | 6 +-----
2 files changed, 10 insertions(+), 8 deletions(-)
diff --git a/src/app/hooks/useUserPresence.ts b/src/app/hooks/useUserPresence.ts
index 6cabf4546..c8e2a480f 100644
--- a/src/app/hooks/useUserPresence.ts
+++ b/src/app/hooks/useUserPresence.ts
@@ -35,7 +35,14 @@ export function clearPresenceCache(): void {
}
function fetchPresenceOnce(
- mx: { getPresence: (userId: string) => Promise<{ presence: string; status_msg?: string; currently_active?: boolean; last_active_ago?: number | null }> },
+ mx: {
+ getPresence: (userId: string) => Promise<{
+ presence: string;
+ status_msg?: string;
+ currently_active?: boolean;
+ last_active_ago?: number | null;
+ }>;
+ },
userId: string
): Promise {
const cached = presenceCache.get(userId);
@@ -53,8 +60,7 @@ function fetchPresenceOnce(
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,
+ lastActiveTs: resp.last_active_ago != null ? Date.now() - resp.last_active_ago : undefined,
};
presenceCache.set(userId, { data, fetchedAt: Date.now() });
return data;
diff --git a/src/app/pages/client/sidebar/DirectDMsList.tsx b/src/app/pages/client/sidebar/DirectDMsList.tsx
index 4d142985d..72d10775a 100644
--- a/src/app/pages/client/sidebar/DirectDMsList.tsx
+++ b/src/app/pages/client/sidebar/DirectDMsList.tsx
@@ -62,11 +62,7 @@ function DMItem({ room, selected }: DMItemProps) {
const groupDMOnline =
isGroupDM &&
[member0Presence, member1Presence, member2Presence].some(
- (p) =>
- p &&
- p.lastActiveTs != null &&
- p.lastActiveTs !== 0 &&
- p.presence === Presence.Online
+ (p) => p && p.lastActiveTs != null && p.lastActiveTs !== 0 && p.presence === Presence.Online
);
let presenceBadge: ReactNode;
From 3149a3797c6277be4dbad392f864cfbff553118a Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Wed, 15 Apr 2026 18:18:01 -0400
Subject: [PATCH 124/191] chore: fix lint and format issues
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
src/app/hooks/useRoomLastMessage.test.tsx | 3 +--
src/app/hooks/useRoomLastMessage.ts | 4 ++--
src/client/slidingSync.ts | 6 +++++-
3 files changed, 8 insertions(+), 5 deletions(-)
diff --git a/src/app/hooks/useRoomLastMessage.test.tsx b/src/app/hooks/useRoomLastMessage.test.tsx
index 5f685f342..e58357834 100644
--- a/src/app/hooks/useRoomLastMessage.test.tsx
+++ b/src/app/hooks/useRoomLastMessage.test.tsx
@@ -150,8 +150,7 @@ describe('eventToPreviewText', () => {
// -------- getLastMessageText --------
describe('getLastMessageText', () => {
- const makeMx = (userId = '@alice:test') =>
- ({ getUserId: () => userId }) as never;
+ const makeMx = (userId = '@alice:test') => ({ getUserId: () => userId }) as never;
const makeRoom = (events: ReturnType[], members?: Record) =>
({
diff --git a/src/app/hooks/useRoomLastMessage.ts b/src/app/hooks/useRoomLastMessage.ts
index c8f27e2a3..e0d6d99f4 100644
--- a/src/app/hooks/useRoomLastMessage.ts
+++ b/src/app/hooks/useRoomLastMessage.ts
@@ -16,9 +16,9 @@ import { MessageEvent } from '$types/matrix/room';
export function stripReplyFallback(body: string): string {
const lines = body.split('\n');
let i = 0;
- while (i < lines.length && lines[i].startsWith('> ')) i++;
+ while (i < lines.length && lines[i].startsWith('> ')) i += 1;
// Skip the blank separator line that follows the fallback block.
- if (i > 0 && i < lines.length && lines[i] === '') i++;
+ if (i > 0 && i < lines.length && lines[i] === '') i += 1;
return lines.slice(i).join('\n');
}
diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts
index 802157123..91c90c2a2 100644
--- a/src/client/slidingSync.ts
+++ b/src/client/slidingSync.ts
@@ -147,7 +147,11 @@ const buildUnencryptedSubscription = (timelineLimit: number): MSC3575RoomSubscri
],
});
-const buildLists = (pageSize: number, includeInviteList: boolean, listTimelineLimit: number): Map => {
+const buildLists = (
+ pageSize: number,
+ includeInviteList: boolean,
+ listTimelineLimit: number
+): Map => {
const lists = new Map();
const listRequiredState = buildListRequiredState();
From 56de896f99de7c8e0c8f67eab8efaaa8ae47ce8c Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Wed, 15 Apr 2026 18:18:03 -0400
Subject: [PATCH 125/191] chore: fix lint and format issues
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
src/app/features/settings/developer-tools/DevelopTools.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx
index 24445bdcc..470ca95c5 100644
--- a/src/app/features/settings/developer-tools/DevelopTools.tsx
+++ b/src/app/features/settings/developer-tools/DevelopTools.tsx
@@ -59,7 +59,7 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp
// Proactively start session creation + key sharing with all devices
// (including bridge bots). fire-and-forget per room, but surface failures.
encryptedRooms.forEach((room) => {
- void Promise.resolve()
+ Promise.resolve()
.then(() => crypto.prepareToEncrypt(room))
.catch((error) => {
console.error('Failed to prepare room encryption', room.roomId, error);
From ca65cef132629089189d4c4078df6f7e1ef76b3d Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Wed, 15 Apr 2026 18:22:49 -0400
Subject: [PATCH 126/191] docs(changeset): clarify Megolm session rotation
description
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.changeset/devtool-rotate-sessions.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.changeset/devtool-rotate-sessions.md b/.changeset/devtool-rotate-sessions.md
index cae1bda0c..686fb292f 100644
--- a/.changeset/devtool-rotate-sessions.md
+++ b/.changeset/devtool-rotate-sessions.md
@@ -2,4 +2,4 @@
default: patch
---
-Add rotate-encryption-sessions developer tool to force Megolm session rotation for testing
+Add developer tool to force-rotate outbound Megolm encryption sessions per room, useful for testing key rotation and bridge session recovery
From cbd653e136499561e92926156106ceef71c27d20 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Wed, 15 Apr 2026 18:22:52 -0400
Subject: [PATCH 127/191] docs(changeset): accurately describe
unhandled-rejection suppression behaviour
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.changeset/async-callback-rejections.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.changeset/async-callback-rejections.md b/.changeset/async-callback-rejections.md
index 89297b90e..a8e8af829 100644
--- a/.changeset/async-callback-rejections.md
+++ b/.changeset/async-callback-rejections.md
@@ -2,4 +2,4 @@
default: patch
---
-Fix unhandled promise rejections in useAsyncCallback by propagating errors to the error boundary
+Suppress "Uncaught (in promise)" console noise for fire-and-forget `useAsyncCallback` call sites; errors are still surfaced to callers that await the returned promise and captured in `AsyncState`
From 8fee117edbf2e36f3eea394065c84df68ed18be4 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Wed, 15 Apr 2026 18:26:30 -0400
Subject: [PATCH 128/191] docs: clarify that listTimelineLimit scales with
message preview setting
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
src/client/slidingSync.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts
index 91c90c2a2..dd4a880b6 100644
--- a/src/client/slidingSync.ts
+++ b/src/client/slidingSync.ts
@@ -52,7 +52,7 @@ const LIST_SORT_ORDER = ['by_recency', 'by_name'];
// Encrypted rooms get [*,*] required_state; unencrypted rooms also request lazy members.
const UNENCRYPTED_SUBSCRIPTION_KEY = 'unencrypted';
// Timeline limit for the active-room subscription (full history load).
-// List entries use a small timeline limit (default 1) for lightweight previews.
+// List entries use a configurable timeline limit (default 1; raised to 5 when message previews are enabled).
const ACTIVE_ROOM_TIMELINE_LIMIT = 50;
export type PartialSlidingSyncRequest = {
From 809a9bbae90e3910d82aade3ae76b15f5eece4c8 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Wed, 15 Apr 2026 23:37:30 -0400
Subject: [PATCH 129/191] fix(preview): close decryption race in
useRoomLastMessage
Subscribe to Decrypted events before reading current state so events
that decrypt between the initial render and listener mount are not
missed. Explicitly request decryption for the last encrypted event on
mount so rooms not yet opened (e.g. sliding-sync previews) resolve
their preview text without requiring the user to visit the room.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
src/app/hooks/useRoomLastMessage.ts | 20 ++++++++++++++++++--
1 file changed, 18 insertions(+), 2 deletions(-)
diff --git a/src/app/hooks/useRoomLastMessage.ts b/src/app/hooks/useRoomLastMessage.ts
index e0d6d99f4..a27c86d5a 100644
--- a/src/app/hooks/useRoomLastMessage.ts
+++ b/src/app/hooks/useRoomLastMessage.ts
@@ -94,18 +94,34 @@ export function useRoomLastMessage(
setText(undefined);
return undefined;
}
- setText(getLastMessageText(room, mx));
const update = () => setText(getLastMessageText(room, mx));
+
+ // Subscribe before reading to close the race window: any decryption that
+ // completes after this point will trigger an update via the listener.
room.on(RoomEventEnum.Timeline, update);
room.on(RoomEventEnum.LocalEchoUpdated, update);
- // Re-check when any event in this room is decrypted (encrypted β plaintext).
const onDecrypted = (ev: MatrixEvent) => {
if (ev.getRoomId() === room.roomId) update();
};
mx.on(MatrixEventEvent.Decrypted, onDecrypted);
+ // Read current state after subscribing to catch any events that decrypted
+ // between the initial render and the listener mount.
+ update();
+
+ // If the last displayable event is still encrypted, explicitly request
+ // decryption. Sliding sync may not auto-decrypt events in rooms that
+ // haven't been opened yet; this ensures the preview resolves on mount.
+ const events = room.getLiveTimeline().getEvents();
+ const lastDisplayable = [...events]
+ .reverse()
+ .find((ev) => eventToPreviewText(ev) !== undefined);
+ if (lastDisplayable && lastDisplayable.isEncrypted()) {
+ mx.decryptEventIfNeeded(lastDisplayable).catch(() => undefined);
+ }
+
return () => {
room.off(RoomEventEnum.Timeline, update);
room.off(RoomEventEnum.LocalEchoUpdated, update);
From dc218731011d18afb9792346e1984349061af1c9 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Thu, 16 Apr 2026 00:42:28 -0400
Subject: [PATCH 130/191] fix(preview): poll/location preview, mxid localpart
fallback
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add poll start event preview (π + question text) and m.location preview.
When room.getMember() returns null (common with sliding sync list
subscriptions), fall back to localpart extracted from mxid instead of
showing the raw @user:server string.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
src/app/hooks/useRoomLastMessage.test.tsx | 27 +++++++++++++++++++++--
src/app/hooks/useRoomLastMessage.ts | 24 +++++++++++++++++++-
2 files changed, 48 insertions(+), 3 deletions(-)
diff --git a/src/app/hooks/useRoomLastMessage.test.tsx b/src/app/hooks/useRoomLastMessage.test.tsx
index e58357834..a049a8e3f 100644
--- a/src/app/hooks/useRoomLastMessage.test.tsx
+++ b/src/app/hooks/useRoomLastMessage.test.tsx
@@ -16,6 +16,7 @@ function makeEvent(overrides: {
roomId?: string;
redacted?: boolean;
effectiveType?: string;
+ encrypted?: boolean;
}) {
const type = overrides.type ?? 'm.room.message';
const content = overrides.content ?? { msgtype: 'm.text', body: 'hello' };
@@ -25,6 +26,7 @@ function makeEvent(overrides: {
getSender: () => overrides.sender ?? '@alice:test',
getRoomId: () => overrides.roomId ?? '!room:test',
isRedacted: () => overrides.redacted ?? false,
+ isEncrypted: () => overrides.encrypted ?? false,
getEffectiveEvent: () => ({ type: overrides.effectiveType ?? type, content }),
} as never;
}
@@ -141,6 +143,27 @@ describe('eventToPreviewText', () => {
expect(eventToPreviewText(ev)).toBe('real message');
});
+ it('returns poll text for MSC3381 poll start events', () => {
+ const ev = makeEvent({
+ type: 'org.matrix.msc3381.poll.start',
+ content: { 'org.matrix.msc3381.poll.start': { question: { body: 'Lunch?' } } },
+ });
+ expect(eventToPreviewText(ev)).toBe('π Lunch?');
+ });
+
+ it('returns poll text for stable poll start events', () => {
+ const ev = makeEvent({
+ type: 'm.poll.start',
+ content: { 'm.poll.start': { question: { body: 'Dinner?' } } },
+ });
+ expect(eventToPreviewText(ev)).toBe('π Dinner?');
+ });
+
+ it('returns location icon for m.location message', () => {
+ const ev = makeEvent({ content: { msgtype: 'm.location', body: 'geo:0,0' } });
+ expect(eventToPreviewText(ev)).toBe('π Location');
+ });
+
it('returns undefined for unknown event types', () => {
const ev = makeEvent({ type: 'm.room.power_levels', content: {} });
expect(eventToPreviewText(ev)).toBeUndefined();
@@ -172,10 +195,10 @@ describe('getLastMessageText', () => {
expect(getLastMessageText(room, makeMx())).toBe('Bob: hey');
});
- it('falls back to userId when no display name is available', () => {
+ it('falls back to localpart when no display name is available', () => {
const ev = makeEvent({ sender: '@bob:test', content: { msgtype: 'm.text', body: 'hey' } });
const room = makeRoom([ev]);
- expect(getLastMessageText(room, makeMx())).toBe('@bob:test: hey');
+ expect(getLastMessageText(room, makeMx())).toBe('bob: hey');
});
it('skips reactions and picks the last real message', () => {
diff --git a/src/app/hooks/useRoomLastMessage.ts b/src/app/hooks/useRoomLastMessage.ts
index a27c86d5a..04dc4fd9b 100644
--- a/src/app/hooks/useRoomLastMessage.ts
+++ b/src/app/hooks/useRoomLastMessage.ts
@@ -48,15 +48,36 @@ export function eventToPreviewText(ev: MatrixEvent): string | undefined {
if (msgtype === MsgType.Video) return 'πΉ Video';
if (msgtype === MsgType.Audio) return 'π΅ Audio';
if (msgtype === MsgType.File) return 'π File';
+ if (msgtype === 'm.location') return 'π Location';
}
if (type === MessageEvent.Sticker) {
return `π ${content.body ?? 'Sticker'}`;
}
+ // Polls β show the question text when available.
+ if (type === 'org.matrix.msc3381.poll.start' || type === 'm.poll.start') {
+ const pollBody =
+ content?.['org.matrix.msc3381.poll.start']?.question?.body ??
+ content?.['m.poll.start']?.question?.body;
+ return `π ${pollBody ?? 'Poll'}`;
+ }
+
return undefined;
}
+/**
+ * Extract a human-readable name from a Matrix user ID (@localpart:server).
+ * Falls back to the raw id if the format is unexpected.
+ */
+function displayNameFromMxid(mxid: string): string {
+ if (mxid.startsWith('@')) {
+ const localpart = mxid.slice(1).split(':')[0];
+ if (localpart) return localpart;
+ }
+ return mxid;
+}
+
export function getLastMessageText(room: Room, mx: MatrixClient): string | undefined {
const events = room.getLiveTimeline().getEvents();
const match = [...events].reverse().find((ev) => eventToPreviewText(ev) !== undefined);
@@ -69,7 +90,8 @@ export function getLastMessageText(room: Room, mx: MatrixClient): string | undef
if (senderId === mx.getUserId()) {
prefix = 'You';
} else {
- prefix = room.getMember(senderId ?? '')?.name ?? senderId ?? 'Unknown';
+ prefix =
+ room.getMember(senderId ?? '')?.name ?? displayNameFromMxid(senderId ?? 'Unknown');
}
return `${prefix}: ${text}`;
}
From 8c3c0e7585bc538caf8ddbe99cc0d2a5bdb82d9f Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Thu, 16 Apr 2026 08:58:45 -0400
Subject: [PATCH 131/191] fix(presence): retry setPresence on failure for app
resume reliability
When the app resumes from background, the HTTP client may not have
reconnected yet, causing setPresence to fail silently. Retry up to
3 times with back-off (2s, 4s, 6s) so presence recovers from idle.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
src/app/pages/client/ClientNonUIFeatures.tsx | 20 ++++++++++++++++----
1 file changed, 16 insertions(+), 4 deletions(-)
diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx
index 7c71db23a..c50f411a4 100644
--- a/src/app/pages/client/ClientNonUIFeatures.tsx
+++ b/src/app/pages/client/ClientNonUIFeatures.tsx
@@ -869,12 +869,24 @@ 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({
+ const presencePayload = {
presence: effectiveState,
...(sendPresence && effectiveMode === 'dnd' ? { status_msg: 'dnd' } : {}),
- }).catch(() => {
- // Server doesn't support presence β ignore.
- });
+ };
+ let retryTimer: ReturnType | undefined;
+ const trySetPresence = (attempt = 0) => {
+ mx.setPresence(presencePayload).catch(() => {
+ // Retry up to 3 times with back-off: the HTTP client may not have
+ // reconnected yet after the app resumes from background.
+ if (attempt < 3) {
+ retryTimer = setTimeout(() => trySetPresence(attempt + 1), 2000 * (attempt + 1));
+ }
+ });
+ };
+ trySetPresence();
+ return () => {
+ if (retryTimer !== undefined) clearTimeout(retryTimer);
+ };
}, [mx, sendPresence, presenceMode, autoIdled]);
return null;
From 71138ecbdf56702c36370797334067c011253bab Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Thu, 16 Apr 2026 11:47:46 -0400
Subject: [PATCH 132/191] Update personal config.json
---
config.json | 87 ++++++++++++++++++++++++++++++++++++-----------------
1 file changed, 59 insertions(+), 28 deletions(-)
diff --git a/config.json b/config.json
index 8f685bfd4..027ad7c15 100644
--- a/config.json
+++ b/config.json
@@ -1,31 +1,62 @@
{
- "allowCustomHomeservers": true,
- "defaultHomeserver": 0,
- "elementCallUrl": "matrix.cloudhub.social",
- "featuredCommunities": {
- "openAsDefault": false,
- "rooms": [],
- "servers": [
- "matrixrooms.info",
- "gitter.im",
- "matrix.org"
- ],
- "spaces": []
- },
- "homeserverList": [
- "https://matrix.cloudhub.social"
+ "defaultHomeserver": 0,
+ "homeserverList": [
+ "https://matrix.cloudhub.social",
+ "matrix.org",
+ "mozilla.org",
+ "unredacted.org",
+ "sable.moe",
+ "kendama.moe"
+ ],
+ "allowCustomHomeservers": true,
+ "elementCallUrl": "matrix.cloudhub.social",
+ "disableAccountSwitcher": false,
+ "hideUsernamePasswordFields": false,
+ "pushNotificationDetails": {
+ "pushNotifyUrl": "https://sygnal.cloudhub.social/_matrix/push/v1/notify",
+ "vapidPublicKey": "BEBdK6VUiqYxcOauFCM1ZB38llgiODAs6pR5EEcC7YBoUh2YvrULagwo5t-Ms0Is0lEmKDhpdUoMiy_i7ArI3oE",
+ "webPushAppID": "social.cloudhub.sable.web"
+ },
+
+ "settingsLinkBaseUrl": "https://app.sable.moe",
+
+ "presenceAutoIdleTimeoutMs": 300000,
+
+ "slidingSync": {
+ "enabled": true
+ },
+
+ "sessionSync": {
+ "phase1ForegroundResync": true,
+ "phase2VisibleHeartbeat": true,
+ "phase3AdaptiveBackoffJitter": true
+ },
+
+ "featuredCommunities": {
+ "openAsDefault": false,
+ "spaces": [
+ "#sable:sable.moe",
+ "#community:matrix.org",
+ "#space:unredacted.org",
+ "#science-space:matrix.org",
+ "#libregaming-games:tchncs.de",
+ "#mathematics-on:matrix.org"
],
- "pushNotificationDetails": {
- "pushNotifyUrl": "https://sygnal.cloudhub.social/_matrix/push/v1/notify",
- "vapidPublicKey": "BEBdK6VUiqYxcOauFCM1ZB38llgiODAs6pR5EEcC7YBoUh2YvrULagwo5t-Ms0Is0lEmKDhpdUoMiy_i7ArI3oE",
- "webPushAppID": "social.cloudhub.sable.web"
- },
- "sessionSync": {
- "phase1ForegroundResync": true,
- "phase2VisibleHeartbeat": true,
- "phase3AdaptiveBackoffJitter": false
- },
- "slidingSync": {
- "enabled": "true"
- }
+ "rooms": [
+ "#announcements:sable.moe",
+ "#freesoftware:matrix.org",
+ "#pcapdroid:matrix.org",
+ "#gentoo:matrix.org",
+ "#PrivSec.dev:arcticfoxes.net",
+ "#disroot:aria-net.org"
+ ],
+ "servers": ["matrixrooms.info", "mozilla.org", "unredacted.org"]
+ },
+ "hashRouter": {
+ "enabled": false,
+ "basename": "/"
+ },
+ "features": {
+ "polls": true
+ }
}
From ad50ff1b1d2d924b1c9531b935601d62ed8beb3f Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Thu, 16 Apr 2026 16:20:04 -0400
Subject: [PATCH 133/191] fix(timeline): restore useLayoutEffect auto-scroll,
fix new-message scroll, fix eventId drag-to-bottom, increase list timeline
limit
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- useTimelineSync: change auto-scroll recovery useEffect β useLayoutEffect to
prevent one-frame flash after timeline reset
- useTimelineSync: remove premature scrollToBottom from useLiveTimelineRefresh
(operated on pre-commit DOM with stale scrollSize)
- useTimelineSync: remove scrollToBottom + eventsLengthRef suppression from
useLiveEventArrive; let useLayoutEffect handle scroll after React commits
- RoomTimeline: init atBottomState to false when eventId is set, and reset it
in the eventId useEffect, so auto-scroll doesn't drag to bottom on bookmark nav
- RoomTimeline: change instant scrollToBottom to use scrollToIndex instead of
scrollTo(scrollSize) β works correctly regardless of VList measurement state
- slidingSync: increase DEFAULT_LIST_TIMELINE_LIMIT 1β3 to reduce empty previews
when recent events are reactions/edits/state
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
src/app/features/room/RoomTimeline.tsx | 23 ++++++++++++++---
src/app/hooks/timeline/useTimelineSync.ts | 31 +++++++++++++----------
src/client/slidingSync.ts | 6 ++---
3 files changed, 41 insertions(+), 19 deletions(-)
diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx
index e703d0206..99590f27a 100644
--- a/src/app/features/room/RoomTimeline.tsx
+++ b/src/app/features/room/RoomTimeline.tsx
@@ -198,7 +198,14 @@ export function RoomTimeline({
const setOpenThread = useSetAtom(openThreadAtom);
const vListRef = useRef(null);
- const [atBottomState, setAtBottomState] = useState(true);
+ // Load any cached scroll state for this room on mount. A fresh RoomTimeline is
+ // mounted per room (via key={roomId} in RoomView) so this is the only place we
+ // need to read the cache β the render-phase room-change block below only fires
+ // in the (hypothetical) case where the room prop changes without a remount.
+ const scrollCacheForRoomRef = useRef(
+ roomScrollCache.load(mxUserId, room.roomId)
+ );
+ const [atBottomState, setAtBottomState] = useState(!eventId);
const atBottomRef = useRef(atBottomState);
const setAtBottom = useCallback((val: boolean) => {
setAtBottomState(val);
@@ -242,7 +249,14 @@ export function RoomTimeline({
if (!vListRef.current) return;
const lastIndex = processedEventsRef.current.length - 1;
if (lastIndex < 0) return;
- vListRef.current.scrollTo(vListRef.current.scrollSize);
+ if (behavior === 'smooth') {
+ vListRef.current.scrollToIndex(lastIndex, { align: 'end', smooth: true });
+ } else {
+ // scrollToIndex works reliably regardless of VList measurement state.
+ // The auto-scroll useLayoutEffect fires after React commits new items,
+ // so lastIndex is always valid when this is called.
+ vListRef.current.scrollToIndex(lastIndex, { align: 'end' });
+ }
}, []);
const timelineSync = useTimelineSync({
@@ -394,8 +408,11 @@ export function RoomTimeline({
useEffect(() => {
if (!eventId) return;
setIsReady(false);
+ // Ensure auto-scroll to bottom doesn't fire while we're navigating to a
+ // specific event β atBottom will be updated correctly once the user scrolls.
+ setAtBottom(false);
timelineSyncRef.current.loadEventTimeline(eventId);
- }, [eventId, room.roomId]);
+ }, [eventId, room.roomId, setAtBottom]);
useEffect(() => {
if (eventId) return;
diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts
index 51c85dda8..948630887 100644
--- a/src/app/hooks/timeline/useTimelineSync.ts
+++ b/src/app/hooks/timeline/useTimelineSync.ts
@@ -1,4 +1,13 @@
-import { useState, useMemo, useCallback, useRef, useEffect, Dispatch, SetStateAction } from 'react';
+import {
+ useState,
+ useMemo,
+ useCallback,
+ useRef,
+ useEffect,
+ useLayoutEffect,
+ Dispatch,
+ SetStateAction,
+} from 'react';
import to from 'await-to-js';
import * as Sentry from '@sentry/react';
import {
@@ -466,9 +475,6 @@ export function useTimelineSync({
const lastScrolledAtEventsLengthRef = useRef(eventsLength);
- const eventsLengthRef = useRef(eventsLength);
- eventsLengthRef.current = eventsLength;
-
useLiveEventArrive(
room,
useCallback(
@@ -490,9 +496,6 @@ export function useTimelineSync({
setUnreadInfo(getRoomUnreadInfo(room));
}
- scrollToBottom(getSender.call(mEvt) === mx.getUserId() ? 'instant' : 'smooth');
- lastScrolledAtEventsLengthRef.current = eventsLengthRef.current + 1;
-
setTimeline((ct) => ({ ...ct }));
return;
}
@@ -502,7 +505,7 @@ export function useTimelineSync({
setUnreadInfo(getRoomUnreadInfo(room));
}
},
- [mx, room, isAtBottomRef, unreadInfo, scrollToBottom, setUnreadInfo, hideReadsRef]
+ [mx, room, isAtBottomRef, unreadInfo, setUnreadInfo, hideReadsRef]
)
);
@@ -527,10 +530,10 @@ export function useTimelineSync({
const wasAtBottom = isAtBottomRef.current;
resetAutoScrollPendingRef.current = wasAtBottom;
setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines });
- if (wasAtBottom) {
- scrollToBottom('instant');
- }
- }, [room, isAtBottomRef, scrollToBottom])
+ // Scroll is handled by the useLayoutEffect auto-scroll recovery which
+ // fires after React commits the new timeline state β scrolling here
+ // would operate on the pre-commit DOM with a stale scrollSize.
+ }, [room, isAtBottomRef])
);
useRelationUpdate(
@@ -547,7 +550,9 @@ export function useTimelineSync({
}, [])
);
- useEffect(() => {
+ // useLayoutEffect so scroll fires before paint β prevents the one-frame flash
+ // where new VList content is briefly visible at the wrong position.
+ useLayoutEffect(() => {
const resetAutoScrollPending = resetAutoScrollPendingRef.current;
if (resetAutoScrollPending) resetAutoScrollPendingRef.current = false;
diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts
index 43fdf39ea..5c147179c 100644
--- a/src/client/slidingSync.ts
+++ b/src/client/slidingSync.ts
@@ -34,9 +34,9 @@ export const LIST_SEARCH = 'search';
export const LIST_ROOM_SEARCH = 'room_search';
// Dynamic list key used for space-scoped room views.
export const LIST_SPACE = 'space';
-// One event of timeline per list room is enough to compute unread counts;
-// the full history is loaded when the user opens the room.
-const LIST_TIMELINE_LIMIT = 1;
+// Higher limit avoids empty previews when the most-recent events are
+// reactions/edits/state that useRoomLatestRenderedEvent skips over.
+const LIST_TIMELINE_LIMIT = 3;
const DEFAULT_LIST_PAGE_SIZE = 250;
const DEFAULT_POLL_TIMEOUT_MS = 20000;
const DEFAULT_MAX_ROOMS = 5000;
From 83031b2371d4752be7393929c5f78ba23f6649e2 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Thu, 16 Apr 2026 16:20:04 -0400
Subject: [PATCH 134/191] fix(timeline): restore useLayoutEffect auto-scroll,
fix new-message scroll, fix eventId drag-to-bottom, increase list timeline
limit
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- useTimelineSync: change auto-scroll recovery useEffect β useLayoutEffect to
prevent one-frame flash after timeline reset
- useTimelineSync: remove premature scrollToBottom from useLiveTimelineRefresh
(operated on pre-commit DOM with stale scrollSize)
- useTimelineSync: remove scrollToBottom + eventsLengthRef suppression from
useLiveEventArrive; let useLayoutEffect handle scroll after React commits
- RoomTimeline: init atBottomState to false when eventId is set, and reset it
in the eventId useEffect, so auto-scroll doesn't drag to bottom on bookmark nav
- RoomTimeline: change instant scrollToBottom to use scrollToIndex instead of
scrollTo(scrollSize) β works correctly regardless of VList measurement state
- slidingSync: increase DEFAULT_LIST_TIMELINE_LIMIT 1β3 to reduce empty previews
when recent events are reactions/edits/state
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
src/app/features/room/RoomTimeline.tsx | 23 ++++++++++++++---
src/app/hooks/timeline/useTimelineSync.ts | 31 +++++++++++++----------
src/client/slidingSync.ts | 6 ++---
3 files changed, 41 insertions(+), 19 deletions(-)
diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx
index d026ec1fa..e48684ea8 100644
--- a/src/app/features/room/RoomTimeline.tsx
+++ b/src/app/features/room/RoomTimeline.tsx
@@ -201,7 +201,14 @@ export function RoomTimeline({
const setOpenThread = useSetAtom(openThreadAtom);
const vListRef = useRef(null);
- const [atBottomState, setAtBottomState] = useState(true);
+ // Load any cached scroll state for this room on mount. A fresh RoomTimeline is
+ // mounted per room (via key={roomId} in RoomView) so this is the only place we
+ // need to read the cache β the render-phase room-change block below only fires
+ // in the (hypothetical) case where the room prop changes without a remount.
+ const scrollCacheForRoomRef = useRef(
+ roomScrollCache.load(mxUserId, room.roomId)
+ );
+ const [atBottomState, setAtBottomState] = useState(!eventId);
const atBottomRef = useRef(atBottomState);
const setAtBottom = useCallback((val: boolean) => {
setAtBottomState(val);
@@ -245,7 +252,14 @@ export function RoomTimeline({
if (!vListRef.current) return;
const lastIndex = processedEventsRef.current.length - 1;
if (lastIndex < 0) return;
- vListRef.current.scrollTo(vListRef.current.scrollSize);
+ if (behavior === 'smooth') {
+ vListRef.current.scrollToIndex(lastIndex, { align: 'end', smooth: true });
+ } else {
+ // scrollToIndex works reliably regardless of VList measurement state.
+ // The auto-scroll useLayoutEffect fires after React commits new items,
+ // so lastIndex is always valid when this is called.
+ vListRef.current.scrollToIndex(lastIndex, { align: 'end' });
+ }
}, []);
const timelineSync = useTimelineSync({
@@ -408,8 +422,11 @@ export function RoomTimeline({
useEffect(() => {
if (!eventId) return;
setIsReady(false);
+ // Ensure auto-scroll to bottom doesn't fire while we're navigating to a
+ // specific event β atBottom will be updated correctly once the user scrolls.
+ setAtBottom(false);
timelineSyncRef.current.loadEventTimeline(eventId);
- }, [eventId, room.roomId]);
+ }, [eventId, room.roomId, setAtBottom]);
useEffect(() => {
if (eventId) return;
diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts
index 51c85dda8..948630887 100644
--- a/src/app/hooks/timeline/useTimelineSync.ts
+++ b/src/app/hooks/timeline/useTimelineSync.ts
@@ -1,4 +1,13 @@
-import { useState, useMemo, useCallback, useRef, useEffect, Dispatch, SetStateAction } from 'react';
+import {
+ useState,
+ useMemo,
+ useCallback,
+ useRef,
+ useEffect,
+ useLayoutEffect,
+ Dispatch,
+ SetStateAction,
+} from 'react';
import to from 'await-to-js';
import * as Sentry from '@sentry/react';
import {
@@ -466,9 +475,6 @@ export function useTimelineSync({
const lastScrolledAtEventsLengthRef = useRef(eventsLength);
- const eventsLengthRef = useRef(eventsLength);
- eventsLengthRef.current = eventsLength;
-
useLiveEventArrive(
room,
useCallback(
@@ -490,9 +496,6 @@ export function useTimelineSync({
setUnreadInfo(getRoomUnreadInfo(room));
}
- scrollToBottom(getSender.call(mEvt) === mx.getUserId() ? 'instant' : 'smooth');
- lastScrolledAtEventsLengthRef.current = eventsLengthRef.current + 1;
-
setTimeline((ct) => ({ ...ct }));
return;
}
@@ -502,7 +505,7 @@ export function useTimelineSync({
setUnreadInfo(getRoomUnreadInfo(room));
}
},
- [mx, room, isAtBottomRef, unreadInfo, scrollToBottom, setUnreadInfo, hideReadsRef]
+ [mx, room, isAtBottomRef, unreadInfo, setUnreadInfo, hideReadsRef]
)
);
@@ -527,10 +530,10 @@ export function useTimelineSync({
const wasAtBottom = isAtBottomRef.current;
resetAutoScrollPendingRef.current = wasAtBottom;
setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines });
- if (wasAtBottom) {
- scrollToBottom('instant');
- }
- }, [room, isAtBottomRef, scrollToBottom])
+ // Scroll is handled by the useLayoutEffect auto-scroll recovery which
+ // fires after React commits the new timeline state β scrolling here
+ // would operate on the pre-commit DOM with a stale scrollSize.
+ }, [room, isAtBottomRef])
);
useRelationUpdate(
@@ -547,7 +550,9 @@ export function useTimelineSync({
}, [])
);
- useEffect(() => {
+ // useLayoutEffect so scroll fires before paint β prevents the one-frame flash
+ // where new VList content is briefly visible at the wrong position.
+ useLayoutEffect(() => {
const resetAutoScrollPending = resetAutoScrollPendingRef.current;
if (resetAutoScrollPending) resetAutoScrollPendingRef.current = false;
diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts
index dd4a880b6..d7acfb35f 100644
--- a/src/client/slidingSync.ts
+++ b/src/client/slidingSync.ts
@@ -36,9 +36,9 @@ export const LIST_ROOM_SEARCH = 'room_search';
export const LIST_SPACE = 'space';
// A small number of timeline events per list room. Unread counts come from
// the server-side notification_count field, so a full history isn't needed.
-// When message previews are enabled, a higher limit (e.g. 5) avoids empty
-// timelines caused by reactions/edits whose parent event is absent.
-const DEFAULT_LIST_TIMELINE_LIMIT = 1;
+// Higher limit avoids empty previews when the most-recent events are
+// reactions/edits/state that useRoomLatestRenderedEvent skips over.
+const DEFAULT_LIST_TIMELINE_LIMIT = 3;
const DEFAULT_LIST_PAGE_SIZE = 250;
const DEFAULT_POLL_TIMEOUT_MS = 20000;
const DEFAULT_MAX_ROOMS = 5000;
From bd31c97dcfe5b3fb7b864fddf21f11bf2021f418 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Thu, 16 Apr 2026 19:33:47 -0400
Subject: [PATCH 135/191] fix(timeline): restore upstream scroll pattern for
new messages
Restore scrollToBottom call in useLiveEventArrive with instant/smooth
based on sender, add back eventsLengthRef and lastScrolledAt suppression,
restore scrollToBottom in useLiveTimelineRefresh when wasAtBottom, and
revert instant scrollToBottom to scrollTo(scrollSize) matching upstream.
The previous changes removed all scroll calls from event arrival handlers
and relied solely on the useLayoutEffect auto-scroll recovery, which has
timing issues with VList measurement. Upstream's pattern of scrolling in
the event handler and suppressing the effect works reliably.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
src/app/features/room/RoomTimeline.tsx | 5 +----
src/app/hooks/timeline/useTimelineSync.ts | 16 +++++++++++-----
2 files changed, 12 insertions(+), 9 deletions(-)
diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx
index 99590f27a..d842f2a28 100644
--- a/src/app/features/room/RoomTimeline.tsx
+++ b/src/app/features/room/RoomTimeline.tsx
@@ -252,10 +252,7 @@ export function RoomTimeline({
if (behavior === 'smooth') {
vListRef.current.scrollToIndex(lastIndex, { align: 'end', smooth: true });
} else {
- // scrollToIndex works reliably regardless of VList measurement state.
- // The auto-scroll useLayoutEffect fires after React commits new items,
- // so lastIndex is always valid when this is called.
- vListRef.current.scrollToIndex(lastIndex, { align: 'end' });
+ vListRef.current.scrollTo(vListRef.current.scrollSize);
}
}, []);
diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts
index 948630887..f6d50c904 100644
--- a/src/app/hooks/timeline/useTimelineSync.ts
+++ b/src/app/hooks/timeline/useTimelineSync.ts
@@ -475,6 +475,9 @@ export function useTimelineSync({
const lastScrolledAtEventsLengthRef = useRef(eventsLength);
+ const eventsLengthRef = useRef(eventsLength);
+ eventsLengthRef.current = eventsLength;
+
useLiveEventArrive(
room,
useCallback(
@@ -496,6 +499,9 @@ export function useTimelineSync({
setUnreadInfo(getRoomUnreadInfo(room));
}
+ scrollToBottom(getSender.call(mEvt) === mx.getUserId() ? 'instant' : 'smooth');
+ lastScrolledAtEventsLengthRef.current = eventsLengthRef.current + 1;
+
setTimeline((ct) => ({ ...ct }));
return;
}
@@ -505,7 +511,7 @@ export function useTimelineSync({
setUnreadInfo(getRoomUnreadInfo(room));
}
},
- [mx, room, isAtBottomRef, unreadInfo, setUnreadInfo, hideReadsRef]
+ [mx, room, isAtBottomRef, unreadInfo, scrollToBottom, setUnreadInfo, hideReadsRef]
)
);
@@ -530,10 +536,10 @@ export function useTimelineSync({
const wasAtBottom = isAtBottomRef.current;
resetAutoScrollPendingRef.current = wasAtBottom;
setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines });
- // Scroll is handled by the useLayoutEffect auto-scroll recovery which
- // fires after React commits the new timeline state β scrolling here
- // would operate on the pre-commit DOM with a stale scrollSize.
- }, [room, isAtBottomRef])
+ if (wasAtBottom) {
+ scrollToBottom('instant');
+ }
+ }, [room, isAtBottomRef, scrollToBottom])
);
useRelationUpdate(
From 17ccebddec214508325acc698c3629b8897ebe92 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Thu, 16 Apr 2026 19:33:47 -0400
Subject: [PATCH 136/191] fix(timeline): restore upstream scroll pattern for
new messages
Restore scrollToBottom call in useLiveEventArrive with instant/smooth
based on sender, add back eventsLengthRef and lastScrolledAt suppression,
restore scrollToBottom in useLiveTimelineRefresh when wasAtBottom, and
revert instant scrollToBottom to scrollTo(scrollSize) matching upstream.
The previous changes removed all scroll calls from event arrival handlers
and relied solely on the useLayoutEffect auto-scroll recovery, which has
timing issues with VList measurement. Upstream's pattern of scrolling in
the event handler and suppressing the effect works reliably.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
src/app/features/room/RoomTimeline.tsx | 5 +----
src/app/hooks/timeline/useTimelineSync.ts | 16 +++++++++++-----
2 files changed, 12 insertions(+), 9 deletions(-)
diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx
index e48684ea8..ee66ac28c 100644
--- a/src/app/features/room/RoomTimeline.tsx
+++ b/src/app/features/room/RoomTimeline.tsx
@@ -255,10 +255,7 @@ export function RoomTimeline({
if (behavior === 'smooth') {
vListRef.current.scrollToIndex(lastIndex, { align: 'end', smooth: true });
} else {
- // scrollToIndex works reliably regardless of VList measurement state.
- // The auto-scroll useLayoutEffect fires after React commits new items,
- // so lastIndex is always valid when this is called.
- vListRef.current.scrollToIndex(lastIndex, { align: 'end' });
+ vListRef.current.scrollTo(vListRef.current.scrollSize);
}
}, []);
diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts
index 948630887..f6d50c904 100644
--- a/src/app/hooks/timeline/useTimelineSync.ts
+++ b/src/app/hooks/timeline/useTimelineSync.ts
@@ -475,6 +475,9 @@ export function useTimelineSync({
const lastScrolledAtEventsLengthRef = useRef(eventsLength);
+ const eventsLengthRef = useRef(eventsLength);
+ eventsLengthRef.current = eventsLength;
+
useLiveEventArrive(
room,
useCallback(
@@ -496,6 +499,9 @@ export function useTimelineSync({
setUnreadInfo(getRoomUnreadInfo(room));
}
+ scrollToBottom(getSender.call(mEvt) === mx.getUserId() ? 'instant' : 'smooth');
+ lastScrolledAtEventsLengthRef.current = eventsLengthRef.current + 1;
+
setTimeline((ct) => ({ ...ct }));
return;
}
@@ -505,7 +511,7 @@ export function useTimelineSync({
setUnreadInfo(getRoomUnreadInfo(room));
}
},
- [mx, room, isAtBottomRef, unreadInfo, setUnreadInfo, hideReadsRef]
+ [mx, room, isAtBottomRef, unreadInfo, scrollToBottom, setUnreadInfo, hideReadsRef]
)
);
@@ -530,10 +536,10 @@ export function useTimelineSync({
const wasAtBottom = isAtBottomRef.current;
resetAutoScrollPendingRef.current = wasAtBottom;
setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines });
- // Scroll is handled by the useLayoutEffect auto-scroll recovery which
- // fires after React commits the new timeline state β scrolling here
- // would operate on the pre-commit DOM with a stale scrollSize.
- }, [room, isAtBottomRef])
+ if (wasAtBottom) {
+ scrollToBottom('instant');
+ }
+ }, [room, isAtBottomRef, scrollToBottom])
);
useRelationUpdate(
From 1392b779080acaa81487908fc19b8a5e84b13087 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Thu, 16 Apr 2026 22:26:19 -0400
Subject: [PATCH 137/191] fix(timeline): align scrollToBottom with upstream,
fix eventId race
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Remove behavior parameter from scrollToBottom β always use
scrollTo(scrollSize) matching upstream. The smooth scrollToIndex
was scrolling to stale lastIndex (before new item measured),
leaving new messages below the fold.
- Revert auto-scroll recovery from useLayoutEffect back to useEffect
(matches upstream). useLayoutEffect fires before VList measures
new items and before setAtBottom(false) in eventId effect.
- Remove stale scrollCacheForRoomRef that referenced missing imports.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
src/app/features/room/RoomTimeline.tsx | 15 +----------
.../hooks/timeline/useTimelineSync.test.tsx | 2 +-
src/app/hooks/timeline/useTimelineSync.ts | 25 ++++++-------------
3 files changed, 9 insertions(+), 33 deletions(-)
diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx
index d842f2a28..73895a64a 100644
--- a/src/app/features/room/RoomTimeline.tsx
+++ b/src/app/features/room/RoomTimeline.tsx
@@ -198,13 +198,6 @@ export function RoomTimeline({
const setOpenThread = useSetAtom(openThreadAtom);
const vListRef = useRef(null);
- // Load any cached scroll state for this room on mount. A fresh RoomTimeline is
- // mounted per room (via key={roomId} in RoomView) so this is the only place we
- // need to read the cache β the render-phase room-change block below only fires
- // in the (hypothetical) case where the room prop changes without a remount.
- const scrollCacheForRoomRef = useRef(
- roomScrollCache.load(mxUserId, room.roomId)
- );
const [atBottomState, setAtBottomState] = useState(!eventId);
const atBottomRef = useRef(atBottomState);
const setAtBottom = useCallback((val: boolean) => {
@@ -249,11 +242,7 @@ export function RoomTimeline({
if (!vListRef.current) return;
const lastIndex = processedEventsRef.current.length - 1;
if (lastIndex < 0) return;
- if (behavior === 'smooth') {
- vListRef.current.scrollToIndex(lastIndex, { align: 'end', smooth: true });
- } else {
- vListRef.current.scrollTo(vListRef.current.scrollSize);
- }
+ vListRef.current.scrollTo(vListRef.current.scrollSize);
}, []);
const timelineSync = useTimelineSync({
@@ -405,8 +394,6 @@ export function RoomTimeline({
useEffect(() => {
if (!eventId) return;
setIsReady(false);
- // Ensure auto-scroll to bottom doesn't fire while we're navigating to a
- // specific event β atBottom will be updated correctly once the user scrolls.
setAtBottom(false);
timelineSyncRef.current.loadEventTimeline(eventId);
}, [eventId, room.roomId, setAtBottom]);
diff --git a/src/app/hooks/timeline/useTimelineSync.test.tsx b/src/app/hooks/timeline/useTimelineSync.test.tsx
index d53d74143..e5e7c4cfd 100644
--- a/src/app/hooks/timeline/useTimelineSync.test.tsx
+++ b/src/app/hooks/timeline/useTimelineSync.test.tsx
@@ -129,7 +129,7 @@ describe('useTimelineSync', () => {
await Promise.resolve();
});
- expect(scrollToBottom).toHaveBeenCalledWith('instant');
+ expect(scrollToBottom).toHaveBeenCalled();
});
it('resets timeline state when room.roomId changes and eventId is not set', async () => {
diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts
index f6d50c904..dda1e0207 100644
--- a/src/app/hooks/timeline/useTimelineSync.ts
+++ b/src/app/hooks/timeline/useTimelineSync.ts
@@ -1,13 +1,4 @@
-import {
- useState,
- useMemo,
- useCallback,
- useRef,
- useEffect,
- useLayoutEffect,
- Dispatch,
- SetStateAction,
-} from 'react';
+import { useState, useMemo, useCallback, useRef, useEffect, Dispatch, SetStateAction } from 'react';
import to from 'await-to-js';
import * as Sentry from '@sentry/react';
import {
@@ -360,7 +351,7 @@ export interface UseTimelineSyncOptions {
eventId?: string;
isAtBottom: boolean;
isAtBottomRef: React.MutableRefObject;
- scrollToBottom: (behavior?: 'instant' | 'smooth') => void;
+ scrollToBottom: () => void;
unreadInfo: ReturnType;
setUnreadInfo: Dispatch>>;
hideReadsRef: React.MutableRefObject;
@@ -469,7 +460,7 @@ export function useTimelineSync({
useCallback(() => {
if (!alive()) return;
setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines });
- scrollToBottom('instant');
+ scrollToBottom();
}, [alive, room, scrollToBottom])
);
@@ -499,7 +490,7 @@ export function useTimelineSync({
setUnreadInfo(getRoomUnreadInfo(room));
}
- scrollToBottom(getSender.call(mEvt) === mx.getUserId() ? 'instant' : 'smooth');
+ scrollToBottom();
lastScrolledAtEventsLengthRef.current = eventsLengthRef.current + 1;
setTimeline((ct) => ({ ...ct }));
@@ -537,7 +528,7 @@ export function useTimelineSync({
resetAutoScrollPendingRef.current = wasAtBottom;
setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines });
if (wasAtBottom) {
- scrollToBottom('instant');
+ scrollToBottom();
}
}, [room, isAtBottomRef, scrollToBottom])
);
@@ -556,9 +547,7 @@ export function useTimelineSync({
}, [])
);
- // useLayoutEffect so scroll fires before paint β prevents the one-frame flash
- // where new VList content is briefly visible at the wrong position.
- useLayoutEffect(() => {
+ useEffect(() => {
const resetAutoScrollPending = resetAutoScrollPendingRef.current;
if (resetAutoScrollPending) resetAutoScrollPendingRef.current = false;
@@ -576,7 +565,7 @@ export function useTimelineSync({
if (eventsLength <= lastScrolledAtEventsLengthRef.current && !resetAutoScrollPending) return;
lastScrolledAtEventsLengthRef.current = eventsLength;
- scrollToBottom('instant');
+ scrollToBottom();
}, [isAtBottom, liveTimelineLinked, eventsLength, scrollToBottom]);
useEffect(() => {
From 399418be13d572c5123d831084e0776828d738c0 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Thu, 16 Apr 2026 22:26:19 -0400
Subject: [PATCH 138/191] fix(timeline): align scrollToBottom with upstream,
fix eventId race
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Remove behavior parameter from scrollToBottom β always use
scrollTo(scrollSize) matching upstream. The smooth scrollToIndex
was scrolling to stale lastIndex (before new item measured),
leaving new messages below the fold.
- Revert auto-scroll recovery from useLayoutEffect back to useEffect
(matches upstream). useLayoutEffect fires before VList measures
new items and before setAtBottom(false) in eventId effect.
- Remove stale scrollCacheForRoomRef that referenced missing imports.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
src/app/features/room/RoomTimeline.tsx | 15 +----------
.../hooks/timeline/useTimelineSync.test.tsx | 2 +-
src/app/hooks/timeline/useTimelineSync.ts | 25 ++++++-------------
3 files changed, 9 insertions(+), 33 deletions(-)
diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx
index ee66ac28c..0630dcc9b 100644
--- a/src/app/features/room/RoomTimeline.tsx
+++ b/src/app/features/room/RoomTimeline.tsx
@@ -201,13 +201,6 @@ export function RoomTimeline({
const setOpenThread = useSetAtom(openThreadAtom);
const vListRef = useRef(null);
- // Load any cached scroll state for this room on mount. A fresh RoomTimeline is
- // mounted per room (via key={roomId} in RoomView) so this is the only place we
- // need to read the cache β the render-phase room-change block below only fires
- // in the (hypothetical) case where the room prop changes without a remount.
- const scrollCacheForRoomRef = useRef(
- roomScrollCache.load(mxUserId, room.roomId)
- );
const [atBottomState, setAtBottomState] = useState(!eventId);
const atBottomRef = useRef(atBottomState);
const setAtBottom = useCallback((val: boolean) => {
@@ -252,11 +245,7 @@ export function RoomTimeline({
if (!vListRef.current) return;
const lastIndex = processedEventsRef.current.length - 1;
if (lastIndex < 0) return;
- if (behavior === 'smooth') {
- vListRef.current.scrollToIndex(lastIndex, { align: 'end', smooth: true });
- } else {
- vListRef.current.scrollTo(vListRef.current.scrollSize);
- }
+ vListRef.current.scrollTo(vListRef.current.scrollSize);
}, []);
const timelineSync = useTimelineSync({
@@ -419,8 +408,6 @@ export function RoomTimeline({
useEffect(() => {
if (!eventId) return;
setIsReady(false);
- // Ensure auto-scroll to bottom doesn't fire while we're navigating to a
- // specific event β atBottom will be updated correctly once the user scrolls.
setAtBottom(false);
timelineSyncRef.current.loadEventTimeline(eventId);
}, [eventId, room.roomId, setAtBottom]);
diff --git a/src/app/hooks/timeline/useTimelineSync.test.tsx b/src/app/hooks/timeline/useTimelineSync.test.tsx
index d53d74143..e5e7c4cfd 100644
--- a/src/app/hooks/timeline/useTimelineSync.test.tsx
+++ b/src/app/hooks/timeline/useTimelineSync.test.tsx
@@ -129,7 +129,7 @@ describe('useTimelineSync', () => {
await Promise.resolve();
});
- expect(scrollToBottom).toHaveBeenCalledWith('instant');
+ expect(scrollToBottom).toHaveBeenCalled();
});
it('resets timeline state when room.roomId changes and eventId is not set', async () => {
diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts
index f6d50c904..dda1e0207 100644
--- a/src/app/hooks/timeline/useTimelineSync.ts
+++ b/src/app/hooks/timeline/useTimelineSync.ts
@@ -1,13 +1,4 @@
-import {
- useState,
- useMemo,
- useCallback,
- useRef,
- useEffect,
- useLayoutEffect,
- Dispatch,
- SetStateAction,
-} from 'react';
+import { useState, useMemo, useCallback, useRef, useEffect, Dispatch, SetStateAction } from 'react';
import to from 'await-to-js';
import * as Sentry from '@sentry/react';
import {
@@ -360,7 +351,7 @@ export interface UseTimelineSyncOptions {
eventId?: string;
isAtBottom: boolean;
isAtBottomRef: React.MutableRefObject;
- scrollToBottom: (behavior?: 'instant' | 'smooth') => void;
+ scrollToBottom: () => void;
unreadInfo: ReturnType;
setUnreadInfo: Dispatch>>;
hideReadsRef: React.MutableRefObject;
@@ -469,7 +460,7 @@ export function useTimelineSync({
useCallback(() => {
if (!alive()) return;
setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines });
- scrollToBottom('instant');
+ scrollToBottom();
}, [alive, room, scrollToBottom])
);
@@ -499,7 +490,7 @@ export function useTimelineSync({
setUnreadInfo(getRoomUnreadInfo(room));
}
- scrollToBottom(getSender.call(mEvt) === mx.getUserId() ? 'instant' : 'smooth');
+ scrollToBottom();
lastScrolledAtEventsLengthRef.current = eventsLengthRef.current + 1;
setTimeline((ct) => ({ ...ct }));
@@ -537,7 +528,7 @@ export function useTimelineSync({
resetAutoScrollPendingRef.current = wasAtBottom;
setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines });
if (wasAtBottom) {
- scrollToBottom('instant');
+ scrollToBottom();
}
}, [room, isAtBottomRef, scrollToBottom])
);
@@ -556,9 +547,7 @@ export function useTimelineSync({
}, [])
);
- // useLayoutEffect so scroll fires before paint β prevents the one-frame flash
- // where new VList content is briefly visible at the wrong position.
- useLayoutEffect(() => {
+ useEffect(() => {
const resetAutoScrollPending = resetAutoScrollPendingRef.current;
if (resetAutoScrollPending) resetAutoScrollPendingRef.current = false;
@@ -576,7 +565,7 @@ export function useTimelineSync({
if (eventsLength <= lastScrolledAtEventsLengthRef.current && !resetAutoScrollPending) return;
lastScrolledAtEventsLengthRef.current = eventsLength;
- scrollToBottom('instant');
+ scrollToBottom();
}, [isAtBottom, liveTimelineLinked, eventsLength, scrollToBottom]);
useEffect(() => {
From 7579368db1f98508c28c8d2d84b24e74a45929a5 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Fri, 17 Apr 2026 23:43:28 -0400
Subject: [PATCH 139/191] perf(sidebar): debounce room preview and DM sort
updates
- Debounce useRoomLastMessage update handler (300ms) to avoid
re-rendering every room preview on each timeline event
- Debounce Direct.tsx activityCounter (500ms) to batch DM list
re-sorts during rapid event bursts (reactions, edits, etc.)
- Update test to account for debounced update timing
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
src/app/hooks/useRoomLastMessage.test.tsx | 8 +++++++-
src/app/hooks/useRoomLastMessage.ts | 16 ++++++++++++----
src/app/pages/client/direct/Direct.tsx | 11 +++++++----
3 files changed, 26 insertions(+), 9 deletions(-)
diff --git a/src/app/hooks/useRoomLastMessage.test.tsx b/src/app/hooks/useRoomLastMessage.test.tsx
index a049a8e3f..2e4b725a3 100644
--- a/src/app/hooks/useRoomLastMessage.test.tsx
+++ b/src/app/hooks/useRoomLastMessage.test.tsx
@@ -259,13 +259,13 @@ describe('useRoomLastMessage', () => {
});
it('updates when a Timeline event fires', () => {
+ vi.useFakeTimers();
const ev1 = makeEvent({ content: { msgtype: 'm.text', body: 'first' } });
const events = [ev1];
const room = makeRoom(events);
const mx = makeMx();
const { result } = renderHook(() => useRoomLastMessage(room as never, mx as never));
- expect(result.current).toBe('You: first');
// Simulate a new message arriving.
const ev2 = makeEvent({ content: { msgtype: 'm.text', body: 'second' } });
@@ -276,6 +276,12 @@ describe('useRoomLastMessage', () => {
timelineHandlers.forEach((h) => h());
});
+ // The update is debounced β advance past the 300ms timer.
+ act(() => {
+ vi.advanceTimersByTime(350);
+ });
+
expect(result.current).toBe('You: second');
+ vi.useRealTimers();
});
});
diff --git a/src/app/hooks/useRoomLastMessage.ts b/src/app/hooks/useRoomLastMessage.ts
index 04dc4fd9b..e40daf938 100644
--- a/src/app/hooks/useRoomLastMessage.ts
+++ b/src/app/hooks/useRoomLastMessage.ts
@@ -1,4 +1,4 @@
-import { useEffect, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
import {
MatrixClient,
MatrixEvent,
@@ -90,8 +90,7 @@ export function getLastMessageText(room: Room, mx: MatrixClient): string | undef
if (senderId === mx.getUserId()) {
prefix = 'You';
} else {
- prefix =
- room.getMember(senderId ?? '')?.name ?? displayNameFromMxid(senderId ?? 'Unknown');
+ prefix = room.getMember(senderId ?? '')?.name ?? displayNameFromMxid(senderId ?? 'Unknown');
}
return `${prefix}: ${text}`;
}
@@ -111,13 +110,21 @@ export function useRoomLastMessage(
room && mx ? getLastMessageText(room, mx) : undefined
);
+ // Debounce timer ref β cleared on unmount and room change.
+ const debounceRef = useRef | undefined>(undefined);
+
useEffect(() => {
if (!room || !mx) {
setText(undefined);
return undefined;
}
- const update = () => setText(getLastMessageText(room, mx));
+ const update = () => {
+ clearTimeout(debounceRef.current);
+ debounceRef.current = setTimeout(() => {
+ setText(getLastMessageText(room, mx));
+ }, 300);
+ };
// Subscribe before reading to close the race window: any decryption that
// completes after this point will trigger an update via the listener.
@@ -145,6 +152,7 @@ export function useRoomLastMessage(
}
return () => {
+ clearTimeout(debounceRef.current);
room.off(RoomEventEnum.Timeline, update);
room.off(RoomEventEnum.LocalEchoUpdated, update);
mx.off(MatrixEventEvent.Decrypted, onDecrypted);
diff --git a/src/app/pages/client/direct/Direct.tsx b/src/app/pages/client/direct/Direct.tsx
index 3b78f43aa..7bf4d153b 100644
--- a/src/app/pages/client/direct/Direct.tsx
+++ b/src/app/pages/client/direct/Direct.tsx
@@ -187,16 +187,18 @@ export function Direct() {
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
// Track timeline activity to trigger re-sorting when messages arrive.
- // Without this, DMs only re-sort when you switch rooms because getLastActiveTimestamp()
- // is internal SDK state not tracked by React dependencies.
+ // Debounced to prevent excessive re-renders on rapid events (reactions, edits, etc.).
const [activityCounter, setActivityCounter] = useState(0);
const directsSetRef = useRef(directs);
+ const activityTimerRef = useRef | undefined>(undefined);
directsSetRef.current = directs;
useEffect(() => {
const handleTimeline = () => {
- // Increment counter to trigger re-sort when any timeline event happens
- setActivityCounter((prev) => prev + 1);
+ clearTimeout(activityTimerRef.current);
+ activityTimerRef.current = setTimeout(() => {
+ setActivityCounter((prev) => prev + 1);
+ }, 500);
};
// Listen to timeline events only for direct message rooms
@@ -206,6 +208,7 @@ export function Direct() {
});
return () => {
+ clearTimeout(activityTimerRef.current);
directsSetRef.current.forEach((roomId) => {
const room = mx.getRoom(roomId);
room?.off(RoomEvent.Timeline, handleTimeline);
From f8986c14acda013f4e29318f178c8b2c5e65af64 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sat, 18 Apr 2026 19:41:03 -0400
Subject: [PATCH 140/191] fix(preview): resolve display names in room previews
---
src/app/hooks/useRoomLastMessage.test.tsx | 9 ++++++++-
src/app/hooks/useRoomLastMessage.ts | 4 +++-
src/client/slidingSync.ts | 16 +++++++++++-----
3 files changed, 22 insertions(+), 7 deletions(-)
diff --git a/src/app/hooks/useRoomLastMessage.test.tsx b/src/app/hooks/useRoomLastMessage.test.tsx
index 2e4b725a3..f8cc9d528 100644
--- a/src/app/hooks/useRoomLastMessage.test.tsx
+++ b/src/app/hooks/useRoomLastMessage.test.tsx
@@ -181,7 +181,8 @@ describe('getLastMessageText', () => {
getLiveTimeline: () => ({
getEvents: () => events,
}),
- getMember: (id: string) => (members?.[id] ? { name: members[id] } : null),
+ getMember: (id: string) =>
+ members?.[id] ? { name: members[id], rawDisplayName: members[id] } : null,
}) as never;
it('returns "You: text" when the sender is the current user', () => {
@@ -201,6 +202,12 @@ describe('getLastMessageText', () => {
expect(getLastMessageText(room, makeMx())).toBe('bob: hey');
});
+ it('falls back to localpart when member is loaded but has no display name', () => {
+ const ev = makeEvent({ sender: '@bob:test', content: { msgtype: 'm.text', body: 'hey' } });
+ const room = makeRoom([ev], { '@bob:test': '@bob:test' });
+ expect(getLastMessageText(room, makeMx())).toBe('bob: hey');
+ });
+
it('skips reactions and picks the last real message', () => {
const msg = makeEvent({ content: { msgtype: 'm.text', body: 'real' } });
const reaction = makeEvent({ type: 'm.reaction', content: {} });
diff --git a/src/app/hooks/useRoomLastMessage.ts b/src/app/hooks/useRoomLastMessage.ts
index e40daf938..92b4c3128 100644
--- a/src/app/hooks/useRoomLastMessage.ts
+++ b/src/app/hooks/useRoomLastMessage.ts
@@ -8,6 +8,7 @@ import {
RoomEvent as RoomEventEnum,
} from '$types/matrix-sdk';
import { MessageEvent } from '$types/matrix/room';
+import { getMemberDisplayName } from '$utils/room';
/**
* Strip the legacy reply fallback (lines starting with `> `) that some
@@ -90,7 +91,8 @@ export function getLastMessageText(room: Room, mx: MatrixClient): string | undef
if (senderId === mx.getUserId()) {
prefix = 'You';
} else {
- prefix = room.getMember(senderId ?? '')?.name ?? displayNameFromMxid(senderId ?? 'Unknown');
+ prefix =
+ getMemberDisplayName(room, senderId ?? '') ?? displayNameFromMxid(senderId ?? 'Unknown');
}
return `${prefix}: ${text}`;
}
diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts
index d7acfb35f..a55bdedff 100644
--- a/src/client/slidingSync.ts
+++ b/src/client/slidingSync.ts
@@ -97,8 +97,11 @@ const clampPositive = (value: number | undefined, fallback: number): number => {
// Notes:
// - RoomName/RoomCanonicalAlias are omitted: sliding sync returns the room name as a
// top-level field in every list response, so fetching them as state events is redundant.
-// - MSC3575_STATE_KEY_LAZY is omitted: lazy-loading members is only needed when the
-// user is actively viewing a room; loading them for every list entry wastes bandwidth.
+// - MSC3575_STATE_KEY_LAZY is included only when `includeMembers=true` (i.e. when
+// message previews are enabled and listTimelineLimit > 0). Lazy loading brings in
+// m.room.member state events for senders of the preview timeline events so that
+// display names resolve correctly. When previews are disabled, lazy loading is
+// omitted to avoid wasteful member fetches for every list entry.
// - SpaceChild with wildcard is required: the roomToParents atom reads m.space.child
// state events (one per child, keyed by child room ID) to build the space hierarchy.
// Without these events the SDK has no parentβchild mapping, so all rooms appear as
@@ -116,7 +119,9 @@ const clampPositive = (value: number | undefined, fallback: number): number => {
// for non-active rooms β notification serverName extraction, mention autocomplete
// alias display, and getCanonicalAliasOrRoomId for navigation. Without it, aliases
// fall back silently to room IDs.
-const buildListRequiredState = (): MSC3575RoomSubscription['required_state'] => [
+const buildListRequiredState = (
+ includeMembers: boolean
+): MSC3575RoomSubscription['required_state'] => [
[EventType.RoomJoinRules, ''],
[EventType.RoomAvatar, ''],
[EventType.RoomTombstone, ''],
@@ -125,6 +130,7 @@ const buildListRequiredState = (): MSC3575RoomSubscription['required_state'] =>
[EventType.RoomTopic, ''],
[EventType.RoomCanonicalAlias, ''],
[EventType.RoomMember, MSC3575_STATE_KEY_ME],
+ ...(includeMembers ? [[EventType.RoomMember, MSC3575_STATE_KEY_LAZY] as [string, string]] : []),
['m.space.child', MSC3575_WILDCARD],
['im.ponies.room_emotes', MSC3575_WILDCARD],
['moe.sable.room.abbreviations', ''],
@@ -153,7 +159,7 @@ const buildLists = (
listTimelineLimit: number
): Map => {
const lists = new Map();
- const listRequiredState = buildListRequiredState();
+ const listRequiredState = buildListRequiredState(listTimelineLimit > 0);
// Start with a reasonable initial range that will quickly expand to full list
// Since timeline_limit=1, loading many rooms is very cheap
@@ -728,7 +734,7 @@ export class SlidingSyncManager {
ranges: [[0, 20]],
sort: LIST_SORT_ORDER,
timeline_limit: this.listTimelineLimit,
- required_state: buildListRequiredState(),
+ required_state: buildListRequiredState(this.listTimelineLimit > 0),
...updateArgs,
};
} else {
From 5cc0da583f974d698971ce4b1517a27aee0d9e9d Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sat, 18 Apr 2026 20:45:43 -0400
Subject: [PATCH 141/191] fix(presence): normalize dnd state handling
---
src/app/components/presence/Presence.tsx | 1 +
src/app/features/settings/account/Profile.tsx | 7 +-
src/app/hooks/useAppVisibility.ts | 219 ++----------------
src/app/hooks/useClientConfig.ts | 94 --------
src/app/hooks/usePresenceAutoIdle.test.tsx | 20 +-
src/app/hooks/usePresenceAutoIdle.ts | 16 +-
src/app/hooks/useUserPresence.test.tsx | 81 +++++++
src/app/hooks/useUserPresence.ts | 74 +++++-
src/app/pages/client/ClientNonUIFeatures.tsx | 49 ++--
.../client/sidebar/AccountSwitcherTab.tsx | 16 +-
.../pages/client/sidebar/DirectDMsList.tsx | 20 +-
src/app/utils/appEvents.ts | 28 +--
12 files changed, 230 insertions(+), 395 deletions(-)
diff --git a/src/app/components/presence/Presence.tsx b/src/app/components/presence/Presence.tsx
index e6ac463bb..ea9ed73d5 100644
--- a/src/app/components/presence/Presence.tsx
+++ b/src/app/components/presence/Presence.tsx
@@ -18,6 +18,7 @@ const PresenceToColor: Record = {
[Presence.Online]: 'Success',
[Presence.Unavailable]: 'Warning',
[Presence.Offline]: 'Secondary',
+ [Presence.Dnd]: 'Critical',
};
type PresenceBadgeProps = {
diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx
index da2d140f6..2595f7e1c 100644
--- a/src/app/features/settings/account/Profile.tsx
+++ b/src/app/features/settings/account/Profile.tsx
@@ -46,7 +46,7 @@ import { CompactUploadCardRenderer } from '$components/upload-card';
import { useCapabilities } from '$hooks/useCapabilities';
import { profilesCacheAtom } from '$state/userRoomProfile';
import { SequenceCardStyle } from '$features/settings/styles.css';
-import { useUserPresence } from '$hooks/useUserPresence';
+import { Presence, useUserPresence } from '$hooks/useUserPresence';
import { MSC1767Text } from '$types/matrix/common';
import { TimezoneEditor } from './TimezoneEditor';
import { PronounEditor } from './PronounEditor';
@@ -511,7 +511,10 @@ function ProfileExtended({ profile, userId }: Readonly) {
const handleSaveStatus = useCallback(
async (newStatus: string) => {
- const currentState = presence?.presence || 'online';
+ const currentState =
+ presence?.presence === Presence.Dnd
+ ? Presence.Online
+ : (presence?.presence ?? Presence.Online);
await mx.setPresence({
presence: currentState,
diff --git a/src/app/hooks/useAppVisibility.ts b/src/app/hooks/useAppVisibility.ts
index 4e7b5b131..7fd5f2325 100644
--- a/src/app/hooks/useAppVisibility.ts
+++ b/src/app/hooks/useAppVisibility.ts
@@ -1,96 +1,22 @@
-import { useCallback, useEffect, useRef } from 'react';
+import { useEffect } from 'react';
import { MatrixClient } from '$types/matrix-sdk';
+import { useAtom } from 'jotai';
+import { togglePusher } from '../features/settings/notifications/PushNotifications';
import { appEvents } from '../utils/appEvents';
-import { useClientConfig, useExperimentVariant } from './useClientConfig';
+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 { createDebugLogger } from '../utils/debugLogger';
-import { pushSessionToSW } from '../../sw-session';
const debugLog = createDebugLogger('AppVisibility');
-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) {
const clientConfig = useClientConfig();
-
- const sessionSyncConfig = clientConfig.sessionSync;
- const sessionSyncVariant = useExperimentVariant(
- 'sessionSyncStrategy',
- mx?.getUserId() ?? undefined
- );
-
- // 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 = mx?.getHomeserverUrl();
- const accessToken = mx?.getAccessToken();
- const userId = mx?.getUserId();
- 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';
- },
- [mx, phase1ForegroundResync, phase2VisibleHeartbeat, phase3AdaptiveBackoffJitter]
- );
+ const [usePushNotifications] = useSetting(settingsAtom, 'usePushNotifications');
+ const pushSubAtom = useAtom(pushSubscriptionAtom);
+ const isMobile = mobileOrTablet();
useEffect(() => {
const handleVisibilityChange = () => {
@@ -100,133 +26,30 @@ export function useAppVisibility(mx: MatrixClient | undefined) {
`App visibility changed: ${isVisible ? 'visible (foreground)' : 'hidden (background)'}`,
{ visibilityState: document.visibilityState }
);
- appEvents.emitVisibilityChange(isVisible);
+ appEvents.onVisibilityChange?.(isVisible);
if (!isVisible) {
- appEvents.emitVisibilityHidden();
- 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;
- }
+ appEvents.onVisibilityHidden?.();
}
};
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 (!phase2VisibleHeartbeat || !mx) 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();
+ if (!mx) return;
- 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());
+ const handleVisibilityForNotifications = (isVisible: boolean) => {
+ togglePusher(mx, clientConfig, isVisible, usePushNotifications, pushSubAtom, isMobile);
};
- timeoutId = window.setTimeout(tick, getDelayMs());
-
+ appEvents.onVisibilityChange = handleVisibilityForNotifications;
+ // eslint-disable-next-line consistent-return
return () => {
- if (timeoutId !== undefined) window.clearTimeout(timeoutId);
+ appEvents.onVisibilityChange = null;
};
- }, [
- heartbeatIntervalMs,
- heartbeatMaxBackoffMs,
- mx,
- phase2VisibleHeartbeat,
- phase3AdaptiveBackoffJitter,
- pushSessionNow,
- ]);
+ }, [mx, clientConfig, usePushNotifications, pushSubAtom, isMobile]);
}
diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts
index 3f5568e80..0e7257532 100644
--- a/src/app/hooks/useClientConfig.ts
+++ b/src/app/hooks/useClientConfig.ts
@@ -5,31 +5,6 @@ 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[];
@@ -39,8 +14,6 @@ export type ClientConfig = {
disableAccountSwitcher?: boolean;
hideUsernamePasswordFields?: boolean;
- experiments?: Record;
-
pushNotificationDetails?: {
pushNotifyUrl?: string;
vapidPublicKey?: string;
@@ -70,7 +43,6 @@ export type ClientConfig = {
matrixToBaseUrl?: string;
settingsLinkBaseUrl?: string;
- sessionSync?: SessionSyncConfig;
/** How long (ms) without input before auto-idling presence. 0 = disabled. */
presenceAutoIdleTimeoutMs?: number;
};
@@ -85,72 +57,6 @@ 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/usePresenceAutoIdle.test.tsx b/src/app/hooks/usePresenceAutoIdle.test.tsx
index 8e2f6d138..2fea1eddd 100644
--- a/src/app/hooks/usePresenceAutoIdle.test.tsx
+++ b/src/app/hooks/usePresenceAutoIdle.test.tsx
@@ -2,7 +2,6 @@ import { act, renderHook } from '@testing-library/react';
import { Provider, useAtomValue } from 'jotai';
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
import { presenceAutoIdledAtom } from '$state/settings';
-import { appEvents } from '$utils/appEvents';
import type { ReactNode } from 'react';
import { usePresenceAutoIdle } from './usePresenceAutoIdle';
@@ -92,7 +91,7 @@ describe('usePresenceAutoIdle', () => {
expect(result.current).toBe(false);
});
- it('resets auto-idle when app becomes visible via appEvents', () => {
+ it('resets auto-idle when the document becomes visible again', () => {
const { result } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 5000), {
wrapper,
});
@@ -102,11 +101,16 @@ describe('usePresenceAutoIdle', () => {
});
expect(result.current).toBe(true);
- // Simulate app returning to foreground.
+ const visibilityStateSpy = vi
+ .spyOn(document, 'visibilityState', 'get')
+ .mockReturnValue('visible');
+
act(() => {
- appEvents.emitVisibilityChange(true);
+ document.dispatchEvent(new Event('visibilitychange'));
});
expect(result.current).toBe(false);
+
+ visibilityStateSpy.mockRestore();
});
it('does not go idle when presenceMode is not online', () => {
@@ -201,7 +205,7 @@ describe('usePresenceAutoIdle', () => {
expect(result.current).toBe(false);
});
- it('unsubscribes from appEvents.onVisibilityChange on cleanup', () => {
+ it('stops responding to focus events after cleanup', () => {
const { result, unmount } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 5000), {
wrapper,
});
@@ -214,10 +218,10 @@ describe('usePresenceAutoIdle', () => {
unmount();
- // After unmount, emitting visibility change should have no effect.
- // (No error thrown means the handler was properly unsubscribed.)
act(() => {
- appEvents.emitVisibilityChange(true);
+ window.dispatchEvent(new Event('focus'));
});
+
+ expect(result.current).toBe(true);
});
});
diff --git a/src/app/hooks/usePresenceAutoIdle.ts b/src/app/hooks/usePresenceAutoIdle.ts
index dc5af7e21..c4f14a008 100644
--- a/src/app/hooks/usePresenceAutoIdle.ts
+++ b/src/app/hooks/usePresenceAutoIdle.ts
@@ -2,7 +2,6 @@ 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 { appEvents } from '$utils/appEvents';
import { createDebugLogger } from '$utils/debugLogger';
const debugLog = createDebugLogger('PresenceAutoIdle');
@@ -65,22 +64,23 @@ export function usePresenceAutoIdle(
timerRef.current = window.setTimeout(goIdle, timeoutMs);
};
+ const handleVisibilityChange = () => {
+ if (document.visibilityState === 'visible') handleActivity();
+ };
+
// Start the initial timer.
timerRef.current = window.setTimeout(goIdle, timeoutMs);
ACTIVITY_EVENTS.forEach((ev) =>
document.addEventListener(ev, handleActivity, { passive: true })
);
-
- // When the app returns to the foreground, treat it as activity so the user
- // isn't shown as idle the moment they switch back to the tab/PWA.
- const unsubVisibility = appEvents.onVisibilityChange((isVisible: boolean) => {
- if (isVisible) handleActivity();
- });
+ document.addEventListener('visibilitychange', handleVisibilityChange);
+ window.addEventListener('focus', handleActivity);
return () => {
ACTIVITY_EVENTS.forEach((ev) => document.removeEventListener(ev, handleActivity));
+ document.removeEventListener('visibilitychange', handleVisibilityChange);
+ window.removeEventListener('focus', handleActivity);
clearTimer();
- unsubVisibility();
};
}, [clearTimer, presenceMode, sendPresence, setAutoIdled, timeoutMs]);
diff --git a/src/app/hooks/useUserPresence.test.tsx b/src/app/hooks/useUserPresence.test.tsx
index 78f334d71..70ca6b5d2 100644
--- a/src/app/hooks/useUserPresence.test.tsx
+++ b/src/app/hooks/useUserPresence.test.tsx
@@ -1,5 +1,9 @@
import { act, renderHook } from '@testing-library/react';
+import { Provider } from 'jotai';
+import { useHydrateAtoms } from 'jotai/utils';
import { beforeEach, describe, expect, it, vi } from 'vitest';
+import type { ReactNode } from 'react';
+import { presenceAutoIdledAtom, settingsAtom } from '$state/settings';
import { useUserPresence, Presence, clearPresenceCache } from './useUserPresence';
// ------- mock setup -------
@@ -41,6 +45,7 @@ const makeMockUser = (
const mockMx = {
getUser: vi.fn((): ReturnType | null => mockUser),
getPresence: vi.fn((): Promise => mockGetPresence()),
+ getUserId: vi.fn<() => string | undefined>(() => undefined),
on: vi.fn(),
removeListener: vi.fn(),
};
@@ -51,14 +56,53 @@ vi.mock('./useMatrixClient', () => ({
const USER_ID = '@alice:test';
+type HookWrapperProps = {
+ children: ReactNode;
+ sendPresence?: boolean;
+ presenceMode?: 'online' | 'unavailable' | 'dnd' | 'offline';
+ autoIdled?: boolean;
+};
+
+const localStorageSettings = () => {
+ const rawSettings = localStorage.getItem('settings');
+ return rawSettings ? JSON.parse(rawSettings) : {};
+};
+
+const HydratePresenceSettings = ({
+ children,
+ sendPresence = true,
+ presenceMode = 'online',
+ autoIdled = false,
+}: HookWrapperProps) => {
+ useHydrateAtoms([
+ [settingsAtom, { ...localStorageSettings(), sendPresence, presenceMode }],
+ [presenceAutoIdledAtom, autoIdled],
+ ]);
+ return children;
+};
+
+const createWrapper = (options?: Omit) => {
+ function Wrapper({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+ }
+
+ return Wrapper;
+};
+
beforeEach(() => {
vi.clearAllMocks();
userListeners.clear();
clearPresenceCache();
+ localStorage.clear();
mockUser = null;
mockGetPresence = () => new Promise(() => {}); // pending by default
mockMx.getUser.mockImplementation(() => mockUser);
mockMx.getPresence.mockImplementation(() => mockGetPresence());
+ mockMx.getUserId.mockReturnValue(undefined);
});
// ------- tests -------
@@ -207,4 +251,41 @@ describe('useUserPresence', () => {
// Should still be undefined without throwing
expect(result.current).toBeUndefined();
});
+
+ it('normalizes synthetic dnd presence from the SDK user object', () => {
+ mockUser = makeMockUser({ presence: 'online', presenceStatusMsg: 'dnd', lastActiveTs: 1000 });
+
+ const { result } = renderHook(() => useUserPresence('@bob:test'));
+
+ expect(result.current).toEqual({
+ presence: Presence.Dnd,
+ status: undefined,
+ active: true,
+ lastActiveTs: 1000,
+ });
+ });
+
+ it('overrides own presence from settings so member lists update immediately', () => {
+ mockUser = makeMockUser({ presence: 'online', lastActiveTs: 1000 });
+ mockMx.getUserId.mockReturnValue(USER_ID);
+
+ const { result } = renderHook(() => useUserPresence(USER_ID), {
+ wrapper: createWrapper({ presenceMode: 'dnd' }),
+ });
+
+ expect(result.current?.presence).toBe(Presence.Dnd);
+ expect(result.current?.status).toBeUndefined();
+ });
+
+ it('marks own presence idle when auto-idle is active', () => {
+ mockUser = makeMockUser({ presence: 'online', lastActiveTs: 1000 });
+ mockMx.getUserId.mockReturnValue(USER_ID);
+
+ const { result } = renderHook(() => useUserPresence(USER_ID), {
+ wrapper: createWrapper({ autoIdled: true }),
+ });
+
+ expect(result.current?.presence).toBe(Presence.Unavailable);
+ expect(result.current?.active).toBe(false);
+ });
});
diff --git a/src/app/hooks/useUserPresence.ts b/src/app/hooks/useUserPresence.ts
index c8e2a480f..63e995df9 100644
--- a/src/app/hooks/useUserPresence.ts
+++ b/src/app/hooks/useUserPresence.ts
@@ -1,11 +1,15 @@
import { useEffect, useMemo, useState } from 'react';
+import { useAtomValue } from 'jotai';
import { ClientEvent, MatrixEvent, User, UserEvent, UserEventHandlerMap } from '$types/matrix-sdk';
+import { presenceAutoIdledAtom, settingsAtom } from '$state/settings';
+import { useSetting } from '$state/hooks/settings';
import { useMatrixClient } from './useMatrixClient';
export enum Presence {
Online = 'online',
Unavailable = 'unavailable',
Offline = 'offline',
+ Dnd = 'dnd',
}
export type UserPresence = {
@@ -15,13 +19,66 @@ export type UserPresence = {
lastActiveTs?: number;
};
+const isSyntheticDndStatus = (status?: string): boolean => status === 'dnd';
+
+const normalizePresence = (presence: string | undefined, status?: string): Presence => {
+ if (presence === Presence.Online && isSyntheticDndStatus(status)) return Presence.Dnd;
+ if (presence === Presence.Unavailable) return Presence.Unavailable;
+ if (presence === Presence.Offline) return Presence.Offline;
+ return Presence.Online;
+};
+
+const sanitizeStatus = (status?: string): string | undefined =>
+ isSyntheticDndStatus(status) ? undefined : status;
+
const getUserPresence = (user: User): UserPresence => ({
- presence: user.presence as Presence,
- status: user.presenceStatusMsg,
+ presence: normalizePresence(user.presence, user.presenceStatusMsg),
+ status: sanitizeStatus(user.presenceStatusMsg),
active: user.currentlyActive,
lastActiveTs: user.getLastActiveTs(),
});
+const getOwnEffectivePresence = (
+ sendPresence: boolean,
+ presenceMode: string | undefined,
+ autoIdled: boolean
+): Presence => {
+ if (!sendPresence) return Presence.Offline;
+ if (autoIdled) return Presence.Unavailable;
+ if (presenceMode === Presence.Unavailable) return Presence.Unavailable;
+ if (presenceMode === Presence.Offline) return Presence.Offline;
+ if (presenceMode === Presence.Dnd) return Presence.Dnd;
+ return Presence.Online;
+};
+
+const applyOwnPresenceOverride = (
+ rawPresence: UserPresence | undefined,
+ sendPresence: boolean,
+ presenceMode: string | undefined,
+ autoIdled: boolean
+): UserPresence | undefined => {
+ const effectivePresence = getOwnEffectivePresence(sendPresence, presenceMode, autoIdled);
+ const sanitizedStatus = sanitizeStatus(rawPresence?.status);
+
+ if (!rawPresence) {
+ return {
+ presence: effectivePresence,
+ status: effectivePresence === Presence.Dnd ? undefined : sanitizedStatus,
+ active: effectivePresence === Presence.Online || effectivePresence === Presence.Dnd,
+ };
+ }
+
+ return {
+ ...rawPresence,
+ presence: effectivePresence,
+ status: effectivePresence === Presence.Dnd ? undefined : sanitizedStatus,
+ active:
+ effectivePresence === Presence.Online || effectivePresence === Presence.Dnd
+ ? rawPresence.active
+ : false,
+ };
+};
+
// In-memory presence REST cache to avoid N+1 /presence/{userId}/status floods.
// Multiple hook instances for the same user share a single in-flight request.
const PRESENCE_CACHE_TTL_MS = 60_000;
@@ -57,8 +114,8 @@ function fetchPresenceOnce(
.getPresence(userId)
.then((resp) => {
const data: UserPresence = {
- presence: resp.presence as Presence,
- status: resp.status_msg,
+ presence: normalizePresence(resp.presence, resp.status_msg),
+ status: sanitizeStatus(resp.status_msg),
active: resp.currently_active ?? false,
lastActiveTs: resp.last_active_ago != null ? Date.now() - resp.last_active_ago : undefined,
};
@@ -84,6 +141,9 @@ function fetchPresenceOnce(
export const useUserPresence = (userId: string): UserPresence | undefined => {
const mx = useMatrixClient();
+ const [sendPresence] = useSetting(settingsAtom, 'sendPresence');
+ const [presenceMode] = useSetting(settingsAtom, 'presenceMode');
+ const autoIdled = useAtomValue(presenceAutoIdledAtom);
const user = mx.getUser(userId);
const [presence, setPresence] = useState(() => (user ? getUserPresence(user) : undefined));
@@ -137,7 +197,10 @@ export const useUserPresence = (userId: string): UserPresence | undefined => {
};
}, [mx, userId, user]);
- return presence;
+ return useMemo(() => {
+ if (userId !== mx.getUserId()) return presence;
+ return applyOwnPresenceOverride(presence, sendPresence, presenceMode, autoIdled);
+ }, [autoIdled, mx, presence, presenceMode, sendPresence, userId]);
};
export const usePresenceLabel = (): Record =>
@@ -146,6 +209,7 @@ export const usePresenceLabel = (): Record =>
[Presence.Online]: 'Online',
[Presence.Unavailable]: 'Idle',
[Presence.Offline]: 'Offline',
+ [Presence.Dnd]: 'Do Not Disturb',
}),
[]
);
diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx
index c50f411a4..9c5c76306 100644
--- a/src/app/pages/client/ClientNonUIFeatures.tsx
+++ b/src/app/pages/client/ClientNonUIFeatures.tsx
@@ -1,4 +1,4 @@
-import { useAtomValue, useSetAtom, useAtom } from 'jotai';
+import { useAtomValue, useSetAtom } from 'jotai';
import * as Sentry from '@sentry/react';
import { ReactNode, useCallback, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
@@ -646,23 +646,10 @@ 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);
- window.addEventListener('pagehide', postHidden);
- return () => {
- document.removeEventListener('visibilitychange', postVisibility);
- window.removeEventListener('pagehide', postHidden);
- };
+ return () => document.removeEventListener('visibilitychange', postVisibility);
}, []);
useEffect(() => {
@@ -844,40 +831,36 @@ function PresenceFeature() {
const mx = useMatrixClient();
const [sendPresence] = useSetting(settingsAtom, 'sendPresence');
const [presenceMode] = useSetting(settingsAtom, 'presenceMode');
- const [autoIdled] = useAtom(presenceAutoIdledAtom);
+ const autoIdled = useAtomValue(presenceAutoIdledAtom);
const clientConfig = useClientConfig();
const timeoutMs = clientConfig.presenceAutoIdleTimeoutMs ?? 0;
usePresenceAutoIdle(mx, presenceMode ?? 'online', sendPresence, timeoutMs);
useEffect(() => {
- // 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 = effectiveMode === 'dnd' ? 'online' : effectiveMode;
const effectiveState = sendPresence ? activePresence : 'offline';
- const broadcasting = effectiveState !== 'offline';
+ const ownUser = mx.getUser(mx.getUserId() ?? '');
+ const shouldClearSyntheticDndStatus =
+ ownUser?.presenceStatusMsg === 'dnd' && (!sendPresence || effectiveMode !== 'dnd');
+ let statusPayload: { status_msg: string } | undefined;
+
+ if (sendPresence && effectiveMode === 'dnd') {
+ statusPayload = { status_msg: 'dnd' };
+ } else if (shouldClearSyntheticDndStatus) {
+ statusPayload = { status_msg: '' };
+ }
- // Classic sync: set_presence query param on every /sync poll.
- // Passing undefined restores the default (online); Offline suppresses broadcasting.
- 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).
+ mx.setSyncPresence(sendPresence ? undefined : SetPresence.Offline);
getSlidingSyncManager(mx)?.setPresenceEnabled(sendPresence);
- // 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.
const presencePayload = {
presence: effectiveState,
- ...(sendPresence && effectiveMode === 'dnd' ? { status_msg: 'dnd' } : {}),
+ ...statusPayload,
};
let retryTimer: ReturnType | undefined;
const trySetPresence = (attempt = 0) => {
mx.setPresence(presencePayload).catch(() => {
- // Retry up to 3 times with back-off: the HTTP client may not have
- // reconnected yet after the app resumes from background.
if (attempt < 3) {
retryTimer = setTimeout(() => trySetPresence(attempt + 1), 2000 * (attempt + 1));
}
@@ -887,7 +870,7 @@ function PresenceFeature() {
return () => {
if (retryTimer !== undefined) clearTimeout(retryTimer);
};
- }, [mx, sendPresence, presenceMode, autoIdled]);
+ }, [autoIdled, mx, presenceMode, sendPresence]);
return null;
}
diff --git a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx
index 191a585dd..4c3838007 100644
--- a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx
+++ b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx
@@ -1,6 +1,5 @@
import { MouseEvent, MouseEventHandler, ReactNode, useCallback, useState } from 'react';
import {
- Badge,
Box,
Button,
Dialog,
@@ -189,13 +188,7 @@ export function AccountSwitcherTab() {
const effectiveDisplayMode = autoIdled ? 'unavailable' : (presenceMode ?? 'online');
let myOwnPresenceBadge: ReactNode;
if (sendPresence) {
- myOwnPresenceBadge =
- effectiveDisplayMode === 'dnd' ? (
- // DND: solid red badge (broadcasts as online with status_msg 'dnd')
-
- ) : (
-
- );
+ myOwnPresenceBadge = ;
}
const activeAvatarUrl = activeProfile.avatarUrl
? (mxcUrlToHttp(mx, activeProfile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined)
@@ -394,12 +387,7 @@ export function AccountSwitcherTab() {
] as const
).map(({ label: statusLabel, desc, mode }) => {
const isSelected = sendPresence && (presenceMode ?? 'online') === mode;
- const badge =
- mode === 'dnd' ? (
-
- ) : (
-
- );
+ const badge = ;
return (
p && p.lastActiveTs != null && p.lastActiveTs !== 0 && p.presence === Presence.Online
- );
+ const groupDMPresence = isGroupDM
+ ? [member0Presence, member1Presence, member2Presence].reduce(
+ (acc, current) => {
+ if (!current || current.lastActiveTs == null || current.lastActiveTs === 0) return acc;
+ if (current.presence === Presence.Dnd) return Presence.Dnd;
+ if (!acc && current.presence === Presence.Online) return Presence.Online;
+ return acc;
+ },
+ undefined
+ )
+ : undefined;
let presenceBadge: ReactNode;
if (
@@ -73,8 +79,8 @@ function DMItem({ room, selected }: DMItemProps) {
singleDMPresence.lastActiveTs !== 0
) {
presenceBadge = ;
- } else if (isGroupDM && groupDMOnline) {
- presenceBadge = ;
+ } else if (groupDMPresence) {
+ presenceBadge = ;
}
// Get unread info for badge
diff --git a/src/app/utils/appEvents.ts b/src/app/utils/appEvents.ts
index 2430f5324..2834c5b6f 100644
--- a/src/app/utils/appEvents.ts
+++ b/src/app/utils/appEvents.ts
@@ -1,29 +1,5 @@
-export type VisibilityChangeHandler = (isVisible: boolean) => void;
-type VisibilityHiddenHandler = () => void;
-
-const visibilityChangeHandlers = new Set();
-const visibilityHiddenHandlers = new Set();
-
export const appEvents = {
- onVisibilityHidden(handler: VisibilityHiddenHandler): () => void {
- visibilityHiddenHandlers.add(handler);
- return () => {
- visibilityHiddenHandlers.delete(handler);
- };
- },
-
- emitVisibilityHidden(): void {
- visibilityHiddenHandlers.forEach((h) => h());
- },
-
- onVisibilityChange(handler: VisibilityChangeHandler): () => void {
- visibilityChangeHandlers.add(handler);
- return () => {
- visibilityChangeHandlers.delete(handler);
- };
- },
+ onVisibilityHidden: null as (() => void) | null,
- emitVisibilityChange(isVisible: boolean): void {
- visibilityChangeHandlers.forEach((h) => h(isVisible));
- },
+ onVisibilityChange: null as ((isVisible: boolean) => void) | null,
};
From e1adb09616caea6c4c0e7d92378bca4645eaa983 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sat, 18 Apr 2026 20:47:17 -0400
Subject: [PATCH 142/191] fix(preview): remove timeline spillover
---
src/app/features/room/RoomTimeline.tsx | 5 ++---
src/app/hooks/timeline/useTimelineSync.test.tsx | 2 +-
src/app/hooks/timeline/useTimelineSync.ts | 10 +++++-----
3 files changed, 8 insertions(+), 9 deletions(-)
diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx
index 0630dcc9b..d026ec1fa 100644
--- a/src/app/features/room/RoomTimeline.tsx
+++ b/src/app/features/room/RoomTimeline.tsx
@@ -201,7 +201,7 @@ export function RoomTimeline({
const setOpenThread = useSetAtom(openThreadAtom);
const vListRef = useRef(null);
- const [atBottomState, setAtBottomState] = useState(!eventId);
+ const [atBottomState, setAtBottomState] = useState(true);
const atBottomRef = useRef(atBottomState);
const setAtBottom = useCallback((val: boolean) => {
setAtBottomState(val);
@@ -408,9 +408,8 @@ export function RoomTimeline({
useEffect(() => {
if (!eventId) return;
setIsReady(false);
- setAtBottom(false);
timelineSyncRef.current.loadEventTimeline(eventId);
- }, [eventId, room.roomId, setAtBottom]);
+ }, [eventId, room.roomId]);
useEffect(() => {
if (eventId) return;
diff --git a/src/app/hooks/timeline/useTimelineSync.test.tsx b/src/app/hooks/timeline/useTimelineSync.test.tsx
index e5e7c4cfd..d53d74143 100644
--- a/src/app/hooks/timeline/useTimelineSync.test.tsx
+++ b/src/app/hooks/timeline/useTimelineSync.test.tsx
@@ -129,7 +129,7 @@ describe('useTimelineSync', () => {
await Promise.resolve();
});
- expect(scrollToBottom).toHaveBeenCalled();
+ expect(scrollToBottom).toHaveBeenCalledWith('instant');
});
it('resets timeline state when room.roomId changes and eventId is not set', async () => {
diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts
index dda1e0207..51c85dda8 100644
--- a/src/app/hooks/timeline/useTimelineSync.ts
+++ b/src/app/hooks/timeline/useTimelineSync.ts
@@ -351,7 +351,7 @@ export interface UseTimelineSyncOptions {
eventId?: string;
isAtBottom: boolean;
isAtBottomRef: React.MutableRefObject;
- scrollToBottom: () => void;
+ scrollToBottom: (behavior?: 'instant' | 'smooth') => void;
unreadInfo: ReturnType;
setUnreadInfo: Dispatch>>;
hideReadsRef: React.MutableRefObject;
@@ -460,7 +460,7 @@ export function useTimelineSync({
useCallback(() => {
if (!alive()) return;
setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines });
- scrollToBottom();
+ scrollToBottom('instant');
}, [alive, room, scrollToBottom])
);
@@ -490,7 +490,7 @@ export function useTimelineSync({
setUnreadInfo(getRoomUnreadInfo(room));
}
- scrollToBottom();
+ scrollToBottom(getSender.call(mEvt) === mx.getUserId() ? 'instant' : 'smooth');
lastScrolledAtEventsLengthRef.current = eventsLengthRef.current + 1;
setTimeline((ct) => ({ ...ct }));
@@ -528,7 +528,7 @@ export function useTimelineSync({
resetAutoScrollPendingRef.current = wasAtBottom;
setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines });
if (wasAtBottom) {
- scrollToBottom();
+ scrollToBottom('instant');
}
}, [room, isAtBottomRef, scrollToBottom])
);
@@ -565,7 +565,7 @@ export function useTimelineSync({
if (eventsLength <= lastScrolledAtEventsLengthRef.current && !resetAutoScrollPending) return;
lastScrolledAtEventsLengthRef.current = eventsLength;
- scrollToBottom();
+ scrollToBottom('instant');
}, [isAtBottom, liveTimelineLinked, eventsLength, scrollToBottom]);
useEffect(() => {
From 3bea590f5151e4bc5eb04c2f4d4669d1b35e33f9 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sat, 18 Apr 2026 20:49:00 -0400
Subject: [PATCH 143/191] fix(bookmarks): remove unrelated branch spillover
---
config.json | 9 ---
src/app/features/room/RoomTimeline.tsx | 5 +-
src/app/features/room/message/Message.tsx | 4 +-
.../hooks/timeline/useTimelineSync.test.tsx | 2 +-
src/app/hooks/timeline/useTimelineSync.ts | 10 +--
src/app/hooks/useClientConfig.ts | 67 -------------------
src/app/hooks/useRoomNavigate.ts | 25 +++----
src/app/pages/client/ClientNonUIFeatures.tsx | 8 +--
src/app/pages/client/home/Home.tsx | 4 +-
src/app/pages/client/inbox/Inbox.tsx | 6 +-
src/client/slidingSync.ts | 6 +-
11 files changed, 26 insertions(+), 120 deletions(-)
diff --git a/config.json b/config.json
index 4f3dae570..f0c3c8b61 100644
--- a/config.json
+++ b/config.json
@@ -19,15 +19,6 @@
"enabled": true
},
- "experiments": {
- "messageBookmarks": {
- "enabled": false,
- "rolloutPercentage": 0,
- "controlVariant": "control",
- "variants": ["enabled"]
- }
- },
-
"featuredCommunities": {
"openAsDefault": false,
"spaces": [
diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx
index 0630dcc9b..d026ec1fa 100644
--- a/src/app/features/room/RoomTimeline.tsx
+++ b/src/app/features/room/RoomTimeline.tsx
@@ -201,7 +201,7 @@ export function RoomTimeline({
const setOpenThread = useSetAtom(openThreadAtom);
const vListRef = useRef(null);
- const [atBottomState, setAtBottomState] = useState(!eventId);
+ const [atBottomState, setAtBottomState] = useState(true);
const atBottomRef = useRef(atBottomState);
const setAtBottom = useCallback((val: boolean) => {
setAtBottomState(val);
@@ -408,9 +408,8 @@ export function RoomTimeline({
useEffect(() => {
if (!eventId) return;
setIsReady(false);
- setAtBottom(false);
timelineSyncRef.current.loadEventTimeline(eventId);
- }, [eventId, room.roomId, setAtBottom]);
+ }, [eventId, room.roomId]);
useEffect(() => {
if (eventId) return;
diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx
index 0eb23c8b0..482a50768 100644
--- a/src/app/features/room/message/Message.tsx
+++ b/src/app/features/room/message/Message.tsx
@@ -81,7 +81,6 @@ 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';
@@ -221,14 +220,13 @@ export const MessageBookmarkItem = as<
}
>(({ 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 (!eventId) return null;
- if (!bookmarksExperiment.inExperiment && !enableMessageBookmarks) return null;
+ if (!enableMessageBookmarks) return null;
const handleClick = async () => {
if (isBookmarked) {
diff --git a/src/app/hooks/timeline/useTimelineSync.test.tsx b/src/app/hooks/timeline/useTimelineSync.test.tsx
index e5e7c4cfd..d53d74143 100644
--- a/src/app/hooks/timeline/useTimelineSync.test.tsx
+++ b/src/app/hooks/timeline/useTimelineSync.test.tsx
@@ -129,7 +129,7 @@ describe('useTimelineSync', () => {
await Promise.resolve();
});
- expect(scrollToBottom).toHaveBeenCalled();
+ expect(scrollToBottom).toHaveBeenCalledWith('instant');
});
it('resets timeline state when room.roomId changes and eventId is not set', async () => {
diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts
index dda1e0207..51c85dda8 100644
--- a/src/app/hooks/timeline/useTimelineSync.ts
+++ b/src/app/hooks/timeline/useTimelineSync.ts
@@ -351,7 +351,7 @@ export interface UseTimelineSyncOptions {
eventId?: string;
isAtBottom: boolean;
isAtBottomRef: React.MutableRefObject;
- scrollToBottom: () => void;
+ scrollToBottom: (behavior?: 'instant' | 'smooth') => void;
unreadInfo: ReturnType;
setUnreadInfo: Dispatch>>;
hideReadsRef: React.MutableRefObject;
@@ -460,7 +460,7 @@ export function useTimelineSync({
useCallback(() => {
if (!alive()) return;
setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines });
- scrollToBottom();
+ scrollToBottom('instant');
}, [alive, room, scrollToBottom])
);
@@ -490,7 +490,7 @@ export function useTimelineSync({
setUnreadInfo(getRoomUnreadInfo(room));
}
- scrollToBottom();
+ scrollToBottom(getSender.call(mEvt) === mx.getUserId() ? 'instant' : 'smooth');
lastScrolledAtEventsLengthRef.current = eventsLengthRef.current + 1;
setTimeline((ct) => ({ ...ct }));
@@ -528,7 +528,7 @@ export function useTimelineSync({
resetAutoScrollPendingRef.current = wasAtBottom;
setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines });
if (wasAtBottom) {
- scrollToBottom();
+ scrollToBottom('instant');
}
}, [room, isAtBottomRef, scrollToBottom])
);
@@ -565,7 +565,7 @@ export function useTimelineSync({
if (eventsLength <= lastScrolledAtEventsLengthRef.current && !resetAutoScrollPending) return;
lastScrolledAtEventsLengthRef.current = eventsLength;
- scrollToBottom();
+ scrollToBottom('instant');
}, [isAtBottom, liveTimelineLinked, eventsLength, scrollToBottom]);
useEffect(() => {
diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts
index d3e85df09..e523f15a7 100644
--- a/src/app/hooks/useClientConfig.ts
+++ b/src/app/hooks/useClientConfig.ts
@@ -5,21 +5,6 @@ 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[];
@@ -29,8 +14,6 @@ export type ClientConfig = {
disableAccountSwitcher?: boolean;
hideUsernamePasswordFields?: boolean;
- experiments?: Record;
-
pushNotificationDetails?: {
pushNotifyUrl?: string;
vapidPublicKey?: string;
@@ -72,56 +55,6 @@ 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/hooks/useRoomNavigate.ts b/src/app/hooks/useRoomNavigate.ts
index c2918d5ca..51555125c 100644
--- a/src/app/hooks/useRoomNavigate.ts
+++ b/src/app/hooks/useRoomNavigate.ts
@@ -37,20 +37,7 @@ export const useRoomNavigate = () => {
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId);
const openSpaceTimeline = developerTools && spaceSelectedId === roomId;
- // Developer-mode: view the space's own timeline (must be checked first).
- if (openSpaceTimeline) {
- navigate(getSpaceRoomPath(roomIdOrAlias, roomId, eventId), opts);
- return;
- }
-
- // DMs take priority over space membership so direct chats always open
- // via the direct route, even when the room also belongs to a space.
- if (mDirects.has(roomId)) {
- navigate(getDirectRoomPath(roomIdOrAlias, eventId), opts);
- return;
- }
-
- const orphanParents = getOrphanParents(roomToParents, roomId);
+ const orphanParents = openSpaceTimeline ? [roomId] : getOrphanParents(roomToParents, roomId);
if (orphanParents.length > 0) {
let parentSpace: string;
if (spaceSelectedId && orphanParents.includes(spaceSelectedId)) {
@@ -61,7 +48,15 @@ export const useRoomNavigate = () => {
const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(mx, parentSpace);
- navigate(getSpaceRoomPath(pSpaceIdOrAlias, roomIdOrAlias, eventId), opts);
+ navigate(
+ getSpaceRoomPath(pSpaceIdOrAlias, openSpaceTimeline ? roomId : roomIdOrAlias, eventId),
+ opts
+ );
+ return;
+ }
+
+ if (mDirects.has(roomId)) {
+ navigate(getDirectRoomPath(roomIdOrAlias, eventId), opts);
return;
}
diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx
index ca0b82b81..caebe459a 100644
--- a/src/app/pages/client/ClientNonUIFeatures.tsx
+++ b/src/app/pages/client/ClientNonUIFeatures.tsx
@@ -514,12 +514,8 @@ function MessageNotifications() {
});
}
- // 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()) {
+ // In-app audio: play when notification sounds are enabled AND this notification is loud.
+ if (notificationSound && isLoud) {
playSound();
}
};
diff --git a/src/app/pages/client/home/Home.tsx b/src/app/pages/client/home/Home.tsx
index 175b48f9d..e7b832df5 100644
--- a/src/app/pages/client/home/Home.tsx
+++ b/src/app/pages/client/home/Home.tsx
@@ -59,7 +59,6 @@ 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,
@@ -210,9 +209,8 @@ export function Home() {
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 showBookmarks = enableMessageBookmarks;
const noRoomToDisplay = rooms.length === 0;
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
diff --git a/src/app/pages/client/inbox/Inbox.tsx b/src/app/pages/client/inbox/Inbox.tsx
index d594a928e..ab42f7dcb 100644
--- a/src/app/pages/client/inbox/Inbox.tsx
+++ b/src/app/pages/client/inbox/Inbox.tsx
@@ -15,10 +15,8 @@ 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();
@@ -76,11 +74,9 @@ function BookmarksNavItem() {
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;
+ const showBookmarks = enableMessageBookmarks;
return (
diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts
index 5c147179c..43fdf39ea 100644
--- a/src/client/slidingSync.ts
+++ b/src/client/slidingSync.ts
@@ -34,9 +34,9 @@ export const LIST_SEARCH = 'search';
export const LIST_ROOM_SEARCH = 'room_search';
// Dynamic list key used for space-scoped room views.
export const LIST_SPACE = 'space';
-// Higher limit avoids empty previews when the most-recent events are
-// reactions/edits/state that useRoomLatestRenderedEvent skips over.
-const LIST_TIMELINE_LIMIT = 3;
+// One event of timeline per list room is enough to compute unread counts;
+// the full history is loaded when the user opens the room.
+const LIST_TIMELINE_LIMIT = 1;
const DEFAULT_LIST_PAGE_SIZE = 250;
const DEFAULT_POLL_TIMEOUT_MS = 20000;
const DEFAULT_MAX_ROOMS = 5000;
From fd9e0079525be17d2f6a0e62f3366df8afc61a59 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sat, 18 Apr 2026 20:50:42 -0400
Subject: [PATCH 144/191] chore(bookmarks): fix branch validation issues
---
src/app/features/bookmarks/useInitBookmarks.test.tsx | 11 ++++-------
src/app/features/room/message/Message.tsx | 1 -
2 files changed, 4 insertions(+), 8 deletions(-)
diff --git a/src/app/features/bookmarks/useInitBookmarks.test.tsx b/src/app/features/bookmarks/useInitBookmarks.test.tsx
index 893b5d64c..afb24f953 100644
--- a/src/app/features/bookmarks/useInitBookmarks.test.tsx
+++ b/src/app/features/bookmarks/useInitBookmarks.test.tsx
@@ -44,9 +44,9 @@ const { accountDataCB, syncStateCB, mockMx } = vi.hoisted(() => {
};
const store: Record = {
- ['org.matrix.msc4438.bookmarks.index']: index,
- ['org.matrix.msc4438.bookmark.bmk_aabb']: item,
- ['org.matrix.msc4438.bookmark.bmk_ccdd']: deletedItem,
+ 'org.matrix.msc4438.bookmarks.index': index,
+ 'org.matrix.msc4438.bookmark.bmk_aabb': item,
+ 'org.matrix.msc4438.bookmark.bmk_ccdd': deletedItem,
};
const mx = {
@@ -67,10 +67,7 @@ vi.mock('$hooks/useMatrixClient', () => ({
}));
vi.mock('$hooks/useAccountDataCallback', () => ({
- useAccountDataCallback: (
- _mx: unknown,
- cb: (event: { getType: () => string }) => void
- ) => {
+ useAccountDataCallback: (_mx: unknown, cb: (event: { getType: () => string }) => void) => {
accountDataCB.current = cb;
},
}));
diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx
index 482a50768..04fc23563 100644
--- a/src/app/features/room/message/Message.tsx
+++ b/src/app/features/room/message/Message.tsx
@@ -219,7 +219,6 @@ export const MessageBookmarkItem = as<
onClose?: () => void;
}
>(({ room, mEvent, onClose, ...props }, ref) => {
- const mx = useMatrixClient();
const [enableMessageBookmarks] = useSetting(settingsAtom, 'enableMessageBookmarks');
const eventId = mEvent.getId();
const isBookmarked = useIsBookmarked(room.roomId, eventId ?? '');
From ef017653e0b2a93bb35e4d6c8c854f9326fd0d2d Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sun, 19 Apr 2026 13:38:15 -0400
Subject: [PATCH 145/191] fix(presence): harden desktop auto-idle detection
---
src/app/hooks/usePresenceAutoIdle.test.tsx | 13 +++++++
src/app/hooks/usePresenceAutoIdle.ts | 42 ++++++++++++++++++++--
2 files changed, 52 insertions(+), 3 deletions(-)
diff --git a/src/app/hooks/usePresenceAutoIdle.test.tsx b/src/app/hooks/usePresenceAutoIdle.test.tsx
index 2fea1eddd..407e7f69c 100644
--- a/src/app/hooks/usePresenceAutoIdle.test.tsx
+++ b/src/app/hooks/usePresenceAutoIdle.test.tsx
@@ -170,6 +170,19 @@ describe('usePresenceAutoIdle', () => {
expect(result.current).toBe(true);
});
+ it('still goes idle after the window loses focus', () => {
+ const { result } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 5000), {
+ wrapper,
+ });
+
+ act(() => {
+ window.dispatchEvent(new Event('blur'));
+ vi.advanceTimersByTime(5000);
+ });
+
+ expect(result.current).toBe(true);
+ });
+
it('clears auto-idle when presenceMode changes away from online', () => {
const { result, rerender } = renderHook(
({ mode }) => useAutoIdledReader(mockMx, mode, true, 5000),
diff --git a/src/app/hooks/usePresenceAutoIdle.ts b/src/app/hooks/usePresenceAutoIdle.ts
index c4f14a008..6dfad4968 100644
--- a/src/app/hooks/usePresenceAutoIdle.ts
+++ b/src/app/hooks/usePresenceAutoIdle.ts
@@ -6,6 +6,7 @@ import { createDebugLogger } from '$utils/debugLogger';
const debugLog = createDebugLogger('PresenceAutoIdle');
const ACTIVITY_EVENTS = ['mousemove', 'mousedown', 'keydown', 'touchstart', 'wheel'] as const;
+const IDLE_CHECK_INTERVAL_MS = 30_000;
/**
* Automatically transitions presence to idle after a configurable inactivity
@@ -28,6 +29,8 @@ export function usePresenceAutoIdle(
const setAutoIdled = useSetAtom(presenceAutoIdledAtom);
const autoIdledRef = useRef(false);
const timerRef = useRef(undefined);
+ const intervalRef = useRef(undefined);
+ const lastActivityAtRef = useRef(Date.now());
const clearTimer = useCallback(() => {
if (timerRef.current !== undefined) {
@@ -36,11 +39,19 @@ export function usePresenceAutoIdle(
}
}, []);
+ const clearIntervalTimer = useCallback(() => {
+ if (intervalRef.current !== undefined) {
+ window.clearInterval(intervalRef.current);
+ intervalRef.current = undefined;
+ }
+ }, []);
+
// Inactivity timer: go idle after timeoutMs without user input.
useEffect(() => {
const shouldAutoIdle = presenceMode === 'online' && sendPresence && timeoutMs > 0;
if (!shouldAutoIdle) {
clearTimer();
+ clearIntervalTimer();
if (autoIdledRef.current) {
autoIdledRef.current = false;
setAutoIdled(false);
@@ -49,19 +60,36 @@ export function usePresenceAutoIdle(
}
const goIdle = () => {
+ if (autoIdledRef.current) return;
debugLog.info('general', 'Inactivity timeout β auto-idling');
autoIdledRef.current = true;
setAutoIdled(true);
};
+ const checkIdleDeadline = () => {
+ const elapsedMs = Date.now() - lastActivityAtRef.current;
+ if (elapsedMs >= timeoutMs) {
+ goIdle();
+ return;
+ }
+ clearTimer();
+ timerRef.current = window.setTimeout(checkIdleDeadline, timeoutMs - elapsedMs);
+ };
+
const handleActivity = () => {
+ lastActivityAtRef.current = Date.now();
clearTimer();
if (autoIdledRef.current) {
debugLog.info('general', 'Activity detected β clearing auto-idle');
autoIdledRef.current = false;
setAutoIdled(false);
}
- timerRef.current = window.setTimeout(goIdle, timeoutMs);
+ timerRef.current = window.setTimeout(checkIdleDeadline, timeoutMs);
+ };
+
+ const handleBlur = () => {
+ debugLog.info('general', 'Window blurred β keeping idle deadline active');
+ checkIdleDeadline();
};
const handleVisibilityChange = () => {
@@ -69,20 +97,28 @@ export function usePresenceAutoIdle(
};
// Start the initial timer.
- timerRef.current = window.setTimeout(goIdle, timeoutMs);
+ lastActivityAtRef.current = Date.now();
+ timerRef.current = window.setTimeout(checkIdleDeadline, timeoutMs);
+ intervalRef.current = window.setInterval(
+ checkIdleDeadline,
+ Math.min(timeoutMs, IDLE_CHECK_INTERVAL_MS)
+ );
ACTIVITY_EVENTS.forEach((ev) =>
document.addEventListener(ev, handleActivity, { passive: true })
);
document.addEventListener('visibilitychange', handleVisibilityChange);
window.addEventListener('focus', handleActivity);
+ window.addEventListener('blur', handleBlur);
return () => {
ACTIVITY_EVENTS.forEach((ev) => document.removeEventListener(ev, handleActivity));
document.removeEventListener('visibilitychange', handleVisibilityChange);
window.removeEventListener('focus', handleActivity);
+ window.removeEventListener('blur', handleBlur);
clearTimer();
+ clearIntervalTimer();
};
- }, [clearTimer, presenceMode, sendPresence, setAutoIdled, timeoutMs]);
+ }, [clearIntervalTimer, clearTimer, presenceMode, sendPresence, setAutoIdled, timeoutMs]);
// Multi-device sync: if another device sets us back to online, clear auto-idle.
useEffect(() => {
From 1e15b4bd74cbf1fbc7c62c9dc5a0ad7caea3c614 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Mon, 13 Apr 2026 02:54:36 -0400
Subject: [PATCH 146/191] fix(notifications): open joined rooms at live
timeline on notification click
---
src/app/hooks/useNotificationJumper.ts | 12 ++++++++----
1 file changed, 8 insertions(+), 4 deletions(-)
diff --git a/src/app/hooks/useNotificationJumper.ts b/src/app/hooks/useNotificationJumper.ts
index 43c358317..1c785d079 100644
--- a/src/app/hooks/useNotificationJumper.ts
+++ b/src/app/hooks/useNotificationJumper.ts
@@ -52,13 +52,17 @@ export function NotificationJumper() {
const isJoined = room?.getMyMembership() === 'join';
if (isSyncing && isJoined) {
- log.log('jumping to:', pending.roomId, pending.eventId);
+ // Always open joined rooms at the live timeline for notification clicks.
+ // Event-scoped navigation can create a sparse historical context where the
+ // room appears to contain only the notification event.
+ const targetEventId = undefined;
+ log.log('jumping to:', pending.roomId, targetEventId);
jumpingRef.current = true;
// Navigate directly to home or direct path β bypasses space routing which
// on mobile shows the space-nav panel first instead of the room timeline.
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, pending.roomId);
if (mDirects.has(pending.roomId)) {
- navigate(getDirectRoomPath(roomIdOrAlias, pending.eventId));
+ navigate(getDirectRoomPath(roomIdOrAlias, targetEventId));
} else {
// If the room lives inside a space, route through the space path so
// SpaceRouteRoomProvider can resolve it β HomeRouteRoomProvider only
@@ -74,11 +78,11 @@ export function NotificationJumper() {
getSpaceRoomPath(
getCanonicalAliasOrRoomId(mx, parentSpace),
roomIdOrAlias,
- pending.eventId
+ targetEventId
)
);
} else {
- navigate(getHomeRoomPath(roomIdOrAlias, pending.eventId));
+ navigate(getHomeRoomPath(roomIdOrAlias, targetEventId));
}
}
setPending(null);
From 164a6b67b23b2ad2cf7bc0f10752b8edc339349d Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Mon, 13 Apr 2026 02:58:37 -0400
Subject: [PATCH 147/191] fix(notifications): prefer live timeline before
event-scoped jump
---
src/app/hooks/useNotificationJumper.ts | 27 +++++++++++++++++++++-----
1 file changed, 22 insertions(+), 5 deletions(-)
diff --git a/src/app/hooks/useNotificationJumper.ts b/src/app/hooks/useNotificationJumper.ts
index 1c785d079..8a9f6aec2 100644
--- a/src/app/hooks/useNotificationJumper.ts
+++ b/src/app/hooks/useNotificationJumper.ts
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef } from 'react';
import { useAtom, useAtomValue } from 'jotai';
import { useNavigate } from 'react-router-dom';
-import { SyncState, ClientEvent } from '$types/matrix-sdk';
+import { SyncState, ClientEvent, RoomEvent, Room, MatrixEvent } from '$types/matrix-sdk';
import { activeSessionIdAtom, pendingNotificationAtom } from '../state/sessions';
import { mDirectAtom } from '../state/mDirectList';
import { useSyncState } from './useSyncState';
@@ -52,10 +52,22 @@ export function NotificationJumper() {
const isJoined = room?.getMyMembership() === 'join';
if (isSyncing && isJoined) {
- // Always open joined rooms at the live timeline for notification clicks.
- // Event-scoped navigation can create a sparse historical context where the
- // room appears to contain only the notification event.
- const targetEventId = undefined;
+ const liveEvents =
+ room?.getUnfilteredTimelineSet?.()?.getLiveTimeline?.()?.getEvents?.() ?? [];
+ const eventInLive = pending.eventId
+ ? liveEvents.some((event) => event.getId() === pending.eventId)
+ : false;
+
+ // If the live timeline is empty the room data is not ready yet.
+ // Defer and retry on RoomEvent.Timeline so we can decide with real data.
+ if (!eventInLive && liveEvents.length === 0) {
+ log.log('live timeline empty, deferring jump...', { roomId: pending.roomId });
+ return;
+ }
+
+ // Keep event targeting when needed, but avoid event-scoped navigation for
+ // events already in the live timeline to prevent sparse historical context.
+ const targetEventId = eventInLive ? undefined : pending.eventId;
log.log('jumping to:', pending.roomId, targetEventId);
jumpingRef.current = true;
// Navigate directly to home or direct path β bypasses space routing which
@@ -121,11 +133,16 @@ export function NotificationJumper() {
if (!pending) return undefined;
const onRoom = () => performJumpRef.current();
+ const onTimeline = (_event: MatrixEvent, eventRoom: Room | undefined) => {
+ if (eventRoom?.roomId === pending.roomId) performJumpRef.current();
+ };
mx.on(ClientEvent.Room, onRoom);
+ mx.on(RoomEvent.Timeline, onTimeline);
performJumpRef.current();
return () => {
mx.removeListener(ClientEvent.Room, onRoom);
+ mx.removeListener(RoomEvent.Timeline, onTimeline);
};
}, [pending, mx]); // performJump intentionally omitted β use ref above
From 143e012b7338e2b45d6ac65694588b0c49d1996e Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Mon, 13 Apr 2026 03:21:33 -0400
Subject: [PATCH 148/191] fix(notifications): defer event-scoped jump until
event appears in live timeline
---
src/app/hooks/useNotificationJumper.ts | 43 ++++++++++++++++++++------
1 file changed, 34 insertions(+), 9 deletions(-)
diff --git a/src/app/hooks/useNotificationJumper.ts b/src/app/hooks/useNotificationJumper.ts
index 8a9f6aec2..90df74293 100644
--- a/src/app/hooks/useNotificationJumper.ts
+++ b/src/app/hooks/useNotificationJumper.ts
@@ -12,6 +12,10 @@ import { getOrphanParents, guessPerfectParent } from '../utils/room';
import { roomToParentsAtom } from '../state/room/roomToParents';
import { createLogger } from '../utils/debug';
+// How long to wait for the notification event to appear in the live timeline
+// before falling back to opening the room at the live bottom.
+const JUMP_TIMEOUT_MS = 15_000;
+
export function NotificationJumper() {
const [pending, setPending] = useAtom(pendingNotificationAtom);
const activeSessionId = useAtomValue(activeSessionIdAtom);
@@ -27,6 +31,9 @@ export function NotificationJumper() {
// churn re-calls performJump (from the ClientEvent.Room listener or effect
// re-runs) before React has committed the null, causing repeated navigation.
const jumpingRef = useRef(false);
+ // Tracks when we first started waiting for the target event to appear in the
+ // live timeline. Reset whenever `pending` changes.
+ const jumpStartTimeRef = useRef(null);
const performJump = useCallback(() => {
if (!pending || jumpingRef.current) return;
@@ -58,16 +65,33 @@ export function NotificationJumper() {
? liveEvents.some((event) => event.getId() === pending.eventId)
: false;
- // If the live timeline is empty the room data is not ready yet.
- // Defer and retry on RoomEvent.Timeline so we can decide with real data.
- if (!eventInLive && liveEvents.length === 0) {
- log.log('live timeline empty, deferring jump...', { roomId: pending.roomId });
- return;
+ // Defer while the target event hasn't arrived in the live timeline yet.
+ // Navigating with an eventId not in the live timeline triggers a sparse
+ // historical context load β the room appears empty or shows only one message.
+ // Retry on each RoomEvent.Timeline until the event appears, then navigate
+ // with the eventId so the room scrolls to and highlights it in full context.
+ // After JUMP_TIMEOUT_MS fall back to opening the room at the live bottom.
+ if (pending.eventId && !eventInLive) {
+ if (jumpStartTimeRef.current === null) {
+ jumpStartTimeRef.current = Date.now();
+ }
+ if (Date.now() - jumpStartTimeRef.current < JUMP_TIMEOUT_MS) {
+ log.log('event not yet in live timeline, deferring jump...', {
+ roomId: pending.roomId,
+ eventId: pending.eventId,
+ });
+ return;
+ }
+ log.log('timed out waiting for event in live; falling back to live bottom', {
+ roomId: pending.roomId,
+ eventId: pending.eventId,
+ });
}
- // Keep event targeting when needed, but avoid event-scoped navigation for
- // events already in the live timeline to prevent sparse historical context.
- const targetEventId = eventInLive ? undefined : pending.eventId;
+ // Pass eventId only when confirmed in the live timeline β scrolls to and
+ // highlights the event in full room context without a sparse historical load.
+ // Falls back to undefined (live bottom) when the event never appears in live.
+ const targetEventId = eventInLive ? pending.eventId : undefined;
log.log('jumping to:', pending.roomId, targetEventId);
jumpingRef.current = true;
// Navigate directly to home or direct path β bypasses space routing which
@@ -108,9 +132,10 @@ export function NotificationJumper() {
}
}, [pending, activeSessionId, mx, mDirects, roomToParents, navigate, setPending, log]);
- // Reset the guard only when pending is replaced (new notification or cleared).
+ // Reset guards only when pending is replaced (new notification or cleared).
useEffect(() => {
jumpingRef.current = false;
+ jumpStartTimeRef.current = null;
}, [pending]);
// Keep a stable ref to the latest performJump so that the listeners below
From 910d13a34bf0beb0f9ac75f93a873f4b0c714a65 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Fri, 17 Apr 2026 23:46:08 -0400
Subject: [PATCH 149/191] fix(notifications): improve notification jump
reliability
- Increase jump timeout from 15s to 30s for slow sync catch-up
- Always pass eventId on navigation (even after timeout) so the
room loads historical context around the notification message
instead of dumping the user at live bottom
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
src/app/hooks/useNotificationJumper.ts | 13 +++++++------
1 file changed, 7 insertions(+), 6 deletions(-)
diff --git a/src/app/hooks/useNotificationJumper.ts b/src/app/hooks/useNotificationJumper.ts
index 90df74293..30d2259b8 100644
--- a/src/app/hooks/useNotificationJumper.ts
+++ b/src/app/hooks/useNotificationJumper.ts
@@ -13,8 +13,8 @@ import { roomToParentsAtom } from '../state/room/roomToParents';
import { createLogger } from '../utils/debug';
// How long to wait for the notification event to appear in the live timeline
-// before falling back to opening the room at the live bottom.
-const JUMP_TIMEOUT_MS = 15_000;
+// before navigating with the eventId anyway (triggers historical context load).
+const JUMP_TIMEOUT_MS = 30_000;
export function NotificationJumper() {
const [pending, setPending] = useAtom(pendingNotificationAtom);
@@ -88,10 +88,11 @@ export function NotificationJumper() {
});
}
- // Pass eventId only when confirmed in the live timeline β scrolls to and
- // highlights the event in full room context without a sparse historical load.
- // Falls back to undefined (live bottom) when the event never appears in live.
- const targetEventId = eventInLive ? pending.eventId : undefined;
+ // Pass eventId when confirmed in the live timeline (best case β scrolls to
+ // and highlights the event in full room context), OR when the timeout fires
+ // (triggers a historical context load so the user at least sees the message
+ // they tapped). Only omit eventId when we never had one in the first place.
+ const targetEventId = pending.eventId ?? undefined;
log.log('jumping to:', pending.roomId, targetEventId);
jumpingRef.current = true;
// Navigate directly to home or direct path β bypasses space routing which
From 67b1a114ca4202f40b0cad639d2c9e0e20d4ee2e Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sat, 18 Apr 2026 22:26:05 -0400
Subject: [PATCH 150/191] fix(notifications): guarantee jump timeout fallback
---
src/app/hooks/useNotificationJumper.test.tsx | 121 +++++++++++++++++++
src/app/hooks/useNotificationJumper.ts | 40 +++++-
2 files changed, 158 insertions(+), 3 deletions(-)
create mode 100644 src/app/hooks/useNotificationJumper.test.tsx
diff --git a/src/app/hooks/useNotificationJumper.test.tsx b/src/app/hooks/useNotificationJumper.test.tsx
new file mode 100644
index 000000000..f1076eaf4
--- /dev/null
+++ b/src/app/hooks/useNotificationJumper.test.tsx
@@ -0,0 +1,121 @@
+import { ReactNode } from 'react';
+import { act, render } from '@testing-library/react';
+import { Provider } from 'jotai';
+import { useHydrateAtoms } from 'jotai/utils';
+import { MemoryRouter } from 'react-router-dom';
+import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
+import { SyncState } from '$types/matrix-sdk';
+import { getHomeRoomPath } from '$pages/pathUtils';
+import { activeSessionIdAtom, pendingNotificationAtom } from '$state/sessions';
+import { mDirectAtom } from '$state/mDirectList';
+import { roomToParentsAtom } from '$state/room/roomToParents';
+import { NotificationJumper } from './useNotificationJumper';
+
+const navigateMock = vi.fn();
+
+const roomTimelineEvents: { getId: () => string }[] = [];
+const roomMock = {
+ roomId: '!room:test',
+ getMyMembership: vi.fn(() => 'join'),
+ getCanonicalAlias: vi.fn(() => undefined),
+ getUnfilteredTimelineSet: vi.fn(() => ({
+ getLiveTimeline: () => ({
+ getEvents: () => roomTimelineEvents,
+ }),
+ })),
+};
+
+const mxMock = {
+ getUserId: vi.fn(() => '@alice:test'),
+ getSyncState: vi.fn(() => SyncState.Syncing),
+ getRoom: vi.fn(() => roomMock),
+ getRooms: vi.fn(() => []),
+ on: vi.fn(),
+ removeListener: vi.fn(),
+};
+
+vi.mock('react-router-dom', async () => {
+ const actual = await vi.importActual('react-router-dom');
+ return {
+ ...actual,
+ useNavigate: () => navigateMock,
+ };
+});
+
+vi.mock('./useMatrixClient', () => ({
+ useMatrixClient: () => mxMock,
+}));
+
+vi.mock('./useSyncState', () => ({
+ useSyncState: vi.fn(),
+}));
+
+vi.mock('../utils/debug', () => ({
+ createLogger: () => ({
+ log: vi.fn(),
+ }),
+}));
+
+type WrapperProps = {
+ children: ReactNode;
+};
+
+function HydrateAtoms({ children }: WrapperProps) {
+ useHydrateAtoms([
+ [activeSessionIdAtom, '@alice:test'],
+ [pendingNotificationAtom, { roomId: '!room:test', eventId: '$event:test' }],
+ [mDirectAtom, new Set()],
+ [roomToParentsAtom, new Map()],
+ ]);
+
+ return <>{children}>;
+}
+
+function HydratedWrapper({ children }: WrapperProps) {
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+describe('NotificationJumper', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ navigateMock.mockReset();
+ roomTimelineEvents.length = 0;
+ roomMock.getMyMembership.mockReturnValue('join');
+ mxMock.getUserId.mockReturnValue('@alice:test');
+ mxMock.getSyncState.mockReturnValue(SyncState.Syncing);
+ mxMock.getRoom.mockReturnValue(roomMock);
+ mxMock.getRooms.mockReturnValue([]);
+ mxMock.on.mockClear();
+ mxMock.removeListener.mockClear();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('navigates immediately when the target event is already in the live timeline', () => {
+ roomTimelineEvents.push({ getId: () => '$event:test' });
+
+ render(, { wrapper: HydratedWrapper });
+
+ expect(navigateMock).toHaveBeenCalledWith(getHomeRoomPath('!room:test', '$event:test'));
+ });
+
+ it('falls back after the timeout even if no further room events arrive', () => {
+ render(, { wrapper: HydratedWrapper });
+
+ expect(navigateMock).not.toHaveBeenCalled();
+
+ act(() => {
+ vi.advanceTimersByTime(30_000);
+ });
+
+ expect(navigateMock).toHaveBeenCalledWith(getHomeRoomPath('!room:test', '$event:test'));
+ });
+});
diff --git a/src/app/hooks/useNotificationJumper.ts b/src/app/hooks/useNotificationJumper.ts
index 30d2259b8..71c376982 100644
--- a/src/app/hooks/useNotificationJumper.ts
+++ b/src/app/hooks/useNotificationJumper.ts
@@ -34,6 +34,14 @@ export function NotificationJumper() {
// Tracks when we first started waiting for the target event to appear in the
// live timeline. Reset whenever `pending` changes.
const jumpStartTimeRef = useRef(null);
+ const jumpTimeoutRef = useRef | undefined>(undefined);
+
+ const clearJumpTimeout = useCallback(() => {
+ if (jumpTimeoutRef.current !== undefined) {
+ clearTimeout(jumpTimeoutRef.current);
+ jumpTimeoutRef.current = undefined;
+ }
+ }, []);
const performJump = useCallback(() => {
if (!pending || jumpingRef.current) return;
@@ -75,7 +83,14 @@ export function NotificationJumper() {
if (jumpStartTimeRef.current === null) {
jumpStartTimeRef.current = Date.now();
}
- if (Date.now() - jumpStartTimeRef.current < JUMP_TIMEOUT_MS) {
+ const elapsedMs = Date.now() - jumpStartTimeRef.current;
+ if (elapsedMs < JUMP_TIMEOUT_MS) {
+ if (jumpTimeoutRef.current === undefined) {
+ jumpTimeoutRef.current = setTimeout(() => {
+ jumpTimeoutRef.current = undefined;
+ performJumpRef.current();
+ }, JUMP_TIMEOUT_MS - elapsedMs);
+ }
log.log('event not yet in live timeline, deferring jump...', {
roomId: pending.roomId,
eventId: pending.eventId,
@@ -95,6 +110,7 @@ export function NotificationJumper() {
const targetEventId = pending.eventId ?? undefined;
log.log('jumping to:', pending.roomId, targetEventId);
jumpingRef.current = true;
+ clearJumpTimeout();
// Navigate directly to home or direct path β bypasses space routing which
// on mobile shows the space-nav panel first instead of the room timeline.
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, pending.roomId);
@@ -131,13 +147,24 @@ export function NotificationJumper() {
membership: room?.getMyMembership(),
});
}
- }, [pending, activeSessionId, mx, mDirects, roomToParents, navigate, setPending, log]);
+ }, [
+ pending,
+ activeSessionId,
+ mx,
+ mDirects,
+ roomToParents,
+ navigate,
+ setPending,
+ log,
+ clearJumpTimeout,
+ ]);
// Reset guards only when pending is replaced (new notification or cleared).
useEffect(() => {
+ clearJumpTimeout();
jumpingRef.current = false;
jumpStartTimeRef.current = null;
- }, [pending]);
+ }, [pending, clearJumpTimeout]);
// Keep a stable ref to the latest performJump so that the listeners below
// always invoke the current version without adding performJump to their dep
@@ -172,5 +199,12 @@ export function NotificationJumper() {
};
}, [pending, mx]); // performJump intentionally omitted β use ref above
+ useEffect(
+ () => () => {
+ clearJumpTimeout();
+ },
+ [clearJumpTimeout]
+ );
+
return null;
}
From a7f8c1aedae88cf41e943c5524d77c3f163a86cc Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Wed, 15 Apr 2026 18:32:27 -0400
Subject: [PATCH 151/191] fix(sw): reuse preloaded session in
handleMinimalPushPayload
onPushNotification already fetches the persisted session and stores it in
preloadedSession. Thread that through handleMinimalPushPayload's fallback
chain so we skip the second cache.match() call on iOS restarts where the
in-memory sessions Map is empty.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
src/sw.ts | 16 +++++++++++++---
1 file changed, 13 insertions(+), 3 deletions(-)
diff --git a/src/sw.ts b/src/sw.ts
index d843963bd..e341e03f3 100644
--- a/src/sw.ts
+++ b/src/sw.ts
@@ -412,9 +412,19 @@ async function handleMinimalPushPayload(
windowClients: readonly Client[]
): Promise {
// On iOS the SW is killed and restarted for every push, clearing the in-memory sessions
- // Map. Fall back to the Cache Storage copy that was written when the user last opened
- // the app (same pattern as settings persistence).
- const session = getAnyStoredSession() ?? (await loadPersistedSession());
+ // Map. Fall back to the Cache Storage copy that was written when the user last opened
+ // the app (same pattern as settings persistence). If onPushNotification already loaded
+ // the persisted session into preloadedSession, reuse it to avoid a second cache read.
+ // Last resort: if neither the in-memory map nor the cache has a session, ask any live
+ // window client for a fresh token (the app may be backgrounded but still alive in memory).
+ let session = getAnyStoredSession() ?? preloadedSession ?? (await loadPersistedSession());
+ if (!session && windowClients.length > 0) {
+ console.debug('[SW push] no cached session, requesting from window clients');
+ const results = await Promise.all(
+ Array.from(windowClients).map((c) => requestSessionWithTimeout(c.id, 1500))
+ );
+ session = results.find((r) => r != null) ?? undefined;
+ }
if (!session) {
// No session anywhere β app was never opened since install, or the user logged out.
From ce68c59e7aefcb2105c8e8803c20010c13749f24 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Wed, 15 Apr 2026 13:35:58 -0400
Subject: [PATCH 152/191] fix(sw): improve push notification reliability and
encrypted room handling - Use getEffectiveEvent()?.type for decrypted event
type in BackgroundNotifications - Fix isEncryptedRoom flag in
pushNotification.ts (was hardcoded false) - Add isEncryptedRoom: true to
relay payload when decryption succeeds - Wrap push handlers in try/catch with
fallback notifications (prevents silent drops on iOS) - Parallelize
requestDecryptionFromClient with Promise.any + shared timeout (was
sequential)
---
.../pages/client/BackgroundNotifications.tsx | 7 +-
src/sw.ts | 92 +++++++++++++------
src/sw/pushNotification.ts | 2 +-
3 files changed, 70 insertions(+), 31 deletions(-)
diff --git a/src/app/pages/client/BackgroundNotifications.tsx b/src/app/pages/client/BackgroundNotifications.tsx
index 395718223..6b712bb50 100644
--- a/src/app/pages/client/BackgroundNotifications.tsx
+++ b/src/app/pages/client/BackgroundNotifications.tsx
@@ -414,6 +414,11 @@ export function BackgroundNotifications() {
const isEncryptedRoom = !!getStateEvent(room, StateEvent.RoomEncryption);
+ // After decryption, getType() still returns the wire type (m.room.encrypted).
+ // Use the effective event type to get the decrypted type when available.
+ const effectiveEventType =
+ (mEvent.getEffectiveEvent()?.type as string) ?? mEvent.getType();
+
notifiedEventsRef.current.add(dedupeId);
// Cap the set so it doesn't grow unbounded
if (notifiedEventsRef.current.size > 200) {
@@ -428,7 +433,7 @@ export function BackgroundNotifications() {
recipientId: session.userId,
previewText: resolveNotificationPreviewText({
content: mEvent.getContent(),
- eventType: mEvent.getType(),
+ eventType: effectiveEventType,
isEncryptedRoom,
showMessageContent: showMessageContentRef.current,
showEncryptedMessageContent: showEncryptedMessageContentRef.current,
diff --git a/src/sw.ts b/src/sw.ts
index e341e03f3..a4724632a 100644
--- a/src/sw.ts
+++ b/src/sw.ts
@@ -369,36 +369,40 @@ async function requestDecryptionFromClient(
): Promise {
const eventId = rawEvent.event_id as string;
- // Chain clients sequentially using reduce to avoid await-in-loop and for-of.
- return Array.from(windowClients).reduce(
- async (prevPromise, client) => {
- const prev = await prevPromise;
- if (prev?.success) return prev;
-
- const promise = new Promise((resolve) => {
- decryptionPendingMap.set(eventId, resolve);
- });
+ // Try all window clients in parallel with a single shared timeout.
+ // This avoids the worst case of N Γ 5s sequential timeouts when multiple
+ // tabs are frozen (common on iOS).
+ const clientAttempts = Array.from(windowClients).map((client) => {
+ const promise = new Promise((resolve) => {
+ decryptionPendingMap.set(eventId, resolve);
+ });
- const timeout = new Promise((resolve) => {
- setTimeout(() => {
- decryptionPendingMap.delete(eventId);
- console.warn('[SW decryptRelay] timed out waiting for client', client.id);
- resolve(undefined);
- }, 5000);
- });
+ try {
+ (client as WindowClient).postMessage({ type: 'decryptPushEvent', rawEvent });
+ } catch (err) {
+ decryptionPendingMap.delete(eventId);
+ console.warn('[SW decryptRelay] postMessage error', err);
+ return Promise.resolve(undefined as DecryptionResult | undefined);
+ }
- try {
- (client as WindowClient).postMessage({ type: 'decryptPushEvent', rawEvent });
- } catch (err) {
- decryptionPendingMap.delete(eventId);
- console.warn('[SW decryptRelay] postMessage error', err);
- return undefined;
- }
+ return promise as Promise;
+ });
- return Promise.race([promise, timeout]);
- },
- Promise.resolve(undefined) as Promise
- );
+ if (clientAttempts.length === 0) return undefined;
+
+ const timeout = new Promise((resolve) => {
+ setTimeout(() => {
+ decryptionPendingMap.delete(eventId);
+ console.warn('[SW decryptRelay] timed out waiting for all clients');
+ resolve(undefined);
+ }, 5000);
+ });
+
+ // Return as soon as any client succeeds or the shared timeout fires.
+ return Promise.race([
+ Promise.any(clientAttempts).catch(() => undefined),
+ timeout,
+ ]);
}
/**
@@ -505,6 +509,7 @@ async function handleMinimalPushPayload(
// Prefer relay's room name (has m.direct / computed SDK name); fall back to state fetch.
room_name: result.room_name || resolvedRoomName,
room_avatar_url: notificationAvatarUrl,
+ isEncryptedRoom: true,
});
} else {
// App is frozen or fully closed β show "Encrypted message" fallback.
@@ -804,11 +809,40 @@ const onPushNotification = async (event: PushEvent) => {
// to relay decryption to an open app tab.
if (isMinimalPushPayload(pushData)) {
console.debug('[SW push] minimal payload detected β fetching event', pushData.event_id);
- await handleMinimalPushPayload(pushData.room_id, pushData.event_id, clients);
+ try {
+ await handleMinimalPushPayload(pushData.room_id, pushData.event_id, clients);
+ } catch (err) {
+ console.error('[SW push] handleMinimalPushPayload failed:', err);
+ // Show a generic fallback so the user still sees something on iOS.
+ await self.registration.showNotification('New Message', {
+ body: undefined,
+ icon: '/public/res/logo-maskable/cinny-logo-maskable-180x180.png',
+ badge: '/public/res/logo-maskable/cinny-logo-maskable-72x72.png',
+ tag: `room-${pushData.room_id}`,
+ renotify: true,
+ data: { room_id: pushData.room_id, event_id: pushData.event_id },
+ } as NotificationOptions);
+ }
return;
}
- await handlePushNotificationPushData(pushData);
+ try {
+ await handlePushNotificationPushData(pushData);
+ } catch (err) {
+ console.error('[SW push] handlePushNotificationPushData failed:', err);
+ await self.registration.showNotification('New Message', {
+ body: undefined,
+ icon: '/public/res/logo-maskable/cinny-logo-maskable-180x180.png',
+ badge: '/public/res/logo-maskable/cinny-logo-maskable-72x72.png',
+ tag: pushData.room_id ? `room-${pushData.room_id}` : (pushData.event_id ?? 'Cinny'),
+ renotify: true,
+ data: {
+ room_id: pushData.room_id,
+ event_id: pushData.event_id,
+ user_id: pushData.user_id,
+ },
+ } as NotificationOptions);
+ }
};
// ---------------------------------------------------------------------------
diff --git a/src/sw/pushNotification.ts b/src/sw/pushNotification.ts
index 1152d3d44..73bc1a495 100644
--- a/src/sw/pushNotification.ts
+++ b/src/sw/pushNotification.ts
@@ -88,7 +88,7 @@ export const createPushNotifications = (
previewText: resolveNotificationPreviewText({
content: pushData?.content,
eventType: pushData?.type,
- isEncryptedRoom: false,
+ isEncryptedRoom: pushData?.isEncryptedRoom === true,
showMessageContent: getNotificationSettings().showMessageContent,
showEncryptedMessageContent: getNotificationSettings().showEncryptedMessageContent,
}),
From 84fadae476bc4410b1486526ca47d9d597c0df01 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sun, 19 Apr 2026 13:38:15 -0400
Subject: [PATCH 153/191] fix(sw): add media and notification diagnostics
---
src/sw.ts | 93 ++++++++++++++++++++++++++++++++++++++
src/sw/pushNotification.ts | 10 ++++
2 files changed, 103 insertions(+)
diff --git a/src/sw.ts b/src/sw.ts
index d843963bd..fba4568ce 100644
--- a/src/sw.ts
+++ b/src/sw.ts
@@ -637,6 +637,99 @@ function fetchConfig(token: string): RequestInit {
};
}
+/**
+ * Fetch a media URL, retrying once with the most-current in-memory session on 401.
+ *
+ * There is a timing window between when the SDK refreshes its access token
+ * (tokenRefreshFunction resolves) and when the resulting pushSessionToSW()
+ * postMessage is processed by the SW. Media requests that land in this window
+ * are sent with the stale token and receive 401. By the time the retry runs,
+ * the setSession message will normally have been processed and sessions will
+ * hold the new token.
+ *
+ * A second timing window exists at startup: preloadedSession may hold a stale
+ * token but the live setSession from the page hasn't arrived yet. In that case
+ * the in-memory check yields no fresher token, so we ask the live client tab
+ * directly (requestSessionWithTimeout) before giving up.
+ */
+async function fetchMediaWithRetry(
+ url: string,
+ token: string,
+ redirect: RequestRedirect,
+ clientId: string
+): Promise {
+ let response = await fetch(url, { ...fetchConfig(token), redirect });
+ if (!isAuthFailureStatus(response.status)) return response;
+
+ console.warn('[SW media] Initial authenticated fetch failed', {
+ url,
+ status: response.status,
+ clientId,
+ hasClientBoundSession: !!(clientId && sessions.get(clientId)),
+ hasPreloadedSession: !!preloadedSession,
+ });
+
+ const attemptedTokens = new Set([token]);
+ const retrySessions: Array<{ session: SessionInfo; source: string }> = [];
+ const seenSessions = new Set();
+
+ const addRetrySession = (session: SessionInfo | undefined, source: string) => {
+ if (!session || !validMediaRequest(url, session.baseUrl)) return;
+ const key = `${session.baseUrl}\x00${session.accessToken}`;
+ if (seenSessions.has(key)) return;
+ seenSessions.add(key);
+ retrySessions.push({ session, source });
+ };
+
+ if (clientId) addRetrySession(sessions.get(clientId), 'client_session');
+ getMatchingSessions(url).forEach((session, index) =>
+ addRetrySession(session, `matching_session_${index}`)
+ );
+ addRetrySession(preloadedSession, 'preloaded_session');
+ addRetrySession(await loadPersistedSession(), 'persisted_session');
+ (await getLiveWindowSessions(url, clientId)).forEach((session, index) =>
+ addRetrySession(session, `live_window_${index}`)
+ );
+
+ console.debug('[SW media] Retry candidates collected', {
+ url,
+ candidateSources: retrySessions.map(({ source }) => source),
+ candidateCount: retrySessions.length,
+ });
+
+ // Try each plausible token once. This handles token-refresh races and ambiguous
+ // multi-account sessions on the same homeserver, including no-clientId requests.
+ // Sequential await is intentional: we want to try one token at a time until one succeeds.
+ /* eslint-disable no-await-in-loop */
+ for (let i = 0; i < retrySessions.length; i += 1) {
+ const candidate = retrySessions[i];
+ if (!candidate || attemptedTokens.has(candidate.session.accessToken)) {
+ // skip this candidate
+ } else {
+ attemptedTokens.add(candidate.session.accessToken);
+ console.debug('[SW media] Retrying with alternate session', {
+ url,
+ source: candidate.source,
+ attempt: i + 1,
+ });
+ response = await fetch(url, { ...fetchConfig(candidate.session.accessToken), redirect });
+ if (!isAuthFailureStatus(response.status)) return response;
+ console.warn('[SW media] Alternate session also failed auth', {
+ url,
+ source: candidate.source,
+ status: response.status,
+ });
+ }
+ }
+ /* eslint-enable no-await-in-loop */
+
+ console.warn('[SW media] Exhausted authenticated retry candidates', {
+ url,
+ finalStatus: response.status,
+ attemptedTokenCount: attemptedTokens.size,
+ });
+ return response;
+}
self.addEventListener('message', (event: ExtendableMessageEvent) => {
if (event.data.type === 'togglePush') {
const token = event.data?.token;
diff --git a/src/sw/pushNotification.ts b/src/sw/pushNotification.ts
index 1152d3d44..d5572094f 100644
--- a/src/sw/pushNotification.ts
+++ b/src/sw/pushNotification.ts
@@ -45,7 +45,17 @@ export const createPushNotifications = (
silent,
data,
};
+ const existingNotifications = await self.registration.getNotifications();
+ const replacedCount = existingNotifications.filter(
+ (notification) => notification.tag === tag
+ ).length;
console.debug('[SW showNotification] title:', title, '| data:', JSON.stringify(data, null, 2));
+ console.debug('[SW showNotification] tag diagnostics:', {
+ tag,
+ roomId,
+ renotify,
+ replacedCount,
+ });
await self.registration.showNotification(title, notifOptions as NotificationOptions);
};
From c4ca608e4ce5f7cc69ebf643fa34e98b669415d9 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sun, 19 Apr 2026 13:38:16 -0400
Subject: [PATCH 154/191] fix(timeline): stabilize bottom pin and unread
fallback
---
src/app/features/room/RoomTimeline.tsx | 55 +++++++++++++++++++++++++-
src/app/utils/room.ts | 50 -----------------------
2 files changed, 54 insertions(+), 51 deletions(-)
diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx
index d026ec1fa..887dc39f3 100644
--- a/src/app/features/room/RoomTimeline.tsx
+++ b/src/app/features/room/RoomTimeline.tsx
@@ -108,6 +108,20 @@ const getDayDividerText = (ts: number) => {
return timeDayMonthYear(ts);
};
+const SCROLL_SETTLE_MS = 250;
+
+const TIMELINE_ANCHOR_SELECTOR = '[data-timeline-event-id]';
+const buildRoomScrollFingerprint = (
+ eventIds: string[],
+ readUptoEventId: string | undefined,
+ layoutKey: string
+): RoomScrollFingerprint => ({
+ eventCount: eventIds.length,
+ headEventIds: eventIds.slice(0, 5),
+ tailEventIds: eventIds.slice(-5),
+ readUptoEventId,
+ layoutKey,
+});
export type RoomTimelineProps = {
room: Room;
eventId?: string;
@@ -214,6 +228,7 @@ export function RoomTimeline({
const topSpacerHeightRef = useRef(0);
const mountScrollWindowRef = useRef(Date.now() + 3000);
const hasInitialScrolledRef = useRef(false);
+ const lastProgrammaticBottomPinAtRef = useRef(0);
// Stored in a ref so eventsLength fluctuations (e.g. onLifecycle timeline reset
// firing within the window) cannot cancel it via useLayoutEffect cleanup.
const initialScrollTimerRef = useRef | undefined>(undefined);
@@ -245,8 +260,10 @@ export function RoomTimeline({
if (!vListRef.current) return;
const lastIndex = processedEventsRef.current.length - 1;
if (lastIndex < 0) return;
+ lastProgrammaticBottomPinAtRef.current = Date.now();
+ setAtBottom(true);
vListRef.current.scrollTo(vListRef.current.scrollSize);
- }, []);
+ }, [setAtBottom]);
const timelineSync = useTimelineSync({
room,
@@ -631,6 +648,42 @@ export function RoomTimeline({
const distanceFromBottom = v.scrollSize - offset - v.viewportSize;
const isNowAtBottom = distanceFromBottom < 100;
+ const withinSettleWindow =
+ Date.now() - lastProgrammaticBottomPinAtRef.current < SCROLL_SETTLE_MS;
+
+ // When the user is pinned to the bottom and content grows (images, embeds,
+ // video thumbnails loading), scrollSize increases while offset stays put,
+ // pushing distanceFromBottom above the threshold. Instead of flipping
+ // atBottom to false (which shows the "Jump to Latest" button), chase the
+ // bottom so the user stays pinned.
+ const contentGrew = v.scrollSize > prevScrollSizeRef.current;
+ prevScrollSizeRef.current = v.scrollSize;
+
+ // Skip content-chase and cache saves during init: the timeline is hidden
+ // (opacity 0) while VList measures items and fires intermediate scroll
+ // events. Chasing the bottom here causes cascading scrollTo calls that
+ // upstream doesn't have, producing visible layout churn after isReady.
+ if (!isReadyRef.current) return;
+
+ // While a jump is in progress (focusItem set), VList fires scroll events
+ // from scrollToIndex that can incorrectly flip atBottom=true β especially
+ // if the target happens to be near the end. Ignore scroll-position
+ // updates until the jump transition finishes and focusItem is cleared.
+ if (timelineSyncRef.current.focusItem) return;
+
+ if (atBottomRef.current && !isNowAtBottom && (contentGrew || withinSettleWindow)) {
+ // Defer the chase to the next animation frame so VList finishes its
+ // current layout pass. Synchronous scrollTo causes cascading scroll
+ // events that produce visible jumps when images/embeds load.
+ requestAnimationFrame(() => {
+ const vl = vListRef.current;
+ if (vl && atBottomRef.current) {
+ lastProgrammaticBottomPinAtRef.current = Date.now();
+ vl.scrollTo(vl.scrollSize);
+ }
+ });
+ return;
+ }
if (isNowAtBottom !== atBottomRef.current) {
setAtBottom(isNowAtBottom);
}
diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts
index 9391dbc90..265670649 100644
--- a/src/app/utils/room.ts
+++ b/src/app/utils/room.ts
@@ -12,7 +12,6 @@ import {
MatrixClient,
MatrixEvent,
NotificationCountType,
- PushProcessor,
RelationType,
Room,
RoomMember,
@@ -332,55 +331,6 @@ export const getUnreadInfo = (room: Room, options?: UnreadInfoOptions): UnreadIn
}
}
- // Fallback: SDK counters are stale/zero but there are receipt-confirmed unread
- // messages. Walk the live timeline to compute real counts so the badge number
- // and highlight colour reflect actual state rather than a hard-coded stub.
- if (total === 0 && highlight === 0 && userId && roomHaveUnread(room.client, room)) {
- const readUpToId = room.getEventReadUpTo(userId);
- const liveEvents = room.getLiveTimeline().getEvents();
- let fallbackTotal = 0;
- let fallbackHighlight = 0;
- const pushProcessor = new PushProcessor(room.client);
- for (let i = liveEvents.length - 1; i >= 0; i -= 1) {
- const event = liveEvents[i];
- if (!event) break;
- if (event.getId() === readUpToId) break;
- if (isNotificationEvent(event, room, userId) && event.getSender() !== userId) {
- fallbackTotal += 1;
- const pushActions = pushProcessor.actionsForEvent(event);
- if (pushActions?.tweaks?.highlight) fallbackHighlight += 1;
- }
- }
- if (fallbackTotal > 0) {
- return { roomId: room.roomId, highlight: fallbackHighlight, total: fallbackTotal };
- }
- }
-
- // Sliding sync limitation: unvisited rooms don't have read receipt data, but may have
- // timeline activity. Check for notification events from others in the timeline to show a
- // badge even when SDK counts are 0 (or unreliable without receipts).
- if (userId) {
- const readUpToId = room.getEventReadUpTo(userId);
-
- // If we have no read receipt, SDK counts may be unreliable. Always check timeline.
- if (!readUpToId) {
- const liveEvents = room.getLiveTimeline().getEvents();
-
- const hasActivity = liveEvents.some(
- (event) => event.getSender() !== userId && isNotificationEvent(event, room, userId)
- );
-
- if (hasActivity) {
- // If SDK already has counts, use those. Otherwise show dot badge (count=1).
- if (total === 0 && highlight === 0) {
- return { roomId: room.roomId, highlight: 0, total: 1 };
- }
- // SDK has counts but no receipt - trust the counts and show them
- return { roomId: room.roomId, highlight, total };
- }
- }
- }
-
// For DMs with Default or AllMessages notification type: if there are unread messages,
// ensure we show a notification badge (treat as highlight for badge color purposes).
// This handles cases where push rules don't properly match (e.g., classic sync with
From 157df8180df8b78be3b25bd011f2eab83dd15c76 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sun, 19 Apr 2026 14:03:29 -0400
Subject: [PATCH 155/191] fix(timeline): align initial room-fill thresholds
---
src/app/features/room/RoomTimeline.tsx | 47 ++++++++++++++++++++++----
1 file changed, 40 insertions(+), 7 deletions(-)
diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx
index 887dc39f3..41ea85e16 100644
--- a/src/app/features/room/RoomTimeline.tsx
+++ b/src/app/features/room/RoomTimeline.tsx
@@ -109,6 +109,8 @@ const getDayDividerText = (ts: number) => {
};
const SCROLL_SETTLE_MS = 250;
+const MIN_INITIAL_SCROLL_ROOM_PX = 300;
+const MIN_INITIAL_SCROLL_ROOM_PX = 300;
const TIMELINE_ANCHOR_SELECTOR = '[data-timeline-event-id]';
const buildRoomScrollFingerprint = (
@@ -322,11 +324,22 @@ export function RoomTimeline({
initialScrollTimerRef.current = setTimeout(() => {
initialScrollTimerRef.current = undefined;
if (processedEventsRef.current.length > 0) {
- vListRef.current?.scrollToIndex(processedEventsRef.current.length - 1, { align: 'end' });
- // Only mark ready once we've successfully scrolled. If processedEvents
- // was empty when the timer fired (e.g. the onLifecycle reset cleared the
- // timeline within the 80 ms window), defer setIsReady until the recovery
- // effect below fires once events repopulate.
+ vListRef.current?.scrollToIndex(processedEventsRef.current.length - 1, {
+ align: 'end',
+ });
+ const v = vListRef.current;
+ // If backward pagination can still fill the viewport, delay revealing
+ // until that pagination settles so the user never sees the 3β60 event jump.
+ const needsFill =
+ canPaginateBackRef.current &&
+ v &&
+ v.scrollSize <= v.viewportSize + MIN_INITIAL_SCROLL_ROOM_PX &&
+ backwardStatusRef.current !== 'error';
+ if (needsFill) {
+ readyBlockedByPaginationRef.current = true;
+ return;
+ }
+ saveRoomScrollStateRef.current?.(v?.cache, true);
setIsReady(true);
} else {
pendingReadyRef.current = true;
@@ -356,7 +369,27 @@ export function RoomTimeline({
if (timelineSync.eventsLength > 0) return;
setIsReady(false);
hasInitialScrolledRef.current = false;
- }, [isReady, timelineSync.eventsLength]);
+ }, [isReady, timelineSync.eventsLength, room]);
+
+ // Reveal the timeline once backward pagination has settled and the viewport is
+ // filled. This handles the case where the 80 ms timer fired before sliding sync
+ // had delivered enough events to fill the screen.
+ useLayoutEffect(() => {
+ if (!readyBlockedByPaginationRef.current) return;
+ if (timelineSync.backwardStatus === 'loading') return;
+ const v = vListRef.current;
+ if (!v) return;
+ // Still not filled and can paginate more β keep waiting.
+ if (
+ canPaginateBackRef.current &&
+ v.scrollSize <= v.viewportSize + MIN_INITIAL_SCROLL_ROOM_PX
+ )
+ return;
+ readyBlockedByPaginationRef.current = false;
+ v.scrollToIndex(processedEventsRef.current.length - 1, { align: 'end' });
+ saveRoomScrollStateRef.current?.(v.cache, true);
+ setIsReady(true);
+ }, [timelineSync.backwardStatus, timelineSync.eventsLength, timelineSync.canPaginateBack]);
const recalcTopSpacer = useCallback(() => {
const v = vListRef.current;
@@ -861,7 +894,7 @@ export function RoomTimeline({
const atTop = v.scrollOffset < 500;
const noVisibleGrowth = processedEvents.length === processedLengthAtEffectStart;
- const hasRealScrollRoom = v.scrollSize > v.viewportSize + 300;
+ const hasRealScrollRoom = v.scrollSize > v.viewportSize + MIN_INITIAL_SCROLL_ROOM_PX;
if (!hasRealScrollRoom || (atTop && noVisibleGrowth)) {
timelineSyncRef.current.handleTimelinePagination(true);
From be5bcd2ac6da3da09817378ecd0944ce94af8c20 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sat, 18 Apr 2026 23:31:56 -0400
Subject: [PATCH 156/191] fix(timeline): align reset relinking with upstream
---
src/app/hooks/timeline/useTimelineSync.test.tsx | 3 +--
src/app/hooks/timeline/useTimelineSync.ts | 4 ++--
2 files changed, 3 insertions(+), 4 deletions(-)
diff --git a/src/app/hooks/timeline/useTimelineSync.test.tsx b/src/app/hooks/timeline/useTimelineSync.test.tsx
index d53d74143..46c445767 100644
--- a/src/app/hooks/timeline/useTimelineSync.test.tsx
+++ b/src/app/hooks/timeline/useTimelineSync.test.tsx
@@ -123,13 +123,12 @@ describe('useTimelineSync', () => {
readUptoEventIdRef: { current: undefined },
})
);
-
await act(async () => {
timelineSet.emit(RoomEvent.TimelineReset);
await Promise.resolve();
});
- expect(scrollToBottom).toHaveBeenCalledWith('instant');
+ expect(scrollToBottom).toHaveBeenCalled();
});
it('resets timeline state when room.roomId changes and eventId is not set', async () => {
diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts
index 51c85dda8..4313e82ad 100644
--- a/src/app/hooks/timeline/useTimelineSync.ts
+++ b/src/app/hooks/timeline/useTimelineSync.ts
@@ -528,7 +528,7 @@ export function useTimelineSync({
resetAutoScrollPendingRef.current = wasAtBottom;
setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines });
if (wasAtBottom) {
- scrollToBottom('instant');
+ scrollToBottom();
}
}, [room, isAtBottomRef, scrollToBottom])
);
@@ -565,7 +565,7 @@ export function useTimelineSync({
if (eventsLength <= lastScrolledAtEventsLengthRef.current && !resetAutoScrollPending) return;
lastScrolledAtEventsLengthRef.current = eventsLength;
- scrollToBottom('instant');
+ scrollToBottom();
}, [isAtBottom, liveTimelineLinked, eventsLength, scrollToBottom]);
useEffect(() => {
From 1fc16aeb1fe8099030bb81e0cc7b094831d80d38 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Fri, 17 Apr 2026 18:48:03 -0400
Subject: [PATCH 157/191] fix(notifications): skip in-app notification for
active room
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add activeRoomIdAtom (synced from all RoomProviders) so
BackgroundNotifications can detect when the user is already viewing the
notification room. When the room matches and the window is focused, the
background handler now returns early β no banner, no OS notification.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../pages/client/BackgroundNotifications.tsx | 15 +++++++++++++++
src/app/pages/client/direct/RoomProvider.tsx | 3 +++
src/app/pages/client/home/RoomProvider.tsx | 3 +++
src/app/pages/client/space/RoomProvider.tsx | 3 +++
src/app/state/room/activeRoomId.ts | 19 +++++++++++++++++++
5 files changed, 43 insertions(+)
create mode 100644 src/app/state/room/activeRoomId.ts
diff --git a/src/app/pages/client/BackgroundNotifications.tsx b/src/app/pages/client/BackgroundNotifications.tsx
index 395718223..d1099cbf3 100644
--- a/src/app/pages/client/BackgroundNotifications.tsx
+++ b/src/app/pages/client/BackgroundNotifications.tsx
@@ -35,6 +35,7 @@ import { createLogger } from '$utils/debug';
import { createDebugLogger } from '$utils/debugLogger';
import LogoSVG from '$public/res/svg/cinny-logo.svg';
import { nicknamesAtom } from '$state/nicknames';
+import { activeRoomIdAtom } from '$state/room/activeRoomId';
import {
buildRoomMessageNotification,
resolveNotificationPreviewText,
@@ -110,8 +111,11 @@ export function BackgroundNotifications() {
);
const shouldRunBackgroundNotifications = showNotifications || usePushNotifications;
const nicknames = useAtomValue(nicknamesAtom);
+ const activeRoomId = useAtomValue(activeRoomIdAtom);
const nicknamesRef = useRef(nicknames);
nicknamesRef.current = nicknames;
+ const activeRoomIdRef = useRef(activeRoomId);
+ activeRoomIdRef.current = activeRoomId;
// Refs so handleTimeline callbacks always read current settings without stale closures
const showNotificationsRef = useRef(showNotifications);
showNotificationsRef.current = showNotifications;
@@ -451,6 +455,17 @@ export function BackgroundNotifications() {
setPending({ roomId: room.roomId, eventId, targetSessionId: session.userId });
};
+ // Skip notifications entirely when the active session is viewing
+ // this exact room and the window has focus β the user is already
+ // looking at the messages.
+ if (room.roomId === activeRoomIdRef.current && document.hasFocus()) {
+ debugLog.debug('notification', 'Skipping notification β room is active', {
+ roomId: room.roomId,
+ eventId,
+ });
+ return;
+ }
+
// Show in-app banner when app is visible, mobile, and in-app notifications enabled
const canShowInAppBanner =
document.visibilityState === 'visible' &&
diff --git a/src/app/pages/client/direct/RoomProvider.tsx b/src/app/pages/client/direct/RoomProvider.tsx
index 1780fa728..a7a5eac0d 100644
--- a/src/app/pages/client/direct/RoomProvider.tsx
+++ b/src/app/pages/client/direct/RoomProvider.tsx
@@ -4,6 +4,7 @@ import { useSelectedRoom } from '$hooks/router/useSelectedRoom';
import { IsDirectRoomProvider, RoomProvider } from '$hooks/useRoom';
import { useMatrixClient } from '$hooks/useMatrixClient';
import { JoinBeforeNavigate } from '$features/join-before-navigate';
+import { useActiveRoomIdSync } from '$state/room/activeRoomId';
import { useDirectRooms } from './useDirectRooms';
export function DirectRouteRoomProvider({ children }: { children: ReactNode }) {
@@ -16,6 +17,8 @@ export function DirectRouteRoomProvider({ children }: { children: ReactNode }) {
const roomId = useSelectedRoom();
const room = mx.getRoom(roomId);
+ useActiveRoomIdSync(roomId);
+
if (!room || !rooms.includes(room.roomId)) {
return ;
}
diff --git a/src/app/pages/client/home/RoomProvider.tsx b/src/app/pages/client/home/RoomProvider.tsx
index b6f7ba7e3..c86ed8219 100644
--- a/src/app/pages/client/home/RoomProvider.tsx
+++ b/src/app/pages/client/home/RoomProvider.tsx
@@ -5,6 +5,7 @@ import { IsDirectRoomProvider, RoomProvider } from '$hooks/useRoom';
import { useMatrixClient } from '$hooks/useMatrixClient';
import { JoinBeforeNavigate } from '$features/join-before-navigate';
import { useSearchParamsViaServers } from '$hooks/router/useSearchParamsViaServers';
+import { useActiveRoomIdSync } from '$state/room/activeRoomId';
import { useHomeRooms } from './useHomeRooms';
export function HomeRouteRoomProvider({ children }: { children: ReactNode }) {
@@ -18,6 +19,8 @@ export function HomeRouteRoomProvider({ children }: { children: ReactNode }) {
const roomId = useSelectedRoom();
const room = mx.getRoom(roomId);
+ useActiveRoomIdSync(roomId);
+
if (!room || !rooms.includes(room.roomId)) {
return (
(undefined);
+
+/** Keep {@link activeRoomIdAtom} in sync with the current route's room. */
+export function useActiveRoomIdSync(roomId: string | undefined): void {
+ const setActiveRoomId = useSetAtom(activeRoomIdAtom);
+ useEffect(() => {
+ setActiveRoomId(roomId);
+ return () => setActiveRoomId(undefined);
+ }, [roomId, setActiveRoomId]);
+}
From 0491959aa99a6e231ba5de390a86876a381f67e8 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sun, 12 Apr 2026 17:10:26 -0400
Subject: [PATCH 158/191] fix(notifications): pass room and userId context to
reaction notification filter
---
.changeset/reaction-notification-context.md | 5 +++++
src/app/pages/client/BackgroundNotifications.tsx | 2 +-
src/app/pages/client/ClientNonUIFeatures.tsx | 2 +-
3 files changed, 7 insertions(+), 2 deletions(-)
create mode 100644 .changeset/reaction-notification-context.md
diff --git a/.changeset/reaction-notification-context.md b/.changeset/reaction-notification-context.md
new file mode 100644
index 000000000..18e22446b
--- /dev/null
+++ b/.changeset/reaction-notification-context.md
@@ -0,0 +1,5 @@
+---
+default: patch
+---
+
+Fix reaction notifications not being delivered by passing room and user context to the notification event filter
diff --git a/src/app/pages/client/BackgroundNotifications.tsx b/src/app/pages/client/BackgroundNotifications.tsx
index d1099cbf3..6b915159e 100644
--- a/src/app/pages/client/BackgroundNotifications.tsx
+++ b/src/app/pages/client/BackgroundNotifications.tsx
@@ -327,7 +327,7 @@ export function BackgroundNotifications() {
return;
}
- if (!isNotificationEvent(mEvent)) {
+ if (!isNotificationEvent(mEvent, room, mx.getUserId() ?? undefined)) {
return;
}
diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx
index 26ac2f431..65bf1825a 100644
--- a/src/app/pages/client/ClientNonUIFeatures.tsx
+++ b/src/app/pages/client/ClientNonUIFeatures.tsx
@@ -336,7 +336,7 @@ function MessageNotifications() {
return;
}
- if (!room || isHistoricalEvent || room.isSpaceRoom() || !isNotificationEvent(mEvent)) {
+ if (!room || isHistoricalEvent || room.isSpaceRoom() || !isNotificationEvent(mEvent, room, mx.getUserId() ?? undefined)) {
return;
}
From f6a7cd763eed9b7cc8c2701719c795eee4158d21 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Thu, 16 Apr 2026 00:43:11 -0400
Subject: [PATCH 159/191] fix(badge): only clear app badge when foregrounded
When backgrounded, the service worker manages the badge from push
payloads. The app's local unread state may be stale before sync catches
up, causing the badge to flash on then immediately off. Guard
clearAppBadge() with a visibility check so the SW badge persists.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
src/app/pages/client/ClientNonUIFeatures.tsx | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx
index 65bf1825a..af821357f 100644
--- a/src/app/pages/client/ClientNonUIFeatures.tsx
+++ b/src/app/pages/client/ClientNonUIFeatures.tsx
@@ -134,7 +134,9 @@ function FaviconUpdater() {
// for an OS-level app badge.
if (highlightTotal > 0) {
navigator.setAppBadge(highlightTotal);
- } else {
+ } else if (document.visibilityState === 'visible') {
+ // Only clear when foregrounded β the SW sets the badge from push
+ // payloads while backgrounded, and local state may be stale.
navigator.clearAppBadge();
}
if (usePushNotifications) {
From 2ef5386d0aa4db7de7dfb358dfd2d56cc5d8a279 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sat, 18 Apr 2026 19:41:02 -0400
Subject: [PATCH 160/191] fix(notifications): normalize DM room names
---
src/app/pages/client/BackgroundNotifications.tsx | 5 +++--
src/app/pages/client/ClientNonUIFeatures.tsx | 7 ++++---
src/app/utils/room.ts | 10 ++++++++++
3 files changed, 17 insertions(+), 5 deletions(-)
diff --git a/src/app/pages/client/BackgroundNotifications.tsx b/src/app/pages/client/BackgroundNotifications.tsx
index 395718223..a1c93ed98 100644
--- a/src/app/pages/client/BackgroundNotifications.tsx
+++ b/src/app/pages/client/BackgroundNotifications.tsx
@@ -26,6 +26,7 @@ import {
getMemberDisplayName,
getNotificationType,
getStateEvent,
+ getRoomDisplayName,
isNotificationEvent,
getMDirects,
isDMRoom,
@@ -422,7 +423,7 @@ export function BackgroundNotifications() {
}
const notificationPayload = buildRoomMessageNotification({
- roomName: room.name ?? room.getCanonicalAlias() ?? room.roomId,
+ roomName: getRoomDisplayName(room),
roomAvatar,
username: senderName,
recipientId: session.userId,
@@ -467,7 +468,7 @@ export function BackgroundNotifications() {
setInAppBannerRef.current({
id: dedupeId,
title: notificationPayload.title,
- roomName: room.name ?? room.getCanonicalAlias() ?? undefined,
+ roomName: getRoomDisplayName(room),
senderName,
body: notificationPayload.options.body,
icon: notificationPayload.options.icon,
diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx
index 26ac2f431..a6d16692a 100644
--- a/src/app/pages/client/ClientNonUIFeatures.tsx
+++ b/src/app/pages/client/ClientNonUIFeatures.tsx
@@ -31,6 +31,7 @@ import {
getMemberDisplayName,
getNotificationType,
getStateEvent,
+ getRoomDisplayName,
isDMRoom,
isNotificationEvent,
} from '$utils/room';
@@ -409,7 +410,7 @@ function MessageNotifications() {
const avatarMxc =
room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl();
const osPayload = buildRoomMessageNotification({
- roomName: room.name ?? 'Unknown',
+ roomName: getRoomDisplayName(room),
roomAvatar: avatarMxc
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined)
: undefined,
@@ -485,7 +486,7 @@ function MessageNotifications() {
}
const payload = buildRoomMessageNotification({
- roomName: room.name ?? 'Unknown',
+ roomName: getRoomDisplayName(room),
roomAvatar,
username: resolvedSenderName,
previewText,
@@ -500,7 +501,7 @@ function MessageNotifications() {
setInAppBanner({
id: eventId,
title: payload.title,
- roomName: room.name ?? undefined,
+ roomName: getRoomDisplayName(room),
serverName,
senderName: resolvedSenderName,
body: previewText,
diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts
index 9391dbc90..fa6114466 100644
--- a/src/app/utils/room.ts
+++ b/src/app/utils/room.ts
@@ -536,6 +536,16 @@ export const getMemberDisplayName = (
return name;
};
+/**
+ * Returns the room's display name, normalising the case where the SDK computes
+ * a raw Matrix user ID for unnamed DMs. In that case, use the localpart so
+ * notifications and banners do not show the full MXID.
+ */
+export const getRoomDisplayName = (room: Room): string => {
+ const { name } = room;
+ return name.match(/^@([^:]+):/)?.[1] ?? name;
+};
+
export const getMemberSearchStr = (
member: RoomMember,
query: string,
From afc2b4961aa1f7bdbfabb4b43165fc7ed16925bb Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sun, 12 Apr 2026 17:36:42 -0400
Subject: [PATCH 161/191] feat(types): add experiment config, sessionSync types
and useExperimentVariant to useClientConfig
---
src/app/hooks/useClientConfig.ts | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts
index e523f15a7..8ca6dc78a 100644
--- a/src/app/hooks/useClientConfig.ts
+++ b/src/app/hooks/useClientConfig.ts
@@ -5,6 +5,15 @@ export type HashRouterConfig = {
basename?: string;
};
+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 +23,7 @@ export type ClientConfig = {
disableAccountSwitcher?: boolean;
hideUsernamePasswordFields?: boolean;
+ sessionSync?: SessionSyncConfig;
pushNotificationDetails?: {
pushNotifyUrl?: string;
vapidPublicKey?: string;
From 6b89dc6c55f7fb3f30ea8d3113801a43ea6371fa Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sat, 11 Apr 2026 17:30:45 -0400
Subject: [PATCH 162/191] fix(sw): increase session TTL to 24h and add
requestSessionWithTimeout fallback
Matrix access tokens are long-lived and only invalidated on logout or server revocation.
The previous 60s TTL caused iOS push handlers (which restart the SW per push) to reject
cached sessions as stale, resulting in generic 'New Message' notifications.
Also adds a requestSessionWithTimeout fallback in handleMinimalPushPayload that asks
live window clients for a fresh session when neither the in-memory map nor the persisted
cache contains a usable session.
---
src/sw.ts | 222 ++++++++++++++++++++++++++++++++++++++++++++++--------
1 file changed, 189 insertions(+), 33 deletions(-)
diff --git a/src/sw.ts b/src/sw.ts
index d843963bd..2e73f7004 100644
--- a/src/sw.ts
+++ b/src/sw.ts
@@ -69,9 +69,12 @@ async function loadPersistedSettings() {
async function persistSession(session: SessionInfo): Promise {
try {
const cache = await self.caches.open(SW_SESSION_CACHE);
+ const sessionWithTimestamp = { ...session, persistedAt: Date.now() };
await cache.put(
SW_SESSION_URL,
- new Response(JSON.stringify(session), { headers: { 'Content-Type': 'application/json' } })
+ new Response(JSON.stringify(sessionWithTimestamp), {
+ headers: { 'Content-Type': 'application/json' },
+ })
);
} catch {
// Ignore β caches may be unavailable in some environments.
@@ -91,13 +94,32 @@ async function loadPersistedSession(): Promise {
try {
const cache = await self.caches.open(SW_SESSION_CACHE);
const response = await cache.match(SW_SESSION_URL);
- if (!response) return undefined;
- const s = await response.json();
- if (typeof s.accessToken === 'string' && typeof s.baseUrl === 'string') {
+ if (response) {
+ const s = await response.json();
+
+ // Reject persisted sessions older than 24 hours. Matrix access tokens are
+ // long-lived and are only invalidated on explicit logout or device revocation β
+ // not by the passage of time. A short TTL (e.g. 60 s) was too aggressive: it
+ // caused the SW to show generic "New Message" notifications whenever the app
+ // was backgrounded for more than a minute, because the cached session was
+ // rejected and requestSession had no live window client to reach.
+ // If the token truly is revoked the fetches in handleMinimalPushPayload will
+ // receive a 401 and gracefully fall back to a generic notification anyway.
+ const age = typeof s.persistedAt === 'number' ? Date.now() - s.persistedAt : Infinity;
+ const MAX_SESSION_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
+ if (age > MAX_SESSION_AGE_MS) {
+ console.debug('[SW] loadPersistedSession: session expired', {
+ age,
+ accessToken: s.accessToken.slice(0, 8),
+ });
+ return undefined;
+ }
+
return {
accessToken: s.accessToken,
baseUrl: s.baseUrl,
userId: typeof s.userId === 'string' ? s.userId : undefined,
+ persistedAt: s.persistedAt,
};
}
return undefined;
@@ -111,6 +133,8 @@ type SessionInfo = {
baseUrl: string;
/** Matrix user ID of the account, used to identify which account a push belongs to. */
userId?: string;
+ /** Timestamp when this session was persisted to cache, used to expire stale tokens. */
+ persistedAt?: number;
};
/**
@@ -414,7 +438,16 @@ async function handleMinimalPushPayload(
// On iOS the SW is killed and restarted for every push, clearing the in-memory sessions
// Map. Fall back to the Cache Storage copy that was written when the user last opened
// the app (same pattern as settings persistence).
- const session = getAnyStoredSession() ?? (await loadPersistedSession());
+ // Last resort: if neither the in-memory map nor the cache has a session, ask any live
+ // window client for a fresh token (the app may be backgrounded but still alive in memory).
+ let session = getAnyStoredSession() ?? (await loadPersistedSession());
+ if (!session && windowClients.length > 0) {
+ console.debug('[SW push] no cached session, requesting from window clients');
+ const result = await Promise.race(
+ Array.from(windowClients).map((c) => requestSessionWithTimeout(c.id, 1500))
+ );
+ session = result ?? undefined;
+ }
if (!session) {
// No session anywhere β app was never opened since install, or the user logged out.
@@ -555,6 +588,14 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => {
if (type === 'setSession') {
setSession(client.id, accessToken, baseUrl, userId);
+ // Keep the SW alive until the cache write completes. persistSession is
+ // called fire-and-forget inside setSession; without waitUntil the browser
+ // can kill the SW before caches.put resolves, leaving the persisted session
+ // stale on the next restart and causing intermittent 401s on media fetches.
+ const persisted = sessions.get(client.id);
+ event.waitUntil(
+ (persisted ? persistSession(persisted) : clearPersistedSession()).catch(() => undefined)
+ );
event.waitUntil(cleanupDeadClients());
}
if (type === 'pushDecryptResult') {
@@ -604,12 +645,24 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => {
const MEDIA_PATHS = [
'/_matrix/client/v1/media/download',
'/_matrix/client/v1/media/thumbnail',
+ '/_matrix/client/v1/media/preview_url',
+ '/_matrix/client/v3/media/download',
+ '/_matrix/client/v3/media/thumbnail',
+ '/_matrix/client/v3/media/preview_url',
+ '/_matrix/client/r0/media/download',
+ '/_matrix/client/r0/media/thumbnail',
+ '/_matrix/client/r0/media/preview_url',
+ '/_matrix/client/unstable/org.matrix.msc3916/media/download',
+ '/_matrix/client/unstable/org.matrix.msc3916/media/thumbnail',
+ '/_matrix/client/unstable/org.matrix.msc3916/media/preview_url',
// Legacy unauthenticated endpoints β servers that require auth return 404/403
// for these when no token is present, so intercept and add auth here too.
'/_matrix/media/v3/download',
'/_matrix/media/v3/thumbnail',
+ '/_matrix/media/v3/preview_url',
'/_matrix/media/r0/download',
'/_matrix/media/r0/thumbnail',
+ '/_matrix/media/r0/preview_url',
];
function mediaPath(url: string): boolean {
@@ -628,6 +681,39 @@ function validMediaRequest(url: string, baseUrl: string): boolean {
});
}
+function getMatchingSessions(url: string): SessionInfo[] {
+ return [...sessions.values()].filter((s) => validMediaRequest(url, s.baseUrl));
+}
+
+function isAuthFailureStatus(status: number): boolean {
+ return status === 401 || status === 403;
+}
+
+async function getLiveWindowSessions(url: string, clientId: string): Promise {
+ const collected: SessionInfo[] = [];
+ const seen = new Set();
+ const add = (session?: SessionInfo) => {
+ if (!session || !validMediaRequest(url, session.baseUrl)) return;
+ const key = `${session.baseUrl}\x00${session.accessToken}`;
+ if (seen.has(key)) return;
+ seen.add(key);
+ collected.push(session);
+ };
+
+ if (clientId) {
+ add(await requestSessionWithTimeout(clientId, 1500));
+ return collected;
+ }
+
+ const windowClients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
+ const liveSessions = await Promise.all(
+ windowClients.map((client) => requestSessionWithTimeout(client.id, 750))
+ );
+ liveSessions.forEach((session) => add(session));
+
+ return collected;
+}
+
function fetchConfig(token: string): RequestInit {
return {
headers: {
@@ -637,6 +723,67 @@ function fetchConfig(token: string): RequestInit {
};
}
+/**
+ * Fetch a media URL, retrying once with the most-current in-memory session on 401.
+ *
+ * There is a timing window between when the SDK refreshes its access token
+ * (tokenRefreshFunction resolves) and when the resulting pushSessionToSW()
+ * postMessage is processed by the SW. Media requests that land in this window
+ * are sent with the stale token and receive 401. By the time the retry runs,
+ * the setSession message will normally have been processed and sessions will
+ * hold the new token.
+ *
+ * A second timing window exists at startup: preloadedSession may hold a stale
+ * token but the live setSession from the page hasn't arrived yet. In that case
+ * the in-memory check yields no fresher token, so we ask the live client tab
+ * directly (requestSessionWithTimeout) before giving up.
+ */
+async function fetchMediaWithRetry(
+ url: string,
+ token: string,
+ redirect: RequestRedirect,
+ clientId: string
+): Promise {
+ let response = await fetch(url, { ...fetchConfig(token), redirect });
+ if (!isAuthFailureStatus(response.status)) return response;
+
+ const attemptedTokens = new Set([token]);
+ const retrySessions: SessionInfo[] = [];
+ const seenSessions = new Set();
+
+ const addRetrySession = (session?: SessionInfo) => {
+ if (!session || !validMediaRequest(url, session.baseUrl)) return;
+ const key = `${session.baseUrl}\x00${session.accessToken}`;
+ if (seenSessions.has(key)) return;
+ seenSessions.add(key);
+ retrySessions.push(session);
+ };
+
+ if (clientId) addRetrySession(sessions.get(clientId));
+ getMatchingSessions(url).forEach((session) => addRetrySession(session));
+ addRetrySession(preloadedSession);
+ addRetrySession(await loadPersistedSession());
+ (await getLiveWindowSessions(url, clientId)).forEach((session) => addRetrySession(session));
+
+ // Try each plausible token once. This handles token-refresh races and ambiguous
+ // multi-account sessions on the same homeserver, including no-clientId requests.
+ // Sequential await is intentional: we want to try one token at a time until one succeeds.
+ /* eslint-disable no-await-in-loop */
+ for (let i = 0; i < retrySessions.length; i += 1) {
+ const candidate = retrySessions[i];
+ if (!candidate || attemptedTokens.has(candidate.accessToken)) {
+ // skip this candidate
+ } else {
+ attemptedTokens.add(candidate.accessToken);
+ response = await fetch(url, { ...fetchConfig(candidate.accessToken), redirect });
+ if (!isAuthFailureStatus(response.status)) return response;
+ }
+ }
+ /* eslint-enable no-await-in-loop */
+
+ return response;
+}
+
self.addEventListener('message', (event: ExtendableMessageEvent) => {
if (event.data.type === 'togglePush') {
const token = event.data?.token;
@@ -667,37 +814,24 @@ self.addEventListener('fetch', (event: FetchEvent) => {
const session = clientId ? sessions.get(clientId) : undefined;
if (session && validMediaRequest(url, session.baseUrl)) {
- event.respondWith(fetch(url, { ...fetchConfig(session.accessToken), redirect }));
- return;
- }
-
- // Since widgets like element call have their own client ids,
- // we need this logic. We just go through the sessions list and get a session
- // with the right base url. Media requests to a homeserver simply are fine with any account
- // on the homeserver authenticating it, so this is fine. But it can be technically wrong.
- // If you have two tabs for different users on the same homeserver, it might authenticate
- // as the wrong one.
- // Thus any logic in the future which cares about which user is authenticating the request
- // might break this. Also, again, it is technically wrong.
- // Also checks preloadedSession β populated from cache at SW activate β for the window
- // between SW restart and the first live setSession arriving from the page.
- const byBaseUrl =
- [...sessions.values()].find((s) => validMediaRequest(url, s.baseUrl)) ??
- (preloadedSession && validMediaRequest(url, preloadedSession.baseUrl)
- ? preloadedSession
- : undefined);
- if (byBaseUrl) {
- event.respondWith(fetch(url, { ...fetchConfig(byBaseUrl.accessToken), redirect }));
+ event.respondWith(fetchMediaWithRetry(url, session.accessToken, redirect, clientId));
return;
}
// No clientId: the fetch came from a context not associated with a specific
- // window (e.g. a prerender). Fall back to the persisted session directly.
+ // window (e.g. a prerender). Fall back to persisted/unique-by-baseUrl sessions.
if (!clientId) {
event.respondWith(
loadPersistedSession().then((persisted) => {
if (persisted && validMediaRequest(url, persisted.baseUrl)) {
- return fetch(url, { ...fetchConfig(persisted.accessToken), redirect });
+ return fetchMediaWithRetry(url, persisted.accessToken, redirect, '');
+ }
+ const matching = getMatchingSessions(url);
+ if (matching.length === 1) {
+ return fetchMediaWithRetry(url, matching[0].accessToken, redirect, '');
+ }
+ if (preloadedSession && validMediaRequest(url, preloadedSession.baseUrl)) {
+ return fetchMediaWithRetry(url, preloadedSession.accessToken, redirect, '');
}
return fetch(event.request);
})
@@ -705,17 +839,30 @@ self.addEventListener('fetch', (event: FetchEvent) => {
return;
}
+ // Synchronous fast-path: check in-memory sessions by baseUrl and the
+ // preloaded session before paying the 3-second requestSessionWithTimeout
+ // cost. This restores the old byBaseUrl behaviour while keeping retry logic.
+ const syncByBaseUrl = getMatchingSessions(url);
+ if (syncByBaseUrl.length === 1) {
+ event.respondWith(fetchMediaWithRetry(url, syncByBaseUrl[0].accessToken, redirect, clientId));
+ return;
+ }
+ if (preloadedSession && validMediaRequest(url, preloadedSession.baseUrl)) {
+ event.respondWith(fetchMediaWithRetry(url, preloadedSession.accessToken, redirect, clientId));
+ return;
+ }
+
event.respondWith(
requestSessionWithTimeout(clientId).then(async (s) => {
// Primary: session received from the live client window.
if (s && validMediaRequest(url, s.baseUrl)) {
- return fetch(url, { ...fetchConfig(s.accessToken), redirect });
+ return fetchMediaWithRetry(url, s.accessToken, redirect, clientId);
}
// Fallback: try the persisted session (helps when SW restarts on iOS and
// the client window hasn't responded to requestSession yet).
const persisted = await loadPersistedSession();
if (persisted && validMediaRequest(url, persisted.baseUrl)) {
- return fetch(url, { ...fetchConfig(persisted.accessToken), redirect });
+ return fetchMediaWithRetry(url, persisted.accessToken, redirect, clientId);
}
console.warn(
'[SW fetch] No valid session for media request',
@@ -749,10 +896,19 @@ const onPushNotification = async (event: PushEvent) => {
// If the app is open and visible, skip the OS push notification β the in-app
// pill notification handles the alert instead.
- // Combine clients.matchAll() visibility with the explicit appIsVisible flag
- // because iOS Safari PWA often returns empty or stale results from matchAll().
+ //
+ // When clients.matchAll() returns β₯1 client, trust its visibilityState
+ // directly. iOS can suspend the JS thread before postMessage({ visible:
+ // false }) is processed, leaving appIsVisible stuck at true. matchAll()
+ // still reports the backgrounded client as 'hidden', so it is the
+ // authoritative and most reliable signal.
+ //
+ // When matchAll() returns zero clients (a separate iOS Safari PWA quirk),
+ // visibility is unknowable β do NOT suppress. Better to show a duplicate
+ // (handled gracefully by the in-app banner) than to silently drop a
+ // notification while the app is backgrounded.
const hasVisibleClient =
- appIsVisible || clients.some((client) => client.visibilityState === 'visible');
+ clients.length > 0 ? clients.some((client) => client.visibilityState === 'visible') : false;
console.debug(
'[SW push] appIsVisible:',
appIsVisible,
From d8636958559b97a2334375fc706096fa7b6a9e56 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sat, 11 Apr 2026 17:58:21 -0400
Subject: [PATCH 163/191] fix(sw): reset heartbeat backoff on foreground sync;
warm preloadedSession from push handler
When phase3AdaptiveBackoffJitter is enabled, successful foreground/focus session pushes
(phase1ForegroundResync) now reset heartbeatFailuresRef to 0. Previously a period of SW
controller absence (e.g. SW update) could inflate the heartbeat interval to its maximum
(30 min) even after the SW became healthy again, reducing session-refresh frequency below
the intended 10-minute rate.
Also captures the loadPersistedSession() result in onPushNotification and assigns it to
preloadedSession, avoiding a redundant second cache read in handleMinimalPushPayload when
the SW is restarted by iOS for a push event.
---
src/app/hooks/useAppVisibility.ts | 213 +++++++++++++++++++++++++++++-
src/sw.ts | 8 +-
2 files changed, 216 insertions(+), 5 deletions(-)
diff --git a/src/app/hooks/useAppVisibility.ts b/src/app/hooks/useAppVisibility.ts
index 7fd5f2325..ed2d69cfb 100644
--- a/src/app/hooks/useAppVisibility.ts
+++ b/src/app/hooks/useAppVisibility.ts
@@ -1,23 +1,112 @@
-import { useEffect } from 'react';
+import { useCallback, useEffect, useRef } from 'react';
import { MatrixClient } from '$types/matrix-sdk';
+import { Session } from '$state/sessions';
import { useAtom } from 'jotai';
import { togglePusher } from '../features/settings/notifications/PushNotifications';
import { appEvents } from '../utils/appEvents';
-import { useClientConfig } from './useClientConfig';
+import { useClientConfig, useExperimentVariant } 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 { 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 = () => {
const isVisible = document.visibilityState === 'visible';
@@ -29,15 +118,66 @@ 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;
@@ -52,4 +192,69 @@ export function useAppVisibility(mx: MatrixClient | undefined) {
appEvents.onVisibilityChange = null;
};
}, [mx, clientConfig, usePushNotifications, pushSubAtom, isMobile]);
+
+ useEffect(() => {
+ 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();
+
+ 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());
+ };
+
+ timeoutId = window.setTimeout(tick, getDelayMs());
+
+ return () => {
+ if (timeoutId !== undefined) window.clearTimeout(timeoutId);
+ };
+ }, [
+ heartbeatIntervalMs,
+ heartbeatMaxBackoffMs,
+ phase2VisibleHeartbeat,
+ phase3AdaptiveBackoffJitter,
+ pushSessionNow,
+ ]);
}
diff --git a/src/sw.ts b/src/sw.ts
index 2e73f7004..d8b5a8697 100644
--- a/src/sw.ts
+++ b/src/sw.ts
@@ -888,11 +888,17 @@ const onPushNotification = async (event: PushEvent) => {
// The SW may have been restarted by the OS (iOS is aggressive about this),
// so in-memory settings would be at their defaults. Reload from cache and
// match active clients in parallel β they are independent operations.
- const [, , clients] = await Promise.all([
+ // Capture the persisted session result into preloadedSession so that
+ // getAnyStoredSession() returns it in handleMinimalPushPayload without a
+ // second cache read.
+ const [, persistedSession, clients] = await Promise.all([
loadPersistedSettings(),
loadPersistedSession(),
self.clients.matchAll({ type: 'window', includeUncontrolled: true }),
]);
+ if (persistedSession && !preloadedSession) {
+ preloadedSession = persistedSession;
+ }
// If the app is open and visible, skip the OS push notification β the in-app
// pill notification handles the alert instead.
From 52bbbc6b3b2313d1e9a9705b2ebf0bc90e67e3e0 Mon Sep 17 00:00:00 2001
From: Evie Gauthier
Date: Sat, 18 Apr 2026 19:19:59 -0400
Subject: [PATCH 164/191] fix(notifications): restore background visibility
sync
---
src/app/hooks/useAppVisibility.ts | 19 ++++++++--
src/sw.ts | 60 +++++++++++++++++--------------
2 files changed, 49 insertions(+), 30 deletions(-)
diff --git a/src/app/hooks/useAppVisibility.ts b/src/app/hooks/useAppVisibility.ts
index ed2d69cfb..c8cf46c6a 100644
--- a/src/app/hooks/useAppVisibility.ts
+++ b/src/app/hooks/useAppVisibility.ts
@@ -61,6 +61,7 @@ export function useAppVisibility(mx: MatrixClient | undefined, activeSession?: S
const lastForegroundPushAtRef = useRef(0);
const suppressHeartbeatUntilRef = useRef(0);
const heartbeatFailuresRef = useRef(0);
+ const lastEmittedVisibilityRef = useRef(undefined);
const pushSessionNow = useCallback(
(reason: 'foreground' | 'focus' | 'heartbeat'): 'sent' | 'skipped' => {
@@ -108,12 +109,14 @@ export function useAppVisibility(mx: MatrixClient | undefined, activeSession?: S
);
useEffect(() => {
- const handleVisibilityChange = () => {
- const isVisible = document.visibilityState === 'visible';
+ const handleVisibilityState = (isVisible: boolean, source: 'visibilitychange' | 'pagehide') => {
+ if (lastEmittedVisibilityRef.current === isVisible) return;
+ lastEmittedVisibilityRef.current = isVisible;
+
debugLog.info(
'general',
`App visibility changed: ${isVisible ? 'visible (foreground)' : 'hidden (background)'}`,
- { visibilityState: document.visibilityState }
+ { visibilityState: document.visibilityState, source }
);
appEvents.onVisibilityChange?.(isVisible);
if (!isVisible) {
@@ -142,6 +145,14 @@ export function useAppVisibility(mx: MatrixClient | undefined, activeSession?: S
}
};
+ const handleVisibilityChange = () => {
+ handleVisibilityState(document.visibilityState === 'visible', 'visibilitychange');
+ };
+
+ const handlePageHide = () => {
+ handleVisibilityState(false, 'pagehide');
+ };
+
const handleFocus = () => {
if (document.visibilityState !== 'visible') return;
@@ -163,10 +174,12 @@ export function useAppVisibility(mx: MatrixClient | undefined, activeSession?: S
};
document.addEventListener('visibilitychange', handleVisibilityChange);
+ window.addEventListener('pagehide', handlePageHide);
window.addEventListener('focus', handleFocus);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
+ window.removeEventListener('pagehide', handlePageHide);
window.removeEventListener('focus', handleFocus);
};
}, [
diff --git a/src/sw.ts b/src/sw.ts
index d8b5a8697..3dad6d3fc 100644
--- a/src/sw.ts
+++ b/src/sw.ts
@@ -392,37 +392,43 @@ async function requestDecryptionFromClient(
rawEvent: Record
): Promise