diff --git a/.changeset/async-callback-rejections.md b/.changeset/async-callback-rejections.md
new file mode 100644
index 000000000..a8e8af829
--- /dev/null
+++ b/.changeset/async-callback-rejections.md
@@ -0,0 +1,5 @@
+---
+default: patch
+---
+
+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`
diff --git a/.changeset/devtool-rotate-sessions.md b/.changeset/devtool-rotate-sessions.md
new file mode 100644
index 000000000..686fb292f
--- /dev/null
+++ b/.changeset/devtool-rotate-sessions.md
@@ -0,0 +1,5 @@
+---
+default: patch
+---
+
+Add developer tool to force-rotate outbound Megolm encryption sessions per room, useful for testing key rotation and bridge session recovery
diff --git a/.changeset/feat-dm-message-preview.md b/.changeset/feat-dm-message-preview.md
new file mode 100644
index 000000000..46cbcff81
--- /dev/null
+++ b/.changeset/feat-dm-message-preview.md
@@ -0,0 +1,5 @@
+---
+default: minor
+---
+
+feat(dm-list): show last-message preview below DM room name
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).
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/.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.
diff --git a/.changeset/presence-auto-idle.md b/.changeset/presence-auto-idle.md
new file mode 100644
index 000000000..56889390c
--- /dev/null
+++ b/.changeset/presence-auto-idle.md
@@ -0,0 +1,5 @@
+---
+default: minor
+---
+
+feat(presence): add auto-idle presence after configurable inactivity timeout with Discord-style status picker
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
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/.changeset/room-message-preview.md b/.changeset/room-message-preview.md
new file mode 100644
index 000000000..3f8587b85
--- /dev/null
+++ b/.changeset/room-message-preview.md
@@ -0,0 +1,5 @@
+---
+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/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
new file mode 100644
index 000000000..f97cbc6fc
--- /dev/null
+++ b/.devcontainer/devcontainer.json
@@ -0,0 +1,152 @@
+// Codespace configuration — lives on personal/config (not ephemeral dev/feat branches).
+// This file intentionally targets browser-based use on iPad.
+{
+ "name": "Sable",
+ // 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": {}
+ },
+
+ // ── 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.
+ //
+ // 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"
+ // ───────────────────────────────────────────────────────────────────────────
+
+ "remoteEnv": {
+ // Pin the pnpm store to a known path so the volume mount works across rebuilds.
+ "PNPM_STORE_DIR": "/home/vscode/.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.
+ // 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', Menlo, 'Courier New', monospace",
+ "editor.fontLigatures": true,
+ "terminal.integrated.fontSize": 14,
+ "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.
+ // 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
+ // 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",
+ // 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 ───────────────────────────────────────────────────────────
+ "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",
+ "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"
+ ]
+ }
+ },
+
+ // ── 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/vscode/.pnpm-store,type=volume"],
+
+ "postCreateCommand": "bash .devcontainer/postCreate.sh",
+ "onCreateCommand": "bash .devcontainer/onCreate.sh",
+ "remoteUser": "vscode"
+}
diff --git a/.devcontainer/onCreate.sh b/.devcontainer/onCreate.sh
new file mode 100644
index 000000000..2f2943fa9
--- /dev/null
+++ b/.devcontainer/onCreate.sh
@@ -0,0 +1,60 @@
+#!/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
+
+# ── 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
+
+# ── 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
+
+# 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
+
+# ── 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 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).
+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
new file mode 100644
index 000000000..58dec301c
--- /dev/null
+++ b/.devcontainer/postCreate.sh
@@ -0,0 +1,157 @@
+#!/bin/bash
+# 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
+
+# ── 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="codespaces"
+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
+
+# ── 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).
+# The POWERLEVEL9K_MODE line has no quotes: POWERLEVEL9K_MODE=nerdfont-complete
+if [ -f "${HOME}/.p10k.zsh" ]; then
+ 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
+
+# ── 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 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 — 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 unconditionally disabled"
+ else
+ echo "✓ P10k instant prompt already disabled"
+ 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
+ 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 "evie@gauthier.id")"
+ 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
+
+# ── 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"
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/copilot-instructions.md b/.github/copilot-instructions.md
new file mode 100644
index 000000000..401bc55cb
--- /dev/null
+++ b/.github/copilot-instructions.md
@@ -0,0 +1,16 @@
+# Sable – GitHub Copilot Instructions
+
+Universal rules that apply to every session. Detailed guidance lives in `.github/instructions/` and `AGENTS.md`.
+
+## Core Rules
+
+- **Never commit directly to `dev` or `integration`.** All work goes on a dedicated branch (`fix/…`, `feat/…`, `chore/…`, etc.).
+- 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:
+ ```
+ pnpm lint && pnpm fmt:check && pnpm typecheck && pnpm test:run && pnpm knip && pnpm build
+ ```
+- 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, 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/.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
diff --git a/.github/workflows/cloudflare-dev-deploy.yml b/.github/workflows/cloudflare-dev-deploy.yml
new file mode 100644
index 000000000..5bc6421e0
--- /dev/null
+++ b/.github/workflows/cloudflare-dev-deploy.yml
@@ -0,0 +1,105 @@
+name: Cloudflare Worker Dev Deploy
+
+on:
+ push:
+ branches:
+ - 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'
+ - '.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-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..762c8fe9d 100644
--- a/.github/workflows/cloudflare-web-preview.yml
+++ b/.github/workflows/cloudflare-web-preview.yml
@@ -4,21 +4,25 @@ 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'
- '.github/actions/setup/**'
push:
branches:
- - dev
+ - 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'
@@ -32,9 +36,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/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml
index 03a63ef99..5badb90a4 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:
@@ -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
@@ -70,10 +74,15 @@ 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 == '') }}
+ # 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 == '') }}
+
+ # 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' }}
type=semver,pattern={{major}}.{{minor}},value=${{ steps.release_tag.outputs.value }},enable=${{ steps.release_tag.outputs.is_release == 'true' }}
@@ -90,6 +99,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
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
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/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/"
+ }
+ }
+}
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"
}
}
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 000000000..c44ee7052
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,74 @@
+# 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`, with `origin/dev` as the remote:
+ ```
+ 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 `origin/dev` from `upstream/dev`, then force-update `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.
+
+## 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
diff --git a/config.json b/config.json
index f0c3c8b61..8aee31d4a 100644
--- a/config.json
+++ b/config.json
@@ -1,24 +1,38 @@
{
"defaultHomeserver": 0,
- "homeserverList": ["matrix.org", "mozilla.org", "unredacted.org", "sable.moe", "kendama.moe"],
+ "homeserverList": [
+ "https://matrix.cloudhub.social",
+ "matrix.org",
+ "mozilla.org",
+ "unredacted.org",
+ "sable.moe",
+ "kendama.moe"
+ ],
"allowCustomHomeservers": true,
- "elementCallUrl": null,
-
+ "elementCallUrl": "matrix.cloudhub.social",
"disableAccountSwitcher": false,
"hideUsernamePasswordFields": false,
-
"pushNotificationDetails": {
- "pushNotifyUrl": "https://sygnal.sable.moe/_matrix/push/v1/notify",
- "vapidPublicKey": "BCnS4SbHjeOaqVFW4wjt5xDt_pYIL62qMzKePfYF9fl9PQU14RieIaObh7nLR_9dQf4sykZa-CTrcjkgMIE1mcg",
- "webPushAppID": "moe.sable.app.sygnal"
+ "pushNotifyUrl": "https://sygnal.cloudhub.social/_matrix/push/v1/notify",
+ "vapidPublicKey": "BEBdK6VUiqYxcOauFCM1ZB38llgiODAs6pR5EEcC7YBoUh2YvrULagwo5t-Ms0Is0lEmKDhpdUoMiy_i7ArI3oE",
+ "webPushAppID": "social.cloudhub.sable.web"
},
"settingsLinkBaseUrl": "https://app.sable.moe",
+
"slidingSync": {
"enabled": true
},
+ "presenceAutoIdleTimeoutMs": 300000,
+
+ "sessionSync": {
+ "phase1ForegroundResync": true,
+ "phase2VisibleHeartbeat": true,
+ "phase3AdaptiveBackoffJitter": true
+ },
+
"featuredCommunities": {
"openAsDefault": false,
"spaces": [
@@ -39,9 +53,11 @@
],
"servers": ["matrixrooms.info", "mozilla.org", "unredacted.org"]
},
-
"hashRouter": {
"enabled": false,
"basename": "/"
+ },
+ "features": {
+ "polls": true
}
}
diff --git a/infra/web/variables.tf b/infra/web/variables.tf
index 3569c9822..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 = "app.sable.moe"
+ default = "dev.cloudhub.social"
}
variable "worker_name" {
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/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]
diff --git a/sable.code-workspace b/sable.code-workspace
new file mode 100644
index 000000000..f937d83ca
--- /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",
+ ],
+ },
+}
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/inject-client-config.js b/scripts/inject-client-config.js
new file mode 100644
index 000000000..0b5fcd3ad
--- /dev/null
+++ b/scripts/inject-client-config.js
@@ -0,0 +1,75 @@
+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);
+
+// 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;
+ });
+ 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(', ')}`
+);
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"
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/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/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.test.ts b/src/app/features/bookmarks/bookmarkRepository.test.ts
new file mode 100644
index 000000000..0489fe692
--- /dev/null
+++ b/src/app/features/bookmarks/bookmarkRepository.test.ts
@@ -0,0 +1,469 @@
+/**
+ * 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,
+ listDeletedBookmarks,
+ 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);
+ });
+
+ 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();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// 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('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'] }),
+ });
+
+ 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([]);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// 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
+// ---------------------------------------------------------------------------
+
+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/features/bookmarks/bookmarkRepository.ts b/src/app/features/bookmarks/bookmarkRepository.ts
new file mode 100644
index 000000000..1b6cf0208
--- /dev/null
+++ b/src/app/features/bookmarks/bookmarkRepository.ts
@@ -0,0 +1,202 @@
+/**
+ * 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. Also handles re-activation: if the same (roomId, eventId) was
+ * previously removed (tombstoned), calling addBookmark again clears the tombstone
+ * and restores it to the active list.
+ *
+ * MSC4438 §Adding a bookmark:
+ * 1. Write the item event first (strips any deleted flag to guarantee re-activation).
+ * 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 {
+ // 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, activeItem as BookmarkItemContent);
+
+ 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. 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. 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
+ // 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);
+ index.bookmark_ids = index.bookmark_ids.filter((id) => id !== bookmarkId);
+ index.revision += 1;
+ index.updated_ts = Date.now();
+ await writeIndex(mx, index);
+}
+
+/**
+ * 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;
+}
+
+/**
+ * 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.
+ *
+ * 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.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
new file mode 100644
index 000000000..c6fac5746
--- /dev/null
+++ b/src/app/features/bookmarks/useBookmarks.ts
@@ -0,0 +1,118 @@
+import { useAtomValue, useSetAtom } from 'jotai';
+import { useCallback } from 'react';
+import { useMatrixClient } from '$hooks/useMatrixClient';
+import {
+ bookmarkDeletedListAtom,
+ bookmarkIdSetAtom,
+ bookmarkListAtom,
+ bookmarkLoadingAtom,
+} from '$state/bookmarks';
+import { BookmarkItemContent, computeBookmarkId } from './bookmarkDomain';
+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);
+}
+
+/**
+ * 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 setDeletedList = useSetAtom(bookmarkDeletedListAtom);
+ const setLoading = useSetAtom(bookmarkLoadingAtom);
+
+ const refresh = useCallback(async () => {
+ setLoading(true);
+ try {
+ setList(listBookmarks(mx));
+ setDeletedList(listDeletedBookmarks(mx));
+ } finally {
+ setLoading(false);
+ }
+ }, [mx, setList, setDeletedList, setLoading]);
+
+ const add = useCallback(
+ async (item: BookmarkItemContent) => {
+ // 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, setDeletedList]
+ );
+
+ const remove = useCallback(
+ async (bookmarkId: string) => {
+ // 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, setDeletedList]
+ );
+
+ 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, restore, checkIsBookmarked };
+}
diff --git a/src/app/features/bookmarks/useInitBookmarks.test.tsx b/src/app/features/bookmarks/useInitBookmarks.test.tsx
new file mode 100644
index 000000000..afb24f953
--- /dev/null
+++ b/src/app/features/bookmarks/useInitBookmarks.test.tsx
@@ -0,0 +1,152 @@
+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
new file mode 100644
index 000000000..480e20083
--- /dev/null
+++ b/src/app/features/bookmarks/useInitBookmarks.ts
@@ -0,0 +1,78 @@
+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 { bookmarkDeletedListAtom, bookmarkListAtom, bookmarkLoadingAtom } from '$state/bookmarks';
+import { AccountDataEvent } from '$types/matrix/accountData';
+import { listBookmarks, listDeletedBookmarks } 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 setDeletedList = useSetAtom(bookmarkDeletedListAtom);
+ const setLoading = useSetAtom(bookmarkLoadingAtom);
+
+ const loadBookmarks = useCallback(() => {
+ setLoading(true);
+ try {
+ setList(listBookmarks(mx));
+ setDeletedList(listDeletedBookmarks(mx));
+ } finally {
+ setLoading(false);
+ }
+ }, [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.
+ // 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 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) => {
+ const type = event.getType();
+ if (
+ type === (AccountDataEvent.BookmarksIndex as string) ||
+ type.startsWith(AccountDataEvent.BookmarkItemPrefix as string)
+ ) {
+ loadBookmarks();
+ }
+ },
+ [loadBookmarks]
+ )
+ );
+}
diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx
index 22886c224..68ead11d8 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,9 @@ type RoomNavItemProps = {
showAvatar?: boolean;
direct?: boolean;
customDMCards?: boolean;
+ roomTopicPreview?: boolean;
+ roomMessagePreview?: boolean;
+ dmMessagePreview?: boolean;
};
export function RoomNavItem({
@@ -266,6 +270,9 @@ export function RoomNavItem({
showAvatar,
direct,
customDMCards,
+ roomTopicPreview = false,
+ roomMessagePreview = false,
+ dmMessagePreview = true,
notificationMode,
linkPath,
}: RoomNavItemProps) {
@@ -287,8 +294,12 @@ export function RoomNavItem({
const matrixRoomName = useRoomName(room);
const roomName = (dmUserId && nicknames[dmUserId]) || matrixRoomName;
const presence = useUserPresence(dmUserId ?? '');
+ const showPreview = direct ? dmMessagePreview : roomMessagePreview;
+ const lastMessage = useRoomLastMessage(showPreview ? room : undefined, mx);
const getRoomTopic = useRoomTopic(room);
- const roomTopic = direct ? ((customDMCards && getRoomTopic) ?? presence?.status) : undefined;
+ const roomTopic = direct
+ ? (customDMCards && getRoomTopic) || lastMessage || presence?.status
+ : (roomTopicPreview && getRoomTopic) || (roomMessagePreview ? lastMessage : undefined);
const { navigateRoom } = useRoomNavigate();
const navigate = useNavigate();
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/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx
index d026ec1fa..80c7ba883 100644
--- a/src/app/features/room/RoomTimeline.tsx
+++ b/src/app/features/room/RoomTimeline.tsx
@@ -13,6 +13,7 @@ import { useAtomValue, useSetAtom } from 'jotai';
import { PushProcessor, Room, Direction } from '$types/matrix-sdk';
import classNames from 'classnames';
import { VList, VListHandle } from 'virtua';
+import { RoomScrollCache } from '$utils/roomScrollCache';
import {
as,
Box,
@@ -108,6 +109,9 @@ const getDayDividerText = (ts: number) => {
return timeDayMonthYear(ts);
};
+const SCROLL_SETTLE_MS = 250;
+const MIN_INITIAL_SCROLL_ROOM_PX = 300;
+
export type RoomTimelineProps = {
room: Room;
eventId?: string;
@@ -149,6 +153,7 @@ export function RoomTimeline({
const [autoplayStickers] = useSetting(settingsAtom, 'autoplayStickers');
const [autoplayEmojis] = useSetting(settingsAtom, 'autoplayEmojis');
const [hideMemberInReadOnly] = useSetting(settingsAtom, 'hideMembershipInReadOnly');
+ const [messageGroupingThreshold] = useSetting(settingsAtom, 'messageGroupingThreshold');
const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
const showClientUrlPreview = room.hasEncryptionStateEvent()
@@ -178,6 +183,7 @@ export function RoomTimeline({
hideReadsRef.current = hideReads;
const prevViewportHeightRef = useRef(0);
+ const prevScrollSizeRef = useRef(0);
const messageListRef = useRef(null);
const mediaAuthentication = useMediaAuthentication();
@@ -214,6 +220,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);
@@ -222,9 +229,18 @@ export function RoomTimeline({
// A recovery useLayoutEffect watches for processedEvents becoming non-empty
// and performs the final scroll + setIsReady when this flag is set.
const pendingReadyRef = useRef(false);
+ // Set to true when the 80 ms timer fires but backward pagination hasn't yet
+ // filled the viewport. The pagination-settle effect below watches for this
+ // flag and performs the final scroll + setIsReady when pagination settles.
+ const readyBlockedByPaginationRef = useRef(false);
const currentRoomIdRef = useRef(room.roomId);
+ const saveRoomScrollStateRef = useRef<
+ ((measurementCache: RoomScrollCache['measurementCache'], atBottom: boolean) => void) | undefined
+ >(undefined);
const [isReady, setIsReady] = useState(false);
+ const isReadyRef = useRef(false);
+ isReadyRef.current = isReady;
if (currentRoomIdRef.current !== room.roomId) {
hasInitialScrolledRef.current = false;
@@ -245,8 +261,12 @@ 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 handleJumpError = useCallback(() => setIsReady(true), []);
const timelineSync = useTimelineSync({
room,
@@ -255,6 +275,7 @@ export function RoomTimeline({
isAtBottom: atBottomState,
isAtBottomRef: atBottomRef,
scrollToBottom,
+ onJumpError: handleJumpError,
unreadInfo,
setUnreadInfo,
hideReadsRef,
@@ -305,11 +326,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;
@@ -339,7 +371,24 @@ 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;
@@ -631,6 +680,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);
}
@@ -740,6 +825,7 @@ export function RoomTimeline({
hideNickAvatarEvents,
isReadOnly,
hideMemberInReadOnly,
+ messageGroupingThreshold,
});
processedEventsRef.current = processedEvents;
@@ -808,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);
diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx
index 887d24a66..04fc23563 100644
--- a/src/app/features/room/message/Message.tsx
+++ b/src/app/features/room/message/Message.tsx
@@ -79,6 +79,8 @@ 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 { MessageReportItem } from '$components/message/modals/MessageReport';
import { filterPronounsByLanguage, getParsedPronouns } from '$utils/pronouns';
import { useMentionClickHandler } from '$hooks/useMentionClickHandler';
@@ -208,6 +210,49 @@ export const MessagePinItem = as<
);
});
+// message bookmarking
+export const MessageBookmarkItem = as<
+ 'button',
+ {
+ room: Room;
+ mEvent: MatrixEvent;
+ onClose?: () => void;
+ }
+>(({ room, mEvent, onClose, ...props }, ref) => {
+ const [enableMessageBookmarks] = useSetting(settingsAtom, 'enableMessageBookmarks');
+ const eventId = mEvent.getId();
+ const isBookmarked = useIsBookmarked(room.roomId, eventId ?? '');
+ const { add, remove } = useBookmarkActions();
+
+ if (!eventId) return null;
+ if (!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;
@@ -1101,6 +1146,7 @@ function MessageInternal(
)}
+
{canPinEvent && (
)}
@@ -1434,6 +1480,13 @@ export const Event = as<'div', EventProps>(
)}
+ {!stateEvent && (
+
+ )}
{((!mEvent.isRedacted() && canDelete && !stateEvent) ||
(mEvent.getSender() !== mx.getUserId() && !stateEvent)) && (
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..c56bf5edd
--- /dev/null
+++ b/src/app/features/room/poll/PollCreatorDialog.tsx
@@ -0,0 +1,407 @@
+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 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: '' },
+ ]);
+ 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(() => {
+ 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]);
+
+ 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;
+ }
+ 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()) {
+ setError('Please choose a future date and time for the custom expiry.');
+ return;
+ }
+ }
+ setError(undefined);
+ onSubmit({
+ question: trimmedQuestion,
+ answers: validAnswers,
+ kind,
+ maxSelections: clampedMaxSelections,
+ 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..103e87050
--- /dev/null
+++ b/src/app/features/room/poll/PollEvent.css.ts
@@ -0,0 +1,48 @@
+import { style } from '@vanilla-extract/css';
+import { config, FocusOutline } from 'folds';
+
+// Vote button wrapping just the radio circle - minimal touch target
+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([
+ 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({
+ 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..d1260e1b6
--- /dev/null
+++ b/src/app/features/room/poll/PollEvent.tsx
@@ -0,0 +1,500 @@
+import { type ReactNode, useCallback, useEffect, useMemo, useReducer, useState } from 'react';
+import FocusTrap from 'focus-trap-react';
+import {
+ Box,
+ Button,
+ Checkbox,
+ 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,
+ MatrixEventEvent,
+ 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 };
+
+export 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 };
+}
+
+export 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;
+};
+
+export 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;
+ // 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) {
+ myVote = myEntry.selections.slice(0, maxSelections).filter((s) => validAnswerIds.has(s));
+ }
+
+ return { tally, myVote, isEnded };
+}
+
+export 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]);
+
+ // 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;
+ 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, maxSelections } = pollData;
+ const isMultiSelect = maxSelections > 1;
+ 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/features/room/poll/pollEvent.test.ts b/src/app/features/room/poll/pollEvent.test.ts
new file mode 100644
index 000000000..e4cce28ff
--- /dev/null
+++ b/src/app/features/room/poll/pollEvent.test.ts
@@ -0,0 +1,395 @@
+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: () => childEvents,
+ },
+ }),
+ currentState: {
+ maySendRedactionForEvent: () => 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);
+ });
+});
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/features/settings/cosmetics/Themes.tsx b/src/app/features/settings/cosmetics/Themes.tsx
index f543a19ea..0df6e5ade 100644
--- a/src/app/features/settings/cosmetics/Themes.tsx
+++ b/src/app/features/settings/cosmetics/Themes.tsx
@@ -482,11 +482,17 @@ 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,
'closeFoldersByDefault'
);
+ const [roomTopicPreview, setRoomTopicPreview] = useSetting(settingsAtom, 'roomTopicPreview');
+ const [roomMessagePreview, setRoomMessagePreview] = useSetting(
+ settingsAtom,
+ 'roomMessagePreview'
+ );
return (
@@ -529,6 +535,43 @@ export function Appearance() {
/>
+
+
+ }
+ />
+
+
+
+
+ }
+ />
+
+
+
+
+ }
+ />
+
+
();
+ const [rotateState, rotateAllSessions] = useAsyncCallback<
+ { rotated: number; total: number },
+ Error,
+ []
+ >(
+ 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');
+
+ const encryptedRooms = mx
+ .getRooms()
+ .filter(
+ (room) =>
+ room.getMyMembership() === KnownMembership.Join && mx.isRoomEncrypted(room.roomId)
+ );
+
+ 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, but surface failures.
+ encryptedRooms.forEach((room) => {
+ Promise.resolve()
+ .then(() => crypto.prepareToEncrypt(room))
+ .catch((error) => {
+ console.error('Failed to prepare room encryption', room.roomId, error);
+ });
+ });
+
+ return { rotated, total: encryptedRooms.length };
+ }, [mx])
+ );
+
const submitAccountData: AccountDataSubmitCallback = useCallback(
async (type, content) => {
// TODO: remove cast once account data typing is unified.
@@ -109,6 +155,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 && (
{
+ 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(', ')}
+
+
+ )}
+ >
+ )}
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/app/features/settings/experimental/Experimental.tsx b/src/app/features/settings/experimental/Experimental.tsx
index 330412185..14da03165 100644
--- a/src/app/features/settings/experimental/Experimental.tsx
+++ b/src/app/features/settings/experimental/Experimental.tsx
@@ -10,6 +10,8 @@ import { Sync } from '../general';
import { SettingsSectionPage } from '../SettingsSectionPage';
import { BandwidthSavingEmojis } from './BandwithSavingEmojis';
import { MSC4268HistoryShare } from './MSC4268HistoryShare';
+import { MSC4438MessageBookmarks } from './MSC4438MessageBookmarks';
+import { MessageGrouping } from './MessageGrouping';
function PersonaToggle() {
const [showPersonaSetting, setShowPersonaSetting] = useSetting(
@@ -59,6 +61,8 @@ 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..0751a5578
--- /dev/null
+++ b/src/app/features/settings/experimental/MSC4438MessageBookmarks.tsx
@@ -0,0 +1,57 @@
+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/features/settings/experimental/MessageGrouping.tsx b/src/app/features/settings/experimental/MessageGrouping.tsx
new file mode 100644
index 000000000..95ce8139f
--- /dev/null
+++ b/src/app/features/settings/experimental/MessageGrouping.tsx
@@ -0,0 +1,53 @@
+import { Box, Text, config } from 'folds';
+import { settingsAtom } from '$state/settings';
+import { useSetting } from '$state/hooks/settings';
+import { SequenceCardStyle } from '$features/common-settings/styles.css';
+import { SettingTile } from '$components/setting-tile';
+import { SequenceCard } from '$components/sequence-card';
+
+const THRESHOLD_OPTIONS: { value: number; label: string }[] = [
+ { value: 2, label: '2 min (default)' },
+ { value: 5, label: '5 min' },
+ { value: 15, label: '15 min (Discord-style)' },
+ { value: 30, label: '30 min' },
+ { value: 60, label: '60 min' },
+];
+
+export function MessageGrouping() {
+ const [threshold, setThreshold] = useSetting(settingsAtom, 'messageGroupingThreshold');
+
+ return (
+
+ Message Grouping
+
+ setThreshold(Number(e.target.value))}
+ style={{
+ background: 'var(--bg-surface)',
+ color: 'var(--tc-surface-high)',
+ border: '1px solid var(--bg-surface-border)',
+ borderRadius: config.radii.R300,
+ padding: `${config.space.S100} ${config.space.S200}`,
+ fontSize: config.fontSize.T300,
+ cursor: 'pointer',
+ }}
+ >
+ {THRESHOLD_OPTIONS.map(({ value, label }) => (
+
+ ))}
+
+ }
+ />
+
+
+ );
+}
diff --git a/src/app/features/settings/notifications/PushNotifications.tsx b/src/app/features/settings/notifications/PushNotifications.tsx
index 46c0ebb0d..f86e13ccd 100644
--- a/src/app/features/settings/notifications/PushNotifications.tsx
+++ b/src/app/features/settings/notifications/PushNotifications.tsx
@@ -69,12 +69,14 @@ export async function enablePushNotifications(
},
append: false,
};
- navigator.serviceWorker.controller?.postMessage({
+ const toggleMsg = {
url: mx.baseUrl,
type: 'togglePush',
pusherData,
token: mx.getAccessToken(),
- });
+ };
+ navigator.serviceWorker.controller?.postMessage(toggleMsg);
+ navigator.serviceWorker.ready.then((reg) => reg.active?.postMessage(toggleMsg));
return;
}
@@ -118,12 +120,9 @@ export async function enablePushNotifications(
append: false,
};
- navigator.serviceWorker.controller?.postMessage({
- url: mx.baseUrl,
- type: 'togglePush',
- pusherData,
- token: mx.getAccessToken(),
- });
+ const enableMsg = { url: mx.baseUrl, type: 'togglePush', pusherData, token: mx.getAccessToken() };
+ navigator.serviceWorker.controller?.postMessage(enableMsg);
+ navigator.serviceWorker.ready.then((reg) => reg.active?.postMessage(enableMsg));
}
/**
@@ -144,12 +143,14 @@ export async function disablePushNotifications(
pushkey: pushSubAtom?.keys?.p256dh,
};
- navigator.serviceWorker.controller?.postMessage({
+ const disableMsg = {
url: mx.baseUrl,
type: 'togglePush',
pusherData,
token: mx.getAccessToken(),
- });
+ };
+ navigator.serviceWorker.controller?.postMessage(disableMsg);
+ navigator.serviceWorker.ready.then((reg) => reg.active?.postMessage(disableMsg));
}
export async function deRegisterAllPushers(mx: MatrixClient): Promise {
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/timeline/useProcessedTimeline.ts b/src/app/hooks/timeline/useProcessedTimeline.ts
index 44e9500a2..ea40f764d 100644
--- a/src/app/hooks/timeline/useProcessedTimeline.ts
+++ b/src/app/hooks/timeline/useProcessedTimeline.ts
@@ -26,6 +26,12 @@ export interface UseProcessedTimelineOptions {
* where every reply legitimately has `threadRootId` set to the root.
*/
skipThreadFilter?: boolean;
+ /**
+ * Minutes of inactivity before a new message from the same sender gets a
+ * full user header. Defaults to 2 (the original behaviour). Set higher
+ * (e.g. 15) for Discord-style compact grouping.
+ */
+ messageGroupingThreshold?: number;
}
export interface ProcessedEvent {
@@ -62,6 +68,7 @@ export function useProcessedTimeline({
isReadOnly,
hideMemberInReadOnly,
skipThreadFilter,
+ messageGroupingThreshold = 2,
}: UseProcessedTimelineOptions): ProcessedEvent[] {
return useMemo(() => {
let prevEvent: MatrixEvent | undefined;
@@ -104,6 +111,19 @@ 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.
+ // Also check the effective (decrypted) type for encrypted events that have been decrypted.
+ const effectiveType =
+ type === 'm.room.encrypted' ? (mEvent.getEffectiveEvent()?.type ?? type) : type;
+ if (
+ effectiveType === 'org.matrix.msc3381.poll.response' ||
+ effectiveType === 'org.matrix.msc3381.poll.end' ||
+ effectiveType === 'm.poll.response' ||
+ effectiveType === 'm.poll.end'
+ )
+ return acc;
+
if (!showHiddenEvents) {
const isStandardRendered = [
'm.room.message',
@@ -114,6 +134,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) {
@@ -145,7 +167,8 @@ export function useProcessedTimeline({
if (isMessageEvent) {
const withinTimeThreshold =
- minuteDifference(getPrevTs.call(prevEvent), getEvtTs.call(mEvent)) < 2;
+ minuteDifference(getPrevTs.call(prevEvent), getEvtTs.call(mEvent)) <
+ messageGroupingThreshold;
const senderMatch = getPrevSender.call(prevEvent) === eventSender;
const typeMatch =
normalizeMessageType(getPrevType.call(prevEvent)) === normalizeMessageType(type);
@@ -201,5 +224,6 @@ export function useProcessedTimeline({
isReadOnly,
hideMemberInReadOnly,
skipThreadFilter,
+ messageGroupingThreshold,
]);
}
diff --git a/src/app/hooks/timeline/useTimelineEventRenderer.tsx b/src/app/hooks/timeline/useTimelineEventRenderer.tsx
index 66431b18a..96957193b 100644
--- a/src/app/hooks/timeline/useTimelineEventRenderer.tsx
+++ b/src/app/hooks/timeline/useTimelineEventRenderer.tsx
@@ -59,6 +59,7 @@ import {
Message,
Reactions,
} from '$features/room/message';
+import { PollEvent } from '$features/room/poll';
import { useSableCosmetics } from '$hooks/useSableCosmetics';
@@ -347,6 +348,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) => {
@@ -1148,6 +1228,15 @@ export function useTimelineEventRenderer({
);
},
+ [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/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..a9e82530b 100644
--- a/src/app/hooks/timeline/useTimelineSync.ts
+++ b/src/app/hooks/timeline/useTimelineSync.ts
@@ -352,6 +352,8 @@ export interface UseTimelineSyncOptions {
isAtBottom: boolean;
isAtBottomRef: React.MutableRefObject;
scrollToBottom: (behavior?: 'instant' | 'smooth') => void;
+ /** Called when a loadEventTimeline jump fails so the caller can reveal the live timeline. */
+ onJumpError?: () => void;
unreadInfo: ReturnType;
setUnreadInfo: Dispatch>>;
hideReadsRef: React.MutableRefObject;
@@ -365,6 +367,7 @@ export function useTimelineSync({
isAtBottom,
isAtBottomRef,
scrollToBottom,
+ onJumpError,
unreadInfo,
setUnreadInfo,
hideReadsRef,
@@ -461,7 +464,8 @@ export function useTimelineSync({
if (!alive()) return;
setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines });
scrollToBottom('instant');
- }, [alive, room, scrollToBottom])
+ onJumpError?.();
+ }, [alive, room, scrollToBottom, onJumpError])
);
const lastScrolledAtEventsLengthRef = useRef(eventsLength);
@@ -528,7 +532,7 @@ export function useTimelineSync({
resetAutoScrollPendingRef.current = wasAtBottom;
setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines });
if (wasAtBottom) {
- scrollToBottom('instant');
+ scrollToBottom();
}
}, [room, isAtBottomRef, scrollToBottom])
);
@@ -565,7 +569,7 @@ export function useTimelineSync({
if (eventsLength <= lastScrolledAtEventsLengthRef.current && !resetAutoScrollPending) return;
lastScrolledAtEventsLengthRef.current = eventsLength;
- scrollToBottom('instant');
+ scrollToBottom();
}, [isAtBottom, liveTimelineLinked, eventsLength, scrollToBottom]);
useEffect(() => {
diff --git a/src/app/hooks/useAppVisibility.ts b/src/app/hooks/useAppVisibility.ts
index 7fd5f2325..ba47af279 100644
--- a/src/app/hooks/useAppVisibility.ts
+++ b/src/app/hooks/useAppVisibility.ts
@@ -1,55 +1,283 @@
-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 { getSlidingSyncManager } from '$client/initMatrix';
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 lastEmittedVisibilityRef = useRef(undefined);
+
+ 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;
+
+ 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,
+ hasSwController: !!navigator.serviceWorker?.controller,
+ });
+ return 'sent';
+ },
+ [mx, phase1ForegroundResync, phase2VisibleHeartbeat, phase3AdaptiveBackoffJitter]
+ );
+
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) {
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();
+ // retryImmediately() is a no-op on SlidingSyncSdk — call resend() on the
+ // SlidingSync instance directly to abort a stale long-poll and start fresh.
+ if (mx) getSlidingSyncManager(mx)?.slidingSync.resend();
+
+ 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 handleVisibilityChange = () => {
+ handleVisibilityState(document.visibilityState === 'visible', 'visibilitychange');
+ };
+
+ const handlePageHide = () => {
+ handleVisibilityState(false, 'pagehide');
+ };
+
+ const handleFocus = () => {
+ if (document.visibilityState !== 'visible') return;
+
+ // Always kick the sync loop on focus for the same reason as above.
+ mx?.retryImmediately();
+ if (mx) getSlidingSyncManager(mx)?.slidingSync.resend();
+
+ 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('pagehide', handlePageHide);
+ window.addEventListener('focus', handleFocus);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
+ window.removeEventListener('pagehide', handlePageHide);
+ window.removeEventListener('focus', handleFocus);
};
- }, []);
+ }, [
+ foregroundDebounceMs,
+ mx,
+ phase1ForegroundResync,
+ phase2VisibleHeartbeat,
+ phase3AdaptiveBackoffJitter,
+ pushSessionNow,
+ resumeHeartbeatSuppressMs,
+ ]);
useEffect(() => {
if (!mx) return;
- const handleVisibilityForNotifications = (isVisible: boolean) => {
- togglePusher(mx, clientConfig, isVisible, usePushNotifications, pushSubAtom, isMobile);
+ const runTogglePusher = (isVisible: boolean) => {
+ togglePusher(mx, clientConfig, isVisible, usePushNotifications, pushSubAtom, isMobile).catch(
+ (err) =>
+ debugLog.warn(
+ 'notification',
+ 'togglePusher failed',
+ err instanceof Error ? err : new Error(String(err))
+ )
+ );
};
- appEvents.onVisibilityChange = handleVisibilityForNotifications;
+ // Re-register the pusher on mount so the endpoint is always current after
+ // an app restart, SW update, or browser push-subscription rotation.
+ // togglePusher/enablePushNotifications is idempotent — it reuses the existing
+ // subscription when the endpoint hasn't changed, so this is cheap.
+ runTogglePusher(document.visibilityState === 'visible');
+
+ appEvents.onVisibilityChange = runTogglePusher;
// eslint-disable-next-line consistent-return
return () => {
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/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];
};
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');
+ });
+});
diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts
index e523f15a7..94ace80d2 100644
--- a/src/app/hooks/useClientConfig.ts
+++ b/src/app/hooks/useClientConfig.ts
@@ -5,6 +5,30 @@ 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 +38,9 @@ export type ClientConfig = {
disableAccountSwitcher?: boolean;
hideUsernamePasswordFields?: boolean;
+ experiments?: Record;
+
+ sessionSync?: SessionSyncConfig;
pushNotificationDetails?: {
pushNotifyUrl?: string;
vapidPublicKey?: string;
@@ -43,6 +70,13 @@ export type ClientConfig = {
matrixToBaseUrl?: string;
settingsLinkBaseUrl?: string;
+
+ features?: {
+ polls?: boolean;
+ };
+
+ /** How long (ms) without input before auto-idling presence. 0 = disabled. */
+ presenceAutoIdleTimeoutMs?: number;
};
const ClientConfigContext = createContext(null);
@@ -55,6 +89,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';
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/app/hooks/useNotificationJumper.test.tsx b/src/app/hooks/useNotificationJumper.test.tsx
new file mode 100644
index 000000000..55bde4b48
--- /dev/null
+++ b/src/app/hooks/useNotificationJumper.test.tsx
@@ -0,0 +1,118 @@
+import { ReactNode } from 'react';
+import { act, render } from '@testing-library/react';
+import { Provider, createStore } from 'jotai';
+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),
+ getLiveTimeline: vi.fn(() => ({
+ getState: vi.fn(() => ({
+ getStateEvents: 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 HydratedWrapper({ children }: WrapperProps) {
+ const store = createStore();
+ store.set(activeSessionIdAtom, '@alice:test');
+ store.set(pendingNotificationAtom, { roomId: '!room:test', eventId: '$event:test' });
+ store.set(mDirectAtom, { type: 'INITIALIZE', rooms: new Set() });
+ store.set(roomToParentsAtom, { type: 'INITIALIZE', roomToParents: new Map() });
+
+ 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 43c358317..e04ad818d 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';
@@ -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 navigating with the eventId anyway (triggers historical context load).
+const JUMP_TIMEOUT_MS = 30_000;
+
export function NotificationJumper() {
const [pending, setPending] = useAtom(pendingNotificationAtom);
const activeSessionId = useAtomValue(activeSessionIdAtom);
@@ -27,6 +31,18 @@ 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 jumpTimeoutRef = useRef | undefined>(undefined);
+ const performJumpRef = useRef<() => void>(() => undefined);
+
+ const clearJumpTimeout = useCallback(() => {
+ if (jumpTimeoutRef.current !== undefined) {
+ clearTimeout(jumpTimeoutRef.current);
+ jumpTimeoutRef.current = undefined;
+ }
+ }, []);
const performJump = useCallback(() => {
if (!pending || jumpingRef.current) return;
@@ -52,13 +68,55 @@ export function NotificationJumper() {
const isJoined = room?.getMyMembership() === 'join';
if (isSyncing && isJoined) {
- log.log('jumping to:', pending.roomId, pending.eventId);
+ const liveEvents =
+ room?.getUnfilteredTimelineSet?.()?.getLiveTimeline?.()?.getEvents?.() ?? [];
+ const eventInLive = pending.eventId
+ ? liveEvents.some((event) => event.getId() === pending.eventId)
+ : false;
+
+ // 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();
+ }
+ 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,
+ });
+ return;
+ }
+ log.log('timed out waiting for event in live; falling back to live bottom', {
+ roomId: pending.roomId,
+ eventId: pending.eventId,
+ });
+ }
+
+ // 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;
+ 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);
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 +132,11 @@ export function NotificationJumper() {
getSpaceRoomPath(
getCanonicalAliasOrRoomId(mx, parentSpace),
roomIdOrAlias,
- pending.eventId
+ targetEventId
)
);
} else {
- navigate(getHomeRoomPath(roomIdOrAlias, pending.eventId));
+ navigate(getHomeRoomPath(roomIdOrAlias, targetEventId));
}
}
setPending(null);
@@ -90,19 +148,30 @@ export function NotificationJumper() {
membership: room?.getMyMembership(),
});
}
- }, [pending, activeSessionId, mx, mDirects, roomToParents, navigate, setPending, log]);
+ }, [
+ pending,
+ activeSessionId,
+ mx,
+ mDirects,
+ roomToParents,
+ navigate,
+ setPending,
+ log,
+ clearJumpTimeout,
+ ]);
- // Reset the guard only when pending is replaced (new notification or cleared).
+ // Reset guards only when pending is replaced (new notification or cleared).
useEffect(() => {
+ clearJumpTimeout();
jumpingRef.current = false;
- }, [pending]);
+ jumpStartTimeRef.current = null;
+ }, [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
// arrays. Adding performJump as a dep causes the effect to re-run (and call
// performJump again) on every atom change during an account switch — that is
// the second source of repeated navigation.
- const performJumpRef = useRef(performJump);
performJumpRef.current = performJump;
useSyncState(
@@ -117,13 +186,25 @@ 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
+ useEffect(
+ () => () => {
+ clearJumpTimeout();
+ },
+ [clearJumpTimeout]
+ );
+
return null;
}
diff --git a/src/app/hooks/usePresenceAutoIdle.test.tsx b/src/app/hooks/usePresenceAutoIdle.test.tsx
new file mode 100644
index 000000000..407e7f69c
--- /dev/null
+++ b/src/app/hooks/usePresenceAutoIdle.test.tsx
@@ -0,0 +1,240 @@
+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 type { ReactNode } from 'react';
+import { usePresenceAutoIdle } from './usePresenceAutoIdle';
+
+// -------- 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
+) {
+ usePresenceAutoIdle(mx as any, presenceMode, sendPresence, timeoutMs);
+ return useAtomValue(presenceAutoIdledAtom);
+}
+
+// -------- lifecycle --------
+
+beforeEach(() => {
+ vi.useFakeTimers();
+ vi.clearAllMocks();
+ userListeners.clear();
+ mockUser = makeMockUser();
+ mockMx = makeMockMx();
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+// -------- 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 the document becomes visible again', () => {
+ const { result } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 5000), {
+ wrapper,
+ });
+
+ act(() => {
+ vi.advanceTimersByTime(5000);
+ });
+ expect(result.current).toBe(true);
+
+ const visibilityStateSpy = vi
+ .spyOn(document, 'visibilityState', 'get')
+ .mockReturnValue('visible');
+
+ act(() => {
+ document.dispatchEvent(new Event('visibilitychange'));
+ });
+ expect(result.current).toBe(false);
+
+ visibilityStateSpy.mockRestore();
+ });
+
+ 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('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),
+ { 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('stops responding to focus events after cleanup', () => {
+ const { result, unmount } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 5000), {
+ wrapper,
+ });
+
+ // Go idle.
+ act(() => {
+ vi.advanceTimersByTime(5000);
+ });
+ expect(result.current).toBe(true);
+
+ unmount();
+
+ act(() => {
+ window.dispatchEvent(new Event('focus'));
+ });
+
+ expect(result.current).toBe(true);
+ });
+});
diff --git a/src/app/hooks/usePresenceAutoIdle.ts b/src/app/hooks/usePresenceAutoIdle.ts
new file mode 100644
index 000000000..6dfad4968
--- /dev/null
+++ b/src/app/hooks/usePresenceAutoIdle.ts
@@ -0,0 +1,145 @@
+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;
+const IDLE_CHECK_INTERVAL_MS = 30_000;
+
+/**
+ * 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 intervalRef = useRef(undefined);
+ const lastActivityAtRef = useRef(Date.now());
+
+ const clearTimer = useCallback(() => {
+ if (timerRef.current !== undefined) {
+ window.clearTimeout(timerRef.current);
+ timerRef.current = undefined;
+ }
+ }, []);
+
+ 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);
+ }
+ return undefined;
+ }
+
+ 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(checkIdleDeadline, timeoutMs);
+ };
+
+ const handleBlur = () => {
+ debugLog.info('general', 'Window blurred — keeping idle deadline active');
+ checkIdleDeadline();
+ };
+
+ const handleVisibilityChange = () => {
+ if (document.visibilityState === 'visible') handleActivity();
+ };
+
+ // Start the initial timer.
+ 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();
+ };
+ }, [clearIntervalTimer, 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/hooks/useRoomLastMessage.test.tsx b/src/app/hooks/useRoomLastMessage.test.tsx
new file mode 100644
index 000000000..f8cc9d528
--- /dev/null
+++ b/src/app/hooks/useRoomLastMessage.test.tsx
@@ -0,0 +1,294 @@
+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;
+ effectiveType?: string;
+ encrypted?: boolean;
+}) {
+ const type = overrides.type ?? 'm.room.message';
+ const content = overrides.content ?? { msgtype: 'm.text', body: 'hello' };
+ return {
+ getType: () => type,
+ getContent: () => content,
+ 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;
+}
+
+// -------- 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 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');
+ });
+
+ 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 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();
+ });
+});
+
+// -------- 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], rawDisplayName: 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 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: 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: {} });
+ 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', () => {
+ 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));
+
+ // 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());
+ });
+
+ // 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
new file mode 100644
index 000000000..92b4c3128
--- /dev/null
+++ b/src/app/hooks/useRoomLastMessage.ts
@@ -0,0 +1,165 @@
+import { useEffect, useRef, useState } from 'react';
+import {
+ MatrixClient,
+ MatrixEvent,
+ MatrixEventEvent,
+ MsgType,
+ Room,
+ 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
+ * clients prepend when replying to a message.
+ */
+export function stripReplyFallback(body: string): string {
+ const lines = body.split('\n');
+ let i = 0;
+ 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 += 1;
+ return lines.slice(i).join('\n');
+}
+
+export function eventToPreviewText(ev: MatrixEvent): string | undefined {
+ if (ev.isRedacted()) return undefined;
+
+ // 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 = 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 { msgtype } = content;
+ if (msgtype === MsgType.Text || msgtype === MsgType.Emote || msgtype === MsgType.Notice) {
+ return stripReplyFallback(content.body);
+ }
+ if (msgtype === MsgType.Image) return '📷 Image';
+ 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);
+ 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 =
+ getMemberDisplayName(room, senderId ?? '') ?? displayNameFromMxid(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
+ );
+
+ // Debounce timer ref — cleared on unmount and room change.
+ const debounceRef = useRef | undefined>(undefined);
+
+ useEffect(() => {
+ if (!room || !mx) {
+ setText(undefined);
+ return undefined;
+ }
+
+ 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.
+ room.on(RoomEventEnum.Timeline, update);
+ room.on(RoomEventEnum.LocalEchoUpdated, update);
+
+ 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 () => {
+ clearTimeout(debounceRef.current);
+ room.off(RoomEventEnum.Timeline, update);
+ room.off(RoomEventEnum.LocalEchoUpdated, update);
+ mx.off(MatrixEventEvent.Decrypted, onDecrypted);
+ };
+ }, [room, mx]);
+
+ return text;
+}
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;
}
diff --git a/src/app/hooks/useUserPresence.test.tsx b/src/app/hooks/useUserPresence.test.tsx
new file mode 100644
index 000000000..70ca6b5d2
--- /dev/null
+++ b/src/app/hooks/useUserPresence.test.tsx
@@ -0,0 +1,291 @@
+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 -------
+
+// Each test can override mockUser / mockGetPresence as needed.
+let mockUser: ReturnType | null = null;
+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;
+ } = {}
+) => ({
+ 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 => mockGetPresence()),
+ getUserId: vi.fn<() => string | undefined>(() => undefined),
+ on: vi.fn(),
+ removeListener: vi.fn(),
+};
+
+vi.mock('./useMatrixClient', () => ({
+ useMatrixClient: () => mockMx,
+}));
+
+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 -------
+
+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 = () =>
+ 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 = () =>
+ 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 = () => 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 = () => 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 = () => 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();
+ });
+
+ 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 2c040b989..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 { User, UserEvent, UserEventHandlerMap } from '$types/matrix-sdk';
+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,49 +19,197 @@ 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;
+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: 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,
+ };
+ 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 [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));
useEffect(() => {
- if (!user) {
- setPresence(undefined);
- return undefined;
+ 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.
+ // 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)) {
+ fetchPresenceOnce(mx, userId).then((data) => {
+ if (cancelled || !data) return;
+ setPresence(data);
+ });
}
- setPresence(getUserPresence(user));
- const updatePresence: UserEventHandlerMap[UserEvent.Presence] = (e, u) => {
- if (u.userId === user.userId) {
- setPresence(getUserPresence(user));
+
+ const updatePresence: UserEventHandlerMap[UserEvent.Presence] = (event, u) => {
+ if (u.userId === userId) {
+ setPresence(getUserPresence(u));
}
};
- user.on(UserEvent.Presence, updatePresence);
- user.on(UserEvent.CurrentlyActive, updatePresence);
- user.on(UserEvent.LastPresenceTs, updatePresence);
+ 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 && userId) {
+ 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 () => {
- user.removeListener(UserEvent.Presence, updatePresence);
- user.removeListener(UserEvent.CurrentlyActive, updatePresence);
- user.removeListener(UserEvent.LastPresenceTs, updatePresence);
+ cancelled = true;
+ user?.removeListener(UserEvent.Presence, updatePresence);
+ user?.removeListener(UserEvent.CurrentlyActive, updatePresence);
+ user?.removeListener(UserEvent.LastPresenceTs, updatePresence);
+ removeClientListener?.();
};
- }, [user]);
+ }, [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 =>
useMemo(
() => ({
- [Presence.Online]: 'Active',
- [Presence.Unavailable]: 'Busy',
- [Presence.Offline]: 'Away',
+ [Presence.Online]: 'Online',
+ [Presence.Unavailable]: 'Idle',
+ [Presence.Offline]: 'Offline',
+ [Presence.Dnd]: 'Do Not Disturb',
}),
[]
);
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/BackgroundNotifications.tsx b/src/app/pages/client/BackgroundNotifications.tsx
index 395718223..536e31940 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,
@@ -35,6 +36,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 +112,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;
@@ -323,7 +328,7 @@ export function BackgroundNotifications() {
return;
}
- if (!isNotificationEvent(mEvent)) {
+ if (!isNotificationEvent(mEvent, room, mx.getUserId() ?? undefined)) {
return;
}
@@ -414,6 +419,10 @@ 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 ?? mEvent.getType();
+
notifiedEventsRef.current.add(dedupeId);
// Cap the set so it doesn't grow unbounded
if (notifiedEventsRef.current.size > 200) {
@@ -422,13 +431,13 @@ export function BackgroundNotifications() {
}
const notificationPayload = buildRoomMessageNotification({
- roomName: room.name ?? room.getCanonicalAlias() ?? room.roomId,
+ roomName: getRoomDisplayName(room),
roomAvatar,
username: senderName,
recipientId: session.userId,
previewText: resolveNotificationPreviewText({
content: mEvent.getContent(),
- eventType: mEvent.getType(),
+ eventType: effectiveEventType,
isEncryptedRoom,
showMessageContent: showMessageContentRef.current,
showEncryptedMessageContent: showEncryptedMessageContentRef.current,
@@ -451,6 +460,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' &&
@@ -467,7 +487,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..e25ff531f 100644
--- a/src/app/pages/client/ClientNonUIFeatures.tsx
+++ b/src/app/pages/client/ClientNonUIFeatures.tsx
@@ -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';
@@ -31,6 +33,7 @@ import {
getMemberDisplayName,
getNotificationType,
getStateEvent,
+ getRoomDisplayName,
isDMRoom,
isNotificationEvent,
} from '$utils/room';
@@ -56,6 +59,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';
@@ -103,6 +107,9 @@ function FaviconUpdater() {
const [usePushNotifications] = useSetting(settingsAtom, 'usePushNotifications');
const [faviconForMentionsOnly] = useSetting(settingsAtom, 'faviconForMentionsOnly');
const registration = useAtomValue(registrationAtom);
+ // Track the latest highlight total so the visibilitychange handler can check
+ // it without needing to be inside the roomToUnread effect.
+ const highlightTotalRef = useRef(0);
useEffect(() => {
let notification = false;
@@ -122,6 +129,8 @@ function FaviconUpdater() {
}
});
+ highlightTotalRef.current = highlightTotal;
+
if (highlight) {
setFavicon(LogoHighlightSVG);
} else if (!faviconForMentionsOnly && notification) {
@@ -134,7 +143,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) {
@@ -160,6 +171,25 @@ function FaviconUpdater() {
}
}, [roomToUnread, usePushNotifications, registration, faviconForMentionsOnly]);
+ // Clear the badge whenever the app comes to the foreground with no active
+ // highlights. The main effect above only runs when roomToUnread changes, so
+ // if highlights reached 0 while the app was backgrounded (e.g. read on
+ // another device during a background sync), the badge would stay set until
+ // the next roomToUnread change. This listener closes that gap.
+ useEffect(() => {
+ const handleVisibilityChange = () => {
+ if (document.visibilityState !== 'visible') return;
+ if (highlightTotalRef.current > 0) return;
+ try {
+ navigator.clearAppBadge();
+ } catch {
+ // Badging API not supported — ignore
+ }
+ };
+ document.addEventListener('visibilitychange', handleVisibilityChange);
+ return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
+ }, []);
+
return null;
}
@@ -336,7 +366,12 @@ function MessageNotifications() {
return;
}
- if (!room || isHistoricalEvent || room.isSpaceRoom() || !isNotificationEvent(mEvent)) {
+ if (
+ !room ||
+ isHistoricalEvent ||
+ room.isSpaceRoom() ||
+ !isNotificationEvent(mEvent, room, mx.getUserId() ?? undefined)
+ ) {
return;
}
@@ -409,7 +444,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 +520,7 @@ function MessageNotifications() {
}
const payload = buildRoomMessageNotification({
- roomName: room.name ?? 'Unknown',
+ roomName: getRoomDisplayName(room),
roomAvatar,
username: resolvedSenderName,
previewText,
@@ -500,7 +535,7 @@ function MessageNotifications() {
setInAppBanner({
id: eventId,
title: payload.title,
- roomName: room.name ?? undefined,
+ roomName: getRoomDisplayName(room),
serverName,
senderName: resolvedSenderName,
body: previewText,
@@ -792,7 +827,7 @@ function HandleDecryptPushEvent() {
appVisible: visible,
});
- navigator.serviceWorker.controller?.postMessage({
+ const successReply = {
type: 'pushDecryptResult',
eventId,
success: true,
@@ -801,20 +836,31 @@ function HandleDecryptPushEvent() {
sender_display_name: senderName,
room_name: room?.name ?? '',
visibilityState: document.visibilityState,
- });
+ appFocused: document.hasFocus(),
+ };
+ navigator.serviceWorker.controller?.postMessage(successReply);
+ // Belt-and-suspenders: also post via registration.active so the reply
+ // reaches the SW even when controller is transiently null (e.g., the
+ // window was opened by a notification tap before the SW claimed it, or
+ // during a SW update cycle). The SW deduplicates via decryptionPendingMap
+ // — the second message is a no-op once the entry is already resolved.
+ navigator.serviceWorker.ready.then((reg) => reg.active?.postMessage(successReply));
} catch (err) {
- console.warn('[app] HandleDecryptPushEvent: failed to decrypt push event', err);
+ console.warn('[app] HandleDecryptPushEvent: failed to decrypt push event', err); // eslint-disable-line no-console
pushRelayLog.error(
'notification',
'Push relay decryption failed',
err instanceof Error ? err : new Error(String(err))
);
- navigator.serviceWorker.controller?.postMessage({
+ const errorReply = {
type: 'pushDecryptResult',
eventId,
success: false,
visibilityState: document.visibilityState,
- });
+ appFocused: document.hasFocus(),
+ };
+ navigator.serviceWorker.controller?.postMessage(errorReply);
+ navigator.serviceWorker.ready.then((reg) => reg.active?.postMessage(errorReply));
}
};
@@ -825,17 +871,74 @@ function HandleDecryptPushEvent() {
return null;
}
+// How often an active device re-asserts its online state to the server.
+// Matrix presence is per-user (not per-device): if another device sets you to
+// idle/unavailable, this heartbeat wins the server state back within one interval.
+// Must be shorter than the shortest expected idle timeout (default 5 min).
+const PRESENCE_HEARTBEAT_INTERVAL_MS = 2 * 60_000; // 2 minutes
+
function PresenceFeature() {
const mx = useMatrixClient();
const [sendPresence] = useSetting(settingsAtom, 'sendPresence');
+ const [presenceMode] = useSetting(settingsAtom, 'presenceMode');
+ const autoIdled = useAtomValue(presenceAutoIdledAtom);
+ const clientConfig = useClientConfig();
+ const timeoutMs = clientConfig.presenceAutoIdleTimeoutMs ?? 0;
+
+ usePresenceAutoIdle(mx, presenceMode ?? 'online', sendPresence, timeoutMs);
useEffect(() => {
- // Classic sync: set_presence query param on every /sync poll.
- // Passing undefined restores the default (online); Offline suppresses broadcasting.
+ const effectiveMode = autoIdled ? 'unavailable' : (presenceMode ?? 'online');
+ const activePresence = effectiveMode === 'dnd' ? 'online' : effectiveMode;
+ const effectiveState = sendPresence ? activePresence : '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: '' };
+ }
+
mx.setSyncPresence(sendPresence ? undefined : SetPresence.Offline);
- // Sliding sync: enable/disable the presence extension on the next poll.
getSlidingSyncManager(mx)?.setPresenceEnabled(sendPresence);
- }, [mx, sendPresence]);
+ const presencePayload = {
+ presence: effectiveState,
+ ...statusPayload,
+ };
+ let retryTimer: ReturnType | undefined;
+ const trySetPresence = (attempt = 0) => {
+ mx.setPresence(presencePayload).catch(() => {
+ if (attempt < 3) {
+ retryTimer = setTimeout(() => trySetPresence(attempt + 1), 2000 * (attempt + 1));
+ }
+ });
+ };
+ trySetPresence();
+ return () => {
+ if (retryTimer !== undefined) clearTimeout(retryTimer);
+ };
+ }, [autoIdled, mx, presenceMode, sendPresence]);
+
+ // Presence heartbeat: periodically re-assert online state while this device
+ // is active. Fixes a multi-device race where a different idle device sets the
+ // shared server presence to unavailable while the user is active here.
+ useEffect(() => {
+ const isActiveOnline = sendPresence && !autoIdled && presenceMode === 'online';
+ if (!isActiveOnline) return undefined;
+
+ const heartbeatId = window.setInterval(() => {
+ mx.setPresence({ presence: 'online' }).catch(() => {
+ // Silently ignore — the main effect will retry on next state change.
+ });
+ }, PRESENCE_HEARTBEAT_INTERVAL_MS);
+
+ return () => {
+ window.clearInterval(heartbeatId);
+ };
+ }, [autoIdled, mx, presenceMode, sendPresence]);
return null;
}
@@ -845,11 +948,17 @@ function SettingsSyncFeature() {
return null;
}
+function BookmarksFeature() {
+ useInitBookmarks();
+ return null;
+}
+
export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
useCallSignaling();
return (
<>
+
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/app/pages/client/SyncStatus.tsx b/src/app/pages/client/SyncStatus.tsx
index f55fe5e59..ae222b4f7 100644
--- a/src/app/pages/client/SyncStatus.tsx
+++ b/src/app/pages/client/SyncStatus.tsx
@@ -1,4 +1,5 @@
-import { MatrixClient, SyncState } from '$types/matrix-sdk';
+import type { MatrixClient } from '$types/matrix-sdk';
+import { SyncState } from '$types/matrix-sdk';
import { useCallback, useState } from 'react';
import { Box, config, Line, Text } from 'folds';
import * as Sentry from '@sentry/react';
diff --git a/src/app/pages/client/bookmarks/BookmarksList.tsx b/src/app/pages/client/bookmarks/BookmarksList.tsx
new file mode 100644
index 000000000..a24b661c8
--- /dev/null
+++ b/src/app/pages/client/bookmarks/BookmarksList.tsx
@@ -0,0 +1,627 @@
+import { FormEventHandler, Fragment, 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,
+ useBookmarkDeletedList,
+ 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) => (
+
+ ))}
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// 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
+// ---------------------------------------------------------------------------
+
+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 deletedBookmarks = useBookmarkDeletedList();
+ const loading = useBookmarkLoading();
+ const { remove, restore } = 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 handleRestore = useCallback(
+ async (item: BookmarkItemContent) => {
+ await restore(item);
+ },
+ [restore]
+ );
+
+ 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 && }
+
+
+ ))}
+
+ )}
+
+ {deletedBookmarks.length > 0 && !filterTerm && (
+
+
+
+
+ {deletedBookmarks.map((item) => (
+
+ ))}
+
+
+ )}
+
+
+
+
+
+ {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/direct/Direct.tsx b/src/app/pages/client/direct/Direct.tsx
index 11eae40c3..e2fb4c319 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();
@@ -186,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
@@ -205,6 +208,7 @@ export function Direct() {
});
return () => {
+ clearTimeout(activityTimerRef.current);
directsSetRef.current.forEach((roomId) => {
const room = mx.getRoom(roomId);
room?.off(RoomEvent.Timeline, handleTimeline);
@@ -230,6 +234,7 @@ export function Direct() {
getScrollElement: () => scrollRef.current,
estimateSize: () => 38,
overscan: 10,
+ getItemKey: (index) => sortedDirects[index],
});
const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) =>
@@ -287,7 +292,7 @@ export function Direct() {
return (
;
}
diff --git a/src/app/pages/client/home/Home.tsx b/src/app/pages/client/home/Home.tsx
index c25d99e30..699a8159d 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';
@@ -199,10 +204,15 @@ 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();
const searchSelected = useHomeSearchSelected();
+ const bookmarksSelected = useHomeBookmarksSelected();
+ const [enableMessageBookmarks] = useSetting(settingsAtom, 'enableMessageBookmarks');
+ const showBookmarks = enableMessageBookmarks;
const noRoomToDisplay = rooms.length === 0;
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
@@ -227,6 +237,7 @@ export function Home() {
getScrollElement: () => scrollRef.current,
estimateSize: () => 38,
overscan: 10,
+ getItemKey: (index) => sortedRooms[index],
});
const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) =>
@@ -236,83 +247,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/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 (
+
+
+
+
+
+
+
+
+ Bookmarks
+
+
+
+
+
+
+ );
+}
+
export function Inbox() {
useNavToActivePathMapper('inbox');
const notificationsSelected = useInboxNotificationsSelected();
+ const [enableMessageBookmarks] = useSetting(settingsAtom, 'enableMessageBookmarks');
+ const showBookmarks = enableMessageBookmarks;
return (
@@ -75,6 +110,7 @@ export function Inbox() {
+ {showBookmarks && }
diff --git a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx
index 6e6ecc572..4c3838007 100644
--- a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx
+++ b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx
@@ -1,4 +1,4 @@
-import { MouseEvent, MouseEventHandler, useCallback, useState } from 'react';
+import { MouseEvent, MouseEventHandler, ReactNode, useCallback, useState } from 'react';
import {
Box,
Button,
@@ -40,14 +40,18 @@ 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 { Presence } 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';
import { UnreadBadge, UnreadBadgeCenter } from '$components/unread-badge';
+import { useSetting } from '$state/hooks/settings';
+import { settingsAtom, presenceAutoIdledAtom } from '$state/settings';
const log = createLogger('AccountSwitcherTab');
const debugLog = createDebugLogger('AccountSwitcherTab');
@@ -173,6 +177,19 @@ export function AccountSwitcherTab() {
const myUserId = mx.getUserId() ?? '';
const activeProfile = useUserProfile(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 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 = ;
+ }
const activeAvatarUrl = activeProfile.avatarUrl
? (mxcUrlToHttp(mx, activeProfile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined)
: undefined;
@@ -270,19 +287,21 @@ export function AccountSwitcherTab() {
{(triggerRef) => (
- 1}
- >
- {nameInitials(label)}}
- />
-
+
+ 1}
+ >
+ {nameInitials(label)}}
+ />
+
+
)}
{(totalBackgroundUnread > 0 || anyBackgroundHighlight) && (
@@ -352,6 +371,59 @@ export function AccountSwitcherTab() {
Add Account
+
+ Status
+
+ {(
+ [
+ { 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(({ label: statusLabel, desc, mode }) => {
+ const isSelected = sendPresence && (presenceMode ?? 'online') === mode;
+ const badge = ;
+ return (
+
+ ) : undefined
+ }
+ 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);
+ setMenuAnchor(undefined);
+ }}
+ >
+
+ {statusLabel}
+ {desc && (
+
+ {desc}
+
+ )}
+
+
+ );
+ })}
+