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/fix-timeline-scroll-regressions.md b/.changeset/fix-timeline-scroll-regressions.md
new file mode 100644
index 000000000..892cf3ed0
--- /dev/null
+++ b/.changeset/fix-timeline-scroll-regressions.md
@@ -0,0 +1,5 @@
+---
+default: patch
+---
+
+Fix timeline scroll regressions: stay-at-bottom, Jump to Latest button flicker, phantom unread dot, and blank notification page recovery
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/perf-timeline-item-memo.md b/.changeset/perf-timeline-item-memo.md
new file mode 100644
index 000000000..1471e3d0a
--- /dev/null
+++ b/.changeset/perf-timeline-item-memo.md
@@ -0,0 +1,5 @@
+---
+default: patch
+---
+
+Memoize individual VList timeline items to prevent mass re-renders when unrelated state changes (e.g. typing indicators, read receipts, or new messages while not at the bottom).
diff --git a/.changeset/perf-timeline-scroll-cache.md b/.changeset/perf-timeline-scroll-cache.md
new file mode 100644
index 000000000..259a0dd79
--- /dev/null
+++ b/.changeset/perf-timeline-scroll-cache.md
@@ -0,0 +1,5 @@
+---
+default: patch
+---
+
+Cache VList item heights across room visits to restore scroll position instantly and skip the 80 ms opacity-fade stabilisation timer on revisit.
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/message/content/VideoContent.tsx b/src/app/components/message/content/VideoContent.tsx
index 71613c598..2b36f2813 100644
--- a/src/app/components/message/content/VideoContent.tsx
+++ b/src/app/components/message/content/VideoContent.tsx
@@ -110,9 +110,12 @@ export const VideoContent = as<'div', VideoContentProps>(
if (autoPlay) loadSrc();
}, [autoPlay, loadSrc]);
+ const hasDimensions = typeof info?.w === 'number' && typeof info?.h === 'number';
+
return (
setIsHovered(true)}
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..6610ea819 100644
--- a/src/app/features/room/RoomTimeline.tsx
+++ b/src/app/features/room/RoomTimeline.tsx
@@ -1,6 +1,7 @@
import {
Fragment,
ReactNode,
+ memo,
useCallback,
useEffect,
useLayoutEffect,
@@ -13,6 +14,13 @@ 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 {
+ roomScrollCache,
+ RoomScrollCache,
+ RoomScrollFingerprint,
+ RoomScrollPosition,
+} from '$utils/roomScrollCache';
import {
as,
Box,
@@ -79,6 +87,51 @@ import { ProcessedEvent, useProcessedTimeline } from '$hooks/timeline/useProcess
import { useTimelineEventRenderer } from '$hooks/timeline/useTimelineEventRenderer';
import * as css from './RoomTimeline.css';
+/** Render function type passed to the memoized TimelineItem via a ref. */
+type TimelineRenderFn = (eventData: ProcessedEvent) => ReactNode;
+
+/**
+ * Renders one timeline item. Defined outside RoomTimeline so React never
+ * recreates the component type, and wrapped in `memo` so it skips re-renders
+ * when neither the event data nor any per-item volatile state changed.
+ *
+ * The actual rendering is delegated to `renderRef.current` (always the latest
+ * version of `renderMatrixEvent`, set synchronously during each render cycle)
+ * so stale-closure issues are avoided.
+ *
+ * The custom `areEqual` comparator checks `data`, `isHighlighted`, `isEditing`,
+ * `isReplying`, `isOpenThread`, and `settingsEpoch` — re-rendering only the
+ * specific item whose volatile state changed.
+ */
+/* eslint-disable react/no-unused-prop-types -- props consumed in areEqual, not component body */
+interface TimelineItemProps {
+ data: ProcessedEvent;
+ renderRef: React.MutableRefObject;
+ isHighlighted: boolean;
+ isEditing: boolean;
+ isReplying: boolean;
+ isOpenThread: boolean;
+ settingsEpoch: object;
+}
+/* eslint-enable react/no-unused-prop-types */
+
+// Declared outside memo() so the callback receives a reference, not an inline
+// function expression (satisfies prefer-arrow-callback).
+function TimelineItemInner({ data, renderRef }: TimelineItemProps) {
+ return <>{renderRef.current?.(data)}>;
+}
+const TimelineItem = memo(
+ TimelineItemInner,
+ (prev, next) =>
+ prev.data === next.data &&
+ prev.isHighlighted === next.isHighlighted &&
+ prev.isEditing === next.isEditing &&
+ prev.isReplying === next.isReplying &&
+ prev.isOpenThread === next.isOpenThread &&
+ prev.settingsEpoch === next.settingsEpoch
+);
+TimelineItem.displayName = 'TimelineItem';
+
const TimelineFloat = as<'div', css.TimelineFloatVariants>(
({ position, className, ...props }, ref) => (
{
return timeDayMonthYear(ts);
};
+const SCROLL_SETTLE_MS = 250;
+const MIN_INITIAL_SCROLL_ROOM_PX = 300;
+
+const TIMELINE_ANCHOR_SELECTOR = '[data-timeline-event-id]';
+const buildRoomScrollFingerprint = (
+ eventIds: string[],
+ readUptoEventId: string | undefined,
+ layoutKey: string
+): RoomScrollFingerprint => ({
+ eventCount: eventIds.length,
+ headEventIds: eventIds.slice(0, 5),
+ tailEventIds: eventIds.slice(-5),
+ readUptoEventId,
+ layoutKey,
+});
+
+
export type RoomTimelineProps = {
room: Room;
eventId?: string;
@@ -124,6 +194,7 @@ export function RoomTimeline({
onEditLastMessageRef,
}: Readonly) {
const mx = useMatrixClient();
+ const initialUnreadInfo = getRoomUnreadInfo(room, true);
const alive = useAlive();
const { editId, handleEdit } = useMessageEdit(editor, { onReset: onEditorReset, alive });
@@ -143,12 +214,13 @@ export function RoomTimeline({
const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
const [showTombstoneEvents] = useSetting(settingsAtom, 'showTombstoneEvents');
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
- const [reducedMotion] = useSetting(settingsAtom, 'reducedMotion');
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
const [autoplayStickers] = useSetting(settingsAtom, 'autoplayStickers');
const [autoplayEmojis] = useSetting(settingsAtom, 'autoplayEmojis');
const [hideMemberInReadOnly] = useSetting(settingsAtom, 'hideMembershipInReadOnly');
+ const [reducedMotion] = useSetting(settingsAtom, 'reducedMotion');
+ const [messageGroupingThreshold] = useSetting(settingsAtom, 'messageGroupingThreshold');
const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
const showClientUrlPreview = room.hasEncryptionStateEvent()
@@ -170,7 +242,7 @@ export function RoomTimeline({
return myPowerLevel < sendLevel;
}, [powerLevels, mx]);
- const [unreadInfo, setUnreadInfo] = useState(() => getRoomUnreadInfo(room, true));
+ const [unreadInfo, setUnreadInfo] = useState(initialUnreadInfo);
const readUptoEventIdRef = useRef(undefined);
if (unreadInfo) readUptoEventIdRef.current = unreadInfo.readUptoEventId;
@@ -178,6 +250,7 @@ export function RoomTimeline({
hideReadsRef.current = hideReads;
const prevViewportHeightRef = useRef(0);
+ const prevScrollSizeRef = useRef(0);
const messageListRef = useRef(null);
const mediaAuthentication = useMediaAuthentication();
@@ -201,7 +274,10 @@ export function RoomTimeline({
const setOpenThread = useSetAtom(openThreadAtom);
const vListRef = useRef(null);
- const [atBottomState, setAtBottomState] = useState(true);
+ const scrollCacheForRoomRef = useRef(undefined);
+ const [atBottomState, setAtBottomState] = useState(
+ eventId ? false : !initialUnreadInfo?.scrollTo
+ );
const atBottomRef = useRef(atBottomState);
const setAtBottom = useCallback((val: boolean) => {
setAtBottomState(val);
@@ -212,8 +288,8 @@ export function RoomTimeline({
const [topSpacerHeight, setTopSpacerHeight] = useState(0);
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,20 +298,40 @@ 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 the viewport isn't yet filled
+ // by backward pagination. A recovery effect watches backwardStatus/eventsLength
+ // and reveals content once pagination is idle and the viewport is filled.
+ const readyBlockedByPaginationRef = useRef(false);
const currentRoomIdRef = useRef(room.roomId);
+ const restoreExactOffsetRef = useRef(false);
+ const currentScrollFingerprintRef = useRef(undefined);
+ 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) {
+ const nextUnreadInfo = getRoomUnreadInfo(room, true);
+ scrollCacheForRoomRef.current = undefined;
hasInitialScrolledRef.current = false;
- mountScrollWindowRef.current = Date.now() + 3000;
currentRoomIdRef.current = room.roomId;
pendingReadyRef.current = false;
+ readyBlockedByPaginationRef.current = false;
+ restoreExactOffsetRef.current = false;
if (initialScrollTimerRef.current !== undefined) {
clearTimeout(initialScrollTimerRef.current);
initialScrollTimerRef.current = undefined;
}
setIsReady(false);
+ // Reset per-room scroll/layout state so the new room starts clean.
+ setAtBottom(eventId ? false : !nextUnreadInfo?.scrollTo);
+ setShift(false);
+ setTopSpacerHeight(0);
+ topSpacerHeightRef.current = 0;
+ setUnreadInfo(nextUnreadInfo);
}
const processedEventsRef = useRef([]);
@@ -245,8 +341,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 +355,7 @@ export function RoomTimeline({
isAtBottom: atBottomState,
isAtBottomRef: atBottomRef,
scrollToBottom,
+ onJumpError: handleJumpError,
unreadInfo,
setUnreadInfo,
hideReadsRef,
@@ -285,6 +386,71 @@ export function RoomTimeline({
return events.indexOf(match);
}, []);
+ const getProcessedIndexByEventId = useCallback((targetEventId: string): number | undefined => {
+ const index = processedEventsRef.current.findIndex((event) => event.id === targetEventId);
+ return index >= 0 ? index : undefined;
+ }, []);
+
+ const getRenderedAnchorPosition = useCallback((): RoomScrollPosition | undefined => {
+ const container = messageListRef.current;
+ if (!container) return undefined;
+
+ const containerTop = container.getBoundingClientRect().top;
+ const anchors = Array.from(container.querySelectorAll(TIMELINE_ANCHOR_SELECTOR));
+ const firstVisibleAnchor = anchors.find(
+ (anchor) => anchor.getBoundingClientRect().bottom > containerTop
+ );
+ if (!firstVisibleAnchor) return undefined;
+
+ const anchorEventId = firstVisibleAnchor.dataset.timelineEventId;
+ if (!anchorEventId) return undefined;
+
+ return {
+ kind: 'anchor',
+ eventId: anchorEventId,
+ offset: firstVisibleAnchor.getBoundingClientRect().top - containerTop,
+ };
+ }, []);
+
+ const restoreRoomScrollPosition = useCallback(
+ (position: RoomScrollPosition, exactOffset: boolean) => {
+ const v = vListRef.current;
+ if (!v) return false;
+
+ if (position.kind === 'live') {
+ scrollToBottom();
+ return true;
+ }
+
+ const processedIndex = getProcessedIndexByEventId(position.eventId);
+ if (processedIndex === undefined) return false;
+
+ v.scrollToIndex(processedIndex, { align: 'start' });
+
+ if (!exactOffset) return true;
+
+ requestAnimationFrame(() => {
+ const container = messageListRef.current;
+ if (!container) return;
+
+ const anchor = Array.from(
+ container.querySelectorAll(TIMELINE_ANCHOR_SELECTOR)
+ ).find((element) => element.dataset.timelineEventId === position.eventId);
+ if (!anchor) return;
+
+ const currentOffset =
+ anchor.getBoundingClientRect().top - container.getBoundingClientRect().top;
+ const delta = currentOffset - position.offset;
+ if (Math.abs(delta) > 2) {
+ vListRef.current?.scrollTo(v.scrollOffset + delta);
+ }
+ });
+
+ return true;
+ },
+ [getProcessedIndexByEventId, scrollToBottom]
+ );
+
useLayoutEffect(() => {
if (
!eventId &&
@@ -298,6 +464,30 @@ export function RoomTimeline({
timelineSync.liveTimelineLinked &&
vListRef.current
) {
+ hasInitialScrolledRef.current = true;
+ const savedCache = !unreadInfo?.scrollTo ? scrollCacheForRoomRef.current : undefined;
+ if (savedCache) {
+ if (processedEventsRef.current.length === 0) {
+ pendingReadyRef.current = true;
+ restoreExactOffsetRef.current =
+ savedCache.measurementCache !== undefined &&
+ savedCache.fingerprint.layoutKey === currentScrollFingerprintRef.current?.layoutKey;
+ } else {
+ const restored = restoreRoomScrollPosition(
+ savedCache.position,
+ savedCache.measurementCache !== undefined &&
+ savedCache.fingerprint.layoutKey === currentScrollFingerprintRef.current?.layoutKey
+ );
+ if (restored) {
+ setAtBottom(savedCache.position.kind === 'live');
+ setIsReady(true);
+ return;
+ }
+ }
+ }
+
+ // Scroll to bottom, then wait 80 ms for VList to finish measuring item
+ // heights before revealing the timeline.
vListRef.current.scrollToIndex(processedEventsRef.current.length - 1, { align: 'end' });
// Store in a ref rather than a local so subsequent eventsLength changes
// (e.g. the onLifecycle timeline reset firing within 80 ms) do NOT
@@ -305,21 +495,39 @@ 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;
}
}, 80);
- hasInitialScrolledRef.current = true;
}
// No cleanup return — the timer must survive eventsLength fluctuations.
// It is cancelled on unmount by the dedicated effect below.
- }, [timelineSync.eventsLength, timelineSync.liveTimelineLinked, eventId, room.roomId]);
+ }, [
+ eventId,
+ room.roomId,
+ restoreRoomScrollPosition,
+ setAtBottom,
+ timelineSync.eventsLength,
+ timelineSync.liveTimelineLinked,
+ unreadInfo?.scrollTo,
+ ]);
// Cancel the initial-scroll timer on unmount (the useLayoutEffect above
// intentionally does not cancel it when deps change).
@@ -337,9 +545,35 @@ export function RoomTimeline({
useLayoutEffect(() => {
if (!isReady) return;
if (timelineSync.eventsLength > 0) return;
+ // The SDK may have already added events to the new timeline but React state
+ // hasn't caught up yet (e.g. useLiveTimelineRefresh deferred via microtask).
+ // Check the SDK's live timeline directly before blanking to avoid a double
+ // skeleton cycle (skeleton→content→blank→skeleton→content).
+ if (room.getLiveTimeline().getEvents().length > 0) return;
setIsReady(false);
+ readyBlockedByPaginationRef.current = 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;
@@ -371,7 +605,9 @@ export function RoomTimeline({
prevBackwardStatusRef.current = timelineSync.backwardStatus;
if (timelineSync.backwardStatus === 'loading') {
wasAtBottomBeforePaginationRef.current = atBottomRef.current;
- if (!atBottomRef.current) setShift(true);
+ // Always anchor during backward pagination — even when at the bottom — so
+ // prepended items don't cause a visible jump (e.g. sliding-sync fill).
+ setShift(true);
} else if (prev === 'loading' && timelineSync.backwardStatus === 'idle') {
setShift(false);
if (wasAtBottomBeforePaginationRef.current) {
@@ -381,35 +617,72 @@ export function RoomTimeline({
}, [timelineSync.backwardStatus]);
useEffect(() => {
- let timeoutId: ReturnType | undefined;
+ let cancelled = false;
+ let outerRafId: ReturnType | undefined;
+ let clearId: ReturnType | undefined;
+
if (timelineSync.focusItem) {
if (timelineSync.focusItem.scrollTo && vListRef.current) {
const processedIndex = getRawIndexToProcessedIndex(timelineSync.focusItem.index);
if (processedIndex !== undefined) {
vListRef.current.scrollToIndex(processedIndex, { align: 'center' });
+ // Setting scrollTo=false triggers effect cleanup then re-run; the reveal
+ // rAFs below restart from the new run so Virtua has time to settle first.
timelineSync.setFocusItem((prev) => (prev ? { ...prev, scrollTo: false } : undefined));
}
}
- timeoutId = setTimeout(() => {
- timelineSync.setFocusItem(undefined);
- }, 2000);
+
+ // Reveal the timeline and begin the highlight timer after two frames so
+ // Virtua has completed the scroll before the content becomes visible.
+ outerRafId = requestAnimationFrame(() => {
+ if (cancelled) return;
+ requestAnimationFrame(() => {
+ if (cancelled) return;
+ setIsReady(true);
+ clearId = setTimeout(() => {
+ timelineSync.setFocusItem(undefined);
+ }, 2000);
+ });
+ });
}
+
return () => {
- if (timeoutId !== undefined) clearTimeout(timeoutId);
+ cancelled = true;
+ if (outerRafId !== undefined) cancelAnimationFrame(outerRafId);
+ if (clearId !== undefined) clearTimeout(clearId);
};
- }, [timelineSync.focusItem, timelineSync, reducedMotion, getRawIndexToProcessedIndex]);
+ }, [timelineSync.focusItem, timelineSync, getRawIndexToProcessedIndex]);
+ // Recovery: if event timeline load failed and fell back to live timeline,
+ // reveal the timeline so the user doesn't see a blank page.
+ // Skip when focusItem is set — that means loadEventTimeline succeeded and
+ // the success effects (415–419) already handle setIsReady.
useEffect(() => {
- if (timelineSync.focusItem) {
+ if (
+ eventId &&
+ !isReady &&
+ !timelineSync.focusItem &&
+ timelineSync.liveTimelineLinked &&
+ timelineSync.eventsLength > 0
+ ) {
+ scrollToBottom();
setIsReady(true);
}
- }, [timelineSync.focusItem]);
+ }, [
+ eventId,
+ isReady,
+ timelineSync.focusItem,
+ timelineSync.liveTimelineLinked,
+ timelineSync.eventsLength,
+ scrollToBottom,
+ ]);
useEffect(() => {
if (!eventId) return;
setIsReady(false);
+ setAtBottom(false);
timelineSyncRef.current.loadEventTimeline(eventId);
- }, [eventId, room.roomId]);
+ }, [eventId, room.roomId, setAtBottom]);
useEffect(() => {
if (eventId) return;
@@ -430,6 +703,7 @@ export function RoomTimeline({
: undefined;
if (absoluteIndex !== undefined) {
+ setAtBottom(false);
const processedIndex = getRawIndexToProcessedIndex(absoluteIndex);
if (processedIndex !== undefined && vListRef.current) {
vListRef.current.scrollToIndex(processedIndex, { align: 'start' });
@@ -448,6 +722,7 @@ export function RoomTimeline({
eventId,
isReady,
getRawIndexToProcessedIndex,
+ setAtBottom,
]);
useEffect(() => {
@@ -593,6 +868,52 @@ export function RoomTimeline({
utils: { htmlReactParserOptions, linkifyOpts, getMemberPowerTag, parseMemberEvent },
});
+ // Render function ref — updated synchronously each render so TimelineItem
+ // always calls the latest version (which has the current focusItem, editId,
+ // etc. in its closure) without needing to be a prop dep.
+ const renderFnRef = useRef(null);
+ renderFnRef.current = (eventData: ProcessedEvent) =>
+ renderMatrixEvent(
+ eventData.mEvent.getType(),
+ typeof eventData.mEvent.getStateKey() === 'string',
+ eventData.id,
+ eventData.mEvent,
+ eventData.itemIndex,
+ eventData.timelineSet,
+ eventData.collapsed
+ );
+
+ // Object whose identity changes when any global render-affecting setting
+ // changes. TimelineItem memo sees the new reference and re-renders all items.
+ const settingsEpoch = useMemo(
+ () => ({}),
+ // Any setting that changes how ALL items are rendered should be listed here.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [
+ messageLayout,
+ messageSpacing,
+ hideReads,
+ showDeveloperTools,
+ hour24Clock,
+ dateFormatString,
+ mediaAutoLoad,
+ showBundledPreview,
+ showUrlPreview,
+ showClientUrlPreview,
+ autoplayStickers,
+ hideMemberInReadOnly,
+ isReadOnly,
+ hideMembershipEvents,
+ hideNickAvatarEvents,
+ showHiddenEvents,
+ reducedMotion,
+ nicknames,
+ imagePackRooms,
+ htmlReactParserOptions,
+ linkifyOpts,
+ ]
+ );
+
const tryAutoMarkAsRead = useCallback(() => {
if (!readUptoEventIdRef.current) {
requestAnimationFrame(() => markAsRead(mx, room.roomId, hideReads));
@@ -631,10 +952,51 @@ 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);
}
+ if (!eventId) {
+ saveRoomScrollStateRef.current?.(v.cache, isNowAtBottom);
+ }
+
if (offset < 500 && canPaginateBackRef.current && backwardStatusRef.current === 'idle') {
timelineSyncRef.current.handleTimelinePagination(true);
}
@@ -646,13 +1008,19 @@ export function RoomTimeline({
timelineSyncRef.current.handleTimelinePagination(false);
}
},
- [setAtBottom]
+ [eventId, setAtBottom]
);
const showLoadingPlaceholders =
timelineSync.eventsLength === 0 &&
(!isReady || timelineSync.canPaginateBack || timelineSync.backwardStatus === 'loading');
+ // Show a skeleton overlay while content is hidden for measurement.
+ // The VList still renders underneath at opacity 0 so it can measure real
+ // item heights — the overlay just gives the user something to look at.
+ const showSkeletonOverlay =
+ !isReady && !scrollCacheForRoomRef.current?.measurementCache && !showLoadingPlaceholders;
+
let backPaginationJSX: ReactNode | undefined;
if (timelineSync.canPaginateBack || timelineSync.backwardStatus !== 'idle') {
if (timelineSync.backwardStatus === 'error') {
@@ -724,13 +1092,58 @@ export function RoomTimeline({
: timelineSync.eventsLength;
const vListIndices = useMemo(
() => Array.from({ length: vListItemCount }, (_, i) => i),
+ // timelineSync.timeline.linkedTimelines: recompute when the timeline structure
+ // changes (pagination, room switch). timelineSync.mutationVersion: recompute
+ // when event content mutates (reactions, edits) without changing the count.
+ // Using the linkedTimelines reference (not the timeline wrapper object) means
+ // a setTimeline spread for a live event arrival does NOT recompute this — the
+ // eventsLength / vListItemCount change already covers that case.
// eslint-disable-next-line react-hooks/exhaustive-deps
- [vListItemCount, timelineSync.timeline]
+ [vListItemCount, timelineSync.timeline.linkedTimelines, timelineSync.mutationVersion]
+ );
+
+ const scrollLayoutKey = useMemo(
+ () =>
+ [
+ messageLayout,
+ messageSpacing,
+ hideReads,
+ hideMembershipEvents,
+ hideNickAvatarEvents,
+ showHiddenEvents,
+ showTombstoneEvents,
+ mediaAutoLoad,
+ showBundledPreview,
+ showUrlPreview,
+ showClientUrlPreview,
+ autoplayStickers,
+ autoplayEmojis,
+ hideMemberInReadOnly,
+ isReadOnly,
+ ].join(':'),
+ [
+ messageLayout,
+ messageSpacing,
+ hideReads,
+ hideMembershipEvents,
+ hideNickAvatarEvents,
+ showHiddenEvents,
+ showTombstoneEvents,
+ mediaAutoLoad,
+ showBundledPreview,
+ showUrlPreview,
+ showClientUrlPreview,
+ autoplayStickers,
+ autoplayEmojis,
+ hideMemberInReadOnly,
+ isReadOnly,
+ ]
);
const processedEvents = useProcessedTimeline({
items: vListIndices,
linkedTimelines: timelineSync.timeline.linkedTimelines,
+ mutationVersion: timelineSync.mutationVersion,
ignoredUsersSet,
showHiddenEvents,
showTombstoneEvents,
@@ -740,10 +1153,51 @@ export function RoomTimeline({
hideNickAvatarEvents,
isReadOnly,
hideMemberInReadOnly,
+ messageGroupingThreshold,
});
processedEventsRef.current = processedEvents;
+ const currentScrollFingerprint = useMemo(
+ () =>
+ buildRoomScrollFingerprint(
+ processedEvents.map((event) => event.id),
+ unreadInfo?.readUptoEventId,
+ scrollLayoutKey
+ ),
+ [processedEvents, scrollLayoutKey, unreadInfo?.readUptoEventId]
+ );
+ currentScrollFingerprintRef.current = currentScrollFingerprint;
+
+ if (!eventId) {
+ scrollCacheForRoomRef.current = roomScrollCache.load(
+ mx.getUserId()!,
+ room.roomId,
+ currentScrollFingerprint
+ );
+ } else {
+ scrollCacheForRoomRef.current = undefined;
+ }
+
+ const saveRoomScrollState = useCallback(
+ (measurementCache: RoomScrollCache['measurementCache'], atBottom: boolean) => {
+ if (eventId) return;
+
+ const position = atBottom
+ ? ({ kind: 'live' } as RoomScrollPosition)
+ : getRenderedAnchorPosition();
+ if (!position) return;
+
+ roomScrollCache.save(mx.getUserId()!, room.roomId, {
+ measurementCache,
+ position,
+ fingerprint: currentScrollFingerprint,
+ });
+ },
+ [currentScrollFingerprint, eventId, getRenderedAnchorPosition, mx, room.roomId]
+ );
+ saveRoomScrollStateRef.current = saveRoomScrollState;
+
// Recovery: if the 80 ms initial-scroll timer fired while processedEvents was
// empty (timeline was mid-reset), scroll to bottom and reveal the timeline once
// events repopulate. Fires on every processedEvents.length change but is
@@ -752,9 +1206,16 @@ export function RoomTimeline({
if (!pendingReadyRef.current) return;
if (processedEvents.length === 0) return;
pendingReadyRef.current = false;
- vListRef.current?.scrollToIndex(processedEvents.length - 1, { align: 'end' });
+ const savedCache = !unreadInfo?.scrollTo ? scrollCacheForRoomRef.current : undefined;
+ const restored = savedCache
+ ? restoreRoomScrollPosition(savedCache.position, restoreExactOffsetRef.current)
+ : false;
+ restoreExactOffsetRef.current = false;
+ if (!restored) {
+ vListRef.current?.scrollToIndex(processedEvents.length - 1, { align: 'end' });
+ }
setIsReady(true);
- }, [processedEvents.length]);
+ }, [processedEvents.length, restoreRoomScrollPosition, unreadInfo?.scrollTo]);
useEffect(() => {
if (!onEditLastMessageRef) return;
@@ -808,7 +1269,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);
@@ -851,12 +1312,37 @@ export function RoomTimeline({
minHeight: 0,
overflow: 'hidden',
position: 'relative',
- opacity: isReady || showLoadingPlaceholders ? 1 : 0,
}}
>
+ {showSkeletonOverlay && (
+
+ {Array.from({ length: 8 }, (_, i) => (
+
+ {messageLayout === MessageLayout.Compact ? (
+
+ ) : (
+
+ )}
+
+ ))}
+
+ )}
+ key={room.roomId}
ref={vListRef}
data={processedEvents}
+ cache={!eventId ? scrollCacheForRoomRef.current?.measurementCache : undefined}
shift={shift}
className={css.messageList}
style={{
@@ -866,6 +1352,7 @@ export function RoomTimeline({
flexDirection: 'column',
paddingTop: topSpacerHeight > 0 ? topSpacerHeight : config.space.S600,
paddingBottom: config.space.S600,
+ opacity: isReady || showLoadingPlaceholders ? 1 : 0,
}}
onScroll={handleVListScroll}
>
@@ -901,14 +1388,19 @@ export function RoomTimeline({
return ;
}
- const renderedEvent = renderMatrixEvent(
- eventData.mEvent.getType(),
- typeof eventData.mEvent.getStateKey() === 'string',
- eventData.id,
- eventData.mEvent,
- eventData.itemIndex,
- eventData.timelineSet,
- eventData.collapsed
+ const renderedEvent = (
+
);
const dividers = (
@@ -947,17 +1439,26 @@ export function RoomTimeline({
)}
{backPaginationJSX}
- {dividers}
- {renderedEvent}
+
+ {dividers}
+ {renderedEvent}
+
);
}
return (
-
+
{dividers}
{renderedEvent}
-
+
);
}}
diff --git a/src/app/features/room/RoomView.tsx b/src/app/features/room/RoomView.tsx
index 421561616..a3a96b921 100644
--- a/src/app/features/room/RoomView.tsx
+++ b/src/app/features/room/RoomView.tsx
@@ -157,7 +157,6 @@ export function RoomView({ eventId }: { eventId?: string }) {
)}
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.test.ts b/src/app/hooks/timeline/useProcessedTimeline.test.ts
new file mode 100644
index 000000000..abd1f2244
--- /dev/null
+++ b/src/app/hooks/timeline/useProcessedTimeline.test.ts
@@ -0,0 +1,306 @@
+import { renderHook } from '@testing-library/react';
+import { describe, it, expect } from 'vitest';
+import type { EventTimeline, EventTimelineSet, MatrixEvent } from '$types/matrix-sdk';
+import { useProcessedTimeline } from './useProcessedTimeline';
+
+// ---------------------------------------------------------------------------
+// Minimal fakes
+// ---------------------------------------------------------------------------
+
+function makeEvent(
+ id: string,
+ opts: {
+ sender?: string;
+ type?: string;
+ ts?: number;
+ content?: Record;
+ } = {}
+): MatrixEvent {
+ const {
+ sender = '@alice:test',
+ type = 'm.room.message',
+ ts = 1_000,
+ content = { body: 'hello' },
+ } = opts;
+ return {
+ getId: () => id,
+ getSender: () => sender,
+ isRedacted: () => false,
+ getTs: () => ts,
+ getType: () => type,
+ threadRootId: undefined,
+ getContent: () => content,
+ getRelation: () => null,
+ isRedaction: () => false,
+ } as unknown as MatrixEvent;
+}
+
+const fakeTimelineSet = {} as EventTimelineSet;
+
+function makeTimeline(events: MatrixEvent[]): EventTimeline {
+ return {
+ getEvents: () => events,
+ getTimelineSet: () => fakeTimelineSet,
+ } as unknown as EventTimeline;
+}
+
+/** Default options — keeps tests concise; individual tests override what they need. */
+const defaults = {
+ ignoredUsersSet: new Set(),
+ showHiddenEvents: false,
+ showTombstoneEvents: false,
+ mxUserId: '@alice:test',
+ readUptoEventId: undefined,
+ hideMembershipEvents: false,
+ hideNickAvatarEvents: false,
+ isReadOnly: false,
+ hideMemberInReadOnly: false,
+} as const;
+
+// ---------------------------------------------------------------------------
+// Helpers to derive `items` from a linked-timeline list
+// index 0 = first event in first timeline, etc.
+// ---------------------------------------------------------------------------
+function makeItems(count: number, startIndex = 0): number[] {
+ return Array.from({ length: count }, (_, i) => startIndex + i);
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe('useProcessedTimeline', () => {
+ it('returns an empty array when there are no events', () => {
+ const { result } = renderHook(() =>
+ useProcessedTimeline({
+ ...defaults,
+ items: [],
+ linkedTimelines: [makeTimeline([])],
+ })
+ );
+ expect(result.current).toHaveLength(0);
+ });
+
+ it('returns one ProcessedEvent per visible event', () => {
+ const events = [makeEvent('$e1'), makeEvent('$e2'), makeEvent('$e3')];
+ const timeline = makeTimeline(events);
+
+ const { result } = renderHook(() =>
+ useProcessedTimeline({
+ ...defaults,
+ items: makeItems(3),
+ linkedTimelines: [timeline],
+ })
+ );
+
+ expect(result.current).toHaveLength(3);
+ expect(result.current[0].id).toBe('$e1');
+ expect(result.current[2].id).toBe('$e3');
+ });
+
+ it('collapses consecutive messages from the same sender within 2 minutes', () => {
+ const events = [
+ makeEvent('$e1', { ts: 1_000 }),
+ makeEvent('$e2', { ts: 60_000 }), // same sender, ~1 min later
+ ];
+ const timeline = makeTimeline(events);
+
+ const { result } = renderHook(() =>
+ useProcessedTimeline({
+ ...defaults,
+ items: makeItems(2),
+ linkedTimelines: [timeline],
+ })
+ );
+
+ expect(result.current[1].collapsed).toBe(true);
+ });
+
+ it('does NOT collapse messages from the same sender more than 2 minutes apart', () => {
+ const events = [
+ makeEvent('$e1', { ts: 1_000 }),
+ makeEvent('$e2', { ts: 3 * 60_000 }), // 3 min later
+ ];
+ const timeline = makeTimeline(events);
+
+ const { result } = renderHook(() =>
+ useProcessedTimeline({
+ ...defaults,
+ items: makeItems(2),
+ linkedTimelines: [timeline],
+ })
+ );
+
+ expect(result.current[1].collapsed).toBe(false);
+ });
+
+ // -------------------------------------------------------------------------
+ // Stable-ref optimisation
+ // -------------------------------------------------------------------------
+
+ it('reuses the same ProcessedEvent reference when nothing changed (stable-ref)', () => {
+ const events = [makeEvent('$e1'), makeEvent('$e2')];
+ const timeline = makeTimeline(events);
+ const items = makeItems(2);
+
+ const { result, rerender } = renderHook(
+ ({ ver }) =>
+ useProcessedTimeline({
+ ...defaults,
+ items,
+ linkedTimelines: [timeline],
+ mutationVersion: ver,
+ }),
+ { initialProps: { ver: 0 } }
+ );
+
+ const firstRender = result.current;
+
+ // Re-render with the same mutationVersion — refs should be reused
+ rerender({ ver: 0 });
+
+ expect(result.current[0]).toBe(firstRender[0]);
+ expect(result.current[1]).toBe(firstRender[1]);
+ });
+
+ it('creates fresh ProcessedEvent objects when mutationVersion increments', () => {
+ const events = [makeEvent('$e1'), makeEvent('$e2')];
+ const timeline = makeTimeline(events);
+ const items = makeItems(2);
+
+ const { result, rerender } = renderHook(
+ ({ ver }) =>
+ useProcessedTimeline({
+ ...defaults,
+ items,
+ linkedTimelines: [timeline],
+ mutationVersion: ver,
+ }),
+ { initialProps: { ver: 0 } }
+ );
+
+ const firstRender = result.current;
+
+ // Bump mutation version — stale refs must not be reused
+ rerender({ ver: 1 });
+
+ expect(result.current[0]).not.toBe(firstRender[0]);
+ expect(result.current[1]).not.toBe(firstRender[1]);
+ });
+
+ it('creates fresh ProcessedEvent objects when itemIndex shifts after back-pagination', () => {
+ // Initial: one event at index 0
+ const existingEvent = makeEvent('$existing');
+ const timelineV1 = makeTimeline([existingEvent]);
+
+ const { result, rerender } = renderHook(
+ ({ linkedTimelines, items }: { linkedTimelines: EventTimeline[]; items: number[] }) =>
+ useProcessedTimeline({
+ ...defaults,
+ items,
+ linkedTimelines,
+ mutationVersion: 0, // unchanged — only the itemIndex changes
+ }),
+ {
+ initialProps: {
+ linkedTimelines: [timelineV1],
+ items: [0],
+ },
+ }
+ );
+
+ const firstRef = result.current[0];
+ expect(firstRef.id).toBe('$existing');
+ expect(firstRef.itemIndex).toBe(0);
+
+ // Back-pagination prepends a new event at the front — existing event now at index 1
+ const newEvent = makeEvent('$new');
+ const timelineV2 = makeTimeline([newEvent, existingEvent]);
+
+ rerender({ linkedTimelines: [timelineV2], items: [0, 1] });
+
+ const existingProcessed = result.current.find((e) => e.id === '$existing')!;
+ // itemIndex must be 1 (updated), NOT 0 (stale from previous render)
+ expect(existingProcessed.itemIndex).toBe(1);
+ // And it must be a new object, not the stale cached ref
+ expect(existingProcessed).not.toBe(firstRef);
+ });
+
+ it('filters events from ignored users', () => {
+ const events = [
+ makeEvent('$e1', { sender: '@alice:test' }),
+ makeEvent('$e2', { sender: '@ignored:test' }),
+ makeEvent('$e3', { sender: '@alice:test' }),
+ ];
+ const timeline = makeTimeline(events);
+
+ const { result } = renderHook(() =>
+ useProcessedTimeline({
+ ...defaults,
+ items: makeItems(3),
+ linkedTimelines: [timeline],
+ ignoredUsersSet: new Set(['@ignored:test']),
+ })
+ );
+
+ const ids = result.current.map((e) => e.id);
+ expect(ids).not.toContain('$e2');
+ expect(ids).toContain('$e1');
+ expect(ids).toContain('$e3');
+ });
+
+ it('places willRenderNewDivider on the event immediately after readUptoEventId', () => {
+ const events = [
+ makeEvent('$read', { sender: '@bob:test', ts: 1_000 }),
+ makeEvent('$new', { sender: '@bob:test', ts: 2_000 }),
+ ];
+ const timeline = makeTimeline(events);
+
+ const { result } = renderHook(() =>
+ useProcessedTimeline({
+ ...defaults,
+ items: makeItems(2),
+ linkedTimelines: [timeline],
+ mxUserId: '@alice:test', // different from sender so divider renders
+ readUptoEventId: '$read',
+ })
+ );
+
+ expect(result.current[1].willRenderNewDivider).toBe(true);
+ });
+
+ it('places willRenderDayDivider between events on different calendar days', () => {
+ const DAY = 86_400_000;
+ const events = [
+ makeEvent('$e1', { ts: 1_000 }),
+ makeEvent('$e2', { ts: 1_000 + DAY + 1 }), // next day
+ ];
+ const timeline = makeTimeline(events);
+
+ const { result } = renderHook(() =>
+ useProcessedTimeline({
+ ...defaults,
+ items: makeItems(2),
+ linkedTimelines: [timeline],
+ })
+ );
+
+ expect(result.current[1].willRenderDayDivider).toBe(true);
+ });
+
+ it('deduplicates overlapping event IDs across linked timelines', () => {
+ const shared = makeEvent('$shared', { ts: 2_000 });
+ const earlier = makeEvent('$earlier', { ts: 1_000 });
+ const later = makeEvent('$later', { ts: 3_000 });
+
+ const { result } = renderHook(() =>
+ useProcessedTimeline({
+ ...defaults,
+ items: makeItems(4),
+ linkedTimelines: [makeTimeline([earlier, shared]), makeTimeline([shared, later])],
+ })
+ );
+
+ expect(result.current.map((event) => event.id)).toEqual(['$earlier', '$shared', '$later']);
+ });
+});
diff --git a/src/app/hooks/timeline/useProcessedTimeline.ts b/src/app/hooks/timeline/useProcessedTimeline.ts
index 44e9500a2..891d57548 100644
--- a/src/app/hooks/timeline/useProcessedTimeline.ts
+++ b/src/app/hooks/timeline/useProcessedTimeline.ts
@@ -1,4 +1,4 @@
-import { useMemo } from 'react';
+import { useMemo, useRef } from 'react';
import { MatrixEvent, EventTimelineSet, EventTimeline } from '$types/matrix-sdk';
import {
getTimelineAndBaseIndex,
@@ -26,6 +26,21 @@ export interface UseProcessedTimelineOptions {
* where every reply legitimately has `threadRootId` set to the root.
*/
skipThreadFilter?: boolean;
+ /**
+ * Increment this whenever existing event content mutates (reactions, edits,
+ * thread updates, local-echo). When it changes, `useProcessedTimeline`
+ * creates fresh `ProcessedEvent` objects so downstream `React.memo` item
+ * components re-render to reflect updated content. When unchanged (e.g. a
+ * new event was appended), existing objects are reused by identity, letting
+ * memo bail out for unchanged items.
+ */
+ mutationVersion: number;
+ /**
+ * 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,8 +77,24 @@ export function useProcessedTimeline({
isReadOnly,
hideMemberInReadOnly,
skipThreadFilter,
+ mutationVersion,
+ messageGroupingThreshold = 2,
}: UseProcessedTimelineOptions): ProcessedEvent[] {
+ // Stable-ref cache: reuse the same ProcessedEvent object for an event when
+ // nothing structural changed. This lets React.memo on item components bail
+ // out for the majority of items when only a new message was appended.
+ const stableRefsCache = useRef
} />
} />
+ } />
} />
} />
+ } />
} />
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}
+
+ )}
+
+
+ );
+ })}
+