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/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  Cloudflare Workers (dev → production config)`, + ``, + `| Status | URL | Commit | Alias | Updated (UTC) |`, + `| - | - | - | - | - |`, + tableRow, + ].join("\n"); + + await core.summary.addRaw(comment).write(); diff --git a/.github/workflows/cloudflare-web-preview.yml b/.github/workflows/cloudflare-web-preview.yml index 8b93a4bb9..d7df99897 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' 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 7ec719709..ab23d31ea 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ build.sh # the following line was added with nvim by Shea because its annoying to clear every so often .vscode/bookmarks.json +.vscode/launch.json diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 434432fea..c58bff3e3 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,3 +1,28 @@ { - "recommendations": ["webpro.vscode-knip", "oxc.oxc-vscode"] + "recommendations": [ + // JS/TS toolchain + "webpro.vscode-knip", + "oxc.oxc-vscode", + "usernamehw.errorlens", + "christian-kohler.path-intellisense", + "styled-components.vscode-styled-components", + "bradlc.vscode-tailwindcss", + // React/TypeScript + "dsznajder.es7-react-js-snippets", + "formulahendry.auto-rename-tag", + "wix.vscode-import-cost", + // i18n + "lokalise.i18n-ally", + // Testing + "vitest.explorer", + // Infrastructure + "hashicorp.terraform", + "zamerick.vscode-caddyfile-syntax", + // Documentation + "streetsidesoftware.code-spell-checker", + "davidanson.vscode-markdownlint", + // Quality of Life + "gruntfuggly.todo-tree", + "wayou.vscode-todo-highlight" + ] } diff --git a/.vscode/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 f2ceb43b5..bf5196aad 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -12,5 +12,22 @@ }, "[json]": { "editor.defaultFormatter": "oxc.oxc-vscode" + }, + // i18n Ally configuration + "i18n-ally.localesPaths": ["public/locales"], + "i18n-ally.keystyle": "nested", + "i18n-ally.enabledFrameworks": ["react", "i18next"], + "i18n-ally.namespace": true, + "i18n-ally.pathMatcher": "{locale}.json", + // Error Lens configuration + "errorLens.enabled": true, + // Import Cost configuration + "importCost.bundleSizeDecoration": "both", + "importCost.showCalculatingDecoration": true, + // Todo Tree configuration + "todo-tree.general.tags": ["TODO", "FIXME", "HACK", "XXX", "NOTE", "BUG"], + "todo-tree.highlights.defaultHighlight": { + "icon": "alert", + "type": "text" } } 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 1bdffb675..027ad7c15 100644 --- a/config.json +++ b/config.json @@ -1,22 +1,37 @@ { "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", + + "presenceAutoIdleTimeoutMs": 300000, + "slidingSync": { "enabled": true }, + "sessionSync": { + "phase1ForegroundResync": true, + "phase2VisibleHeartbeat": true, + "phase3AdaptiveBackoffJitter": true + }, + "featuredCommunities": { "openAsDefault": false, "spaces": [ @@ -37,9 +52,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/knope.toml b/knope.toml index fc533824c..9be54d79d 100644 --- a/knope.toml +++ b/knope.toml @@ -62,7 +62,7 @@ help_text = "Create a new change file to be included in the next release" type = "CreateChangeFile" [github] -owner = "SableClient" +owner = "Just-Insane" repo = "Sable" [release_notes] 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/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/features/bookmarks/BookmarksList.tsx b/src/app/features/bookmarks/BookmarksList.tsx new file mode 100644 index 000000000..0e18279ef --- /dev/null +++ b/src/app/features/bookmarks/BookmarksList.tsx @@ -0,0 +1,137 @@ +import { Avatar, Box, Icon, IconButton, Icons, Scroll, Text, config } from 'folds'; +import { useBookmarks, toggleBookmark } from '$hooks/useBookmarks'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { useRoomNavigate } from '$hooks/useRoomNavigate'; +import { useGetRoom, useAllJoinedRoomsSet } from '$hooks/useGetRoom'; +import { getRoomAvatarUrl } from '$utils/room'; +import { nameInitials } from '$utils/common'; +import { RoomAvatar } from '$components/room-avatar'; +import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; + +type BookmarksListProps = { + onNavigate?: () => void; +}; + +export function BookmarksList({ onNavigate }: BookmarksListProps) { + const mx = useMatrixClient(); + const bookmarks = useBookmarks(); + const { navigateRoom } = useRoomNavigate(); + const useAuthentication = useMediaAuthentication(); + const allRoomsSet = useAllJoinedRoomsSet(); + const getRoom = useGetRoom(allRoomsSet); + + const handleOpen = (roomId: string, eventId: string) => { + navigateRoom(roomId, eventId); + onNavigate?.(); + }; + + const handleRemove = (roomId: string, eventId: string) => { + toggleBookmark(mx, roomId, eventId, bookmarks).catch(() => {}); + }; + + if (bookmarks.length === 0) { + return ( + + + + No bookmarks yet + + + Bookmark messages from the message menu to save them here. + + + ); + } + + return ( + + + {bookmarks.map((bookmark) => { + const room = getRoom(bookmark.room_id); + const event = room + ?.getTimelineForEvent(bookmark.event_id) + ?.getEvents() + .find((e) => e.getId() === bookmark.event_id); + + const senderDisplayName = event + ? (room?.getMember(event.getSender() ?? '')?.name ?? event.getSender() ?? 'Unknown') + : 'Unknown'; + const body = (event?.getContent() as Record | undefined)?.body as + | string + | undefined; + const preview = body + ? body.length > 100 + ? `${body.slice(0, 100)}…` + : body + : 'Encrypted or unknown message'; + + return ( + handleOpen(bookmark.room_id, bookmark.event_id)} + as="button" + > + + {room ? ( + ( + + {nameInitials(room.name)} + + )} + /> + ) : ( + + )} + + + + + {room?.name ?? bookmark.room_id} + + + {senderDisplayName} + + + + {preview} + + + { + e.stopPropagation(); + handleRemove(bookmark.room_id, bookmark.event_id); + }} + aria-label="Remove bookmark" + > + + + + ); + })} + + + ); +} diff --git a/src/app/features/bookmarks/BookmarksPanel.tsx b/src/app/features/bookmarks/BookmarksPanel.tsx new file mode 100644 index 000000000..d9b9c0462 --- /dev/null +++ b/src/app/features/bookmarks/BookmarksPanel.tsx @@ -0,0 +1,195 @@ +import { + Avatar, + Box, + Dialog, + Header, + Icon, + IconButton, + Icons, + Overlay, + OverlayBackdrop, + OverlayCenter, + Scroll, + Text, + config, +} from 'folds'; +import FocusTrap from 'focus-trap-react'; +import { useAtom } from 'jotai'; +import { useBookmarks, toggleBookmark } from '$hooks/useBookmarks'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { bookmarksPanelAtom } from '$state/bookmarksPanelAtom'; +import { useRoomNavigate } from '$hooks/useRoomNavigate'; +import { useGetRoom } from '$hooks/useGetRoom'; +import { allRoomsAtom } from '$state/room-list/roomList'; +import { useAllJoinedRoomsSet } from '$hooks/useGetRoom'; +import { getRoomAvatarUrl } from '$utils/room'; +import { nameInitials } from '$utils/common'; +import { RoomAvatar } from '$components/room-avatar'; +import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; +import { stopPropagation } from '$utils/keyboard'; + +export { BookmarksList } from './BookmarksList'; + +type BookmarksPanelProps = { + requestClose: () => void; +}; + +function BookmarksPanel({ requestClose }: BookmarksPanelProps) { + const mx = useMatrixClient(); + const bookmarks = useBookmarks(); + const { navigateRoom } = useRoomNavigate(); + const useAuthentication = useMediaAuthentication(); + const allRoomsSet = useAllJoinedRoomsSet(); + const getRoom = useGetRoom(allRoomsSet); + + const handleOpen = (roomId: string, eventId: string) => { + navigateRoom(roomId, eventId); + requestClose(); + }; + + const handleRemove = (roomId: string, eventId: string) => { + toggleBookmark(mx, roomId, eventId, bookmarks).catch(() => {}); + }; + + return ( + +
+ + + Bookmarks + + + + +
+ + + + {bookmarks.length === 0 && ( + + + + No bookmarks yet + + + Bookmark messages from the message menu to save them here. + + + )} + {bookmarks.map((bookmark) => { + const room = getRoom(bookmark.room_id); + const event = room?.getTimelineForEvent(bookmark.event_id) + ?.getEvents() + .find((e) => e.getId() === bookmark.event_id); + + const senderDisplayName = event + ? (room?.getMember(event.getSender() ?? '')?.name ?? event.getSender() ?? 'Unknown') + : 'Unknown'; + const body = (event?.getContent() as Record | undefined)?.body as string | undefined; + const preview = body ? (body.length > 80 ? `${body.slice(0, 80)}…` : body) : 'Encrypted or unknown message'; + + return ( + handleOpen(bookmark.room_id, bookmark.event_id)} + as="button" + > + + {room ? ( + ( + + {nameInitials(room.name)} + + )} + /> + ) : ( + + )} + + + + + {room?.name ?? bookmark.room_id} + + + {senderDisplayName} + + + + {preview} + + + { + e.stopPropagation(); + handleRemove(bookmark.room_id, bookmark.event_id); + }} + aria-label="Remove bookmark" + > + + + + ); + })} + + + +
+ ); +} + +export function BookmarksPanelRenderer() { + const [opened, setOpen] = useAtom(bookmarksPanelAtom); + + if (!opened) return null; + + const close = () => setOpen(false); + + return ( + }> + + + + + + + ); +} diff --git a/src/app/features/common-settings/developer-tools/DevelopTools.tsx b/src/app/features/common-settings/developer-tools/DevelopTools.tsx index d35e20da7..bb03e3bd4 100644 --- a/src/app/features/common-settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/common-settings/developer-tools/DevelopTools.tsx @@ -63,6 +63,22 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) { const [expandStateType, setExpandStateType] = useState(); const [openStateEvent, setOpenStateEvent] = useState(); const [composeEvent, setComposeEvent] = useState<{ type?: string; stateKey?: string }>(); + const [rotateSessionStatus, setRotateSessionStatus] = useState< + 'idle' | 'rotating' | 'done' | 'error' + >('idle'); + + const handleRotateSessions = useCallback(async () => { + const crypto = mx.getCrypto(); + if (!crypto) return; + setRotateSessionStatus('rotating'); + try { + await crypto.forceDiscardSession(room.roomId); + crypto.prepareToEncrypt(room); + setRotateSessionStatus('done'); + } catch { + setRotateSessionStatus('error'); + } + }, [mx, room]); const [expandAccountData, setExpandAccountData] = useState(false); const [accountDataType, setAccountDataType] = useState(); @@ -233,6 +249,40 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) { /> )} + {developerTools && room.hasEncryptionStateEvent() && ( + + + + {rotateSessionStatus === 'rotating' ? 'Rotating…' : 'Rotate'} + + + } + /> + + )} {developerTools && ( diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index a372b975a..46a1d64b0 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -72,6 +72,7 @@ import { useAutoDiscoveryInfo } from '$hooks/useAutoDiscoveryInfo'; import { livekitSupport } from '$hooks/useLivekitSupport'; import { Presence, useUserPresence } from '$hooks/useUserPresence'; import { AvatarPresence, PresenceBadge } from '$components/presence'; +import { useRoomLastMessagePreview } from '$hooks/useRoomLastMessagePreview'; import { RoomNavUser } from './RoomNavUser'; /** @@ -292,6 +293,9 @@ export function RoomNavItem({ const getRoomTopic = useRoomTopic(room); const roomTopic = direct ? ((customDMCards && getRoomTopic) ?? presence?.status) : undefined; + const [showRoomMessagePreview] = useSetting(settingsAtom, 'showRoomMessagePreview'); + const lastMessagePreview = useRoomLastMessagePreview(room, !direct); + const { navigateRoom } = useRoomNavigate(); const navigate = useNavigate(); const screenSize = useScreenSizeContext(); @@ -453,6 +457,21 @@ export function RoomNavItem({ {roomTopic} )} + {!roomTopic && showRoomMessagePreview && lastMessagePreview && ( + + {lastMessagePreview.senderDisplayName + ? `${lastMessagePreview.senderDisplayName}: ${lastMessagePreview.body}` + : lastMessagePreview.body} + + )} {!optionsVisible && !unread && !selected && typingMember.length > 0 && ( diff --git a/src/app/features/room/MembersDrawer.tsx b/src/app/features/room/MembersDrawer.tsx index f751bcf31..442c51b30 100644 --- a/src/app/features/room/MembersDrawer.tsx +++ b/src/app/features/room/MembersDrawer.tsx @@ -26,7 +26,7 @@ import { useVirtualizer } from '@tanstack/react-virtual'; import classNames from 'classnames'; import { AvatarPresence, PresenceBadge } from '$components/presence'; -import { useUserPresence } from '$hooks/useUserPresence'; +import { Presence, useUserPresence } from '$hooks/useUserPresence'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { UseStateProvider } from '$components/UseStateProvider'; import type { SearchItemStrGetter, UseAsyncSearchOptions } from '$hooks/useAsyncSearch'; @@ -151,7 +151,7 @@ function MemberItem({ > ) : undefined } diff --git a/src/app/features/room/PollContent.tsx b/src/app/features/room/PollContent.tsx new file mode 100644 index 000000000..1a555ba08 --- /dev/null +++ b/src/app/features/room/PollContent.tsx @@ -0,0 +1,184 @@ +import type { MatrixEvent, Relations, Room } from '$types/matrix-sdk'; +import { useCallback, useEffect, useState } from 'react'; +import { Box, Button, Text, config } from 'folds'; +import { M_POLL_KIND_DISCLOSED, M_POLL_RESPONSE, M_POLL_START } from 'matrix-js-sdk/lib/@types/polls'; +import type { PollAnswer } from 'matrix-js-sdk/lib/@types/polls'; +import { M_TEXT } from 'matrix-js-sdk/lib/@types/extensible_events'; +import { PollEvent as PollModelEvent } from 'matrix-js-sdk/lib/models/poll'; +import type { Poll } from 'matrix-js-sdk/lib/models/poll'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { tallyCounts } from '$hooks/usePollTally'; + +type PollContentProps = { + mEvent: MatrixEvent; + room: Room; +}; + +function getAnswerText(answer: PollAnswer): string { + const raw = answer as unknown as Record; + return ( + (raw[M_TEXT.name] as string | undefined) ?? + (raw[M_TEXT.altName] as string | undefined) ?? + '' + ); +} + +export function PollContent({ mEvent, room }: PollContentProps) { + const mx = useMatrixClient(); + const content = mEvent.getContent(); + + const pollRaw = ( + content[M_POLL_START.name] ?? content[M_POLL_START.altName] + ) as Record | undefined; + + const question: string = (() => { + if (!pollRaw) return '(Poll)'; + const q = pollRaw.question as Record | undefined; + if (!q) return '(Poll)'; + return ( + (q[M_TEXT.name] as string | undefined) ?? + (q[M_TEXT.altName] as string | undefined) ?? + '(Poll)' + ); + })(); + + const answers = (pollRaw?.answers as PollAnswer[] | undefined) ?? []; + const maxSelections = (pollRaw?.max_selections as number | undefined) ?? 1; + const kind = (pollRaw?.kind as string | undefined) ?? M_POLL_KIND_DISCLOSED.name; + const isDisclosed = + kind === M_POLL_KIND_DISCLOSED.name || kind === M_POLL_KIND_DISCLOSED.altName; + + const eventId = mEvent.getId() ?? ''; + const roomId = room.roomId; + const myUserId = mx.getUserId() ?? ''; + + const [relations, setRelations] = useState(undefined); + + useEffect(() => { + const roomWithPolls = room as unknown as { polls: Map }; + const poll = roomWithPolls.polls.get(eventId); + if (!poll) return; + + poll + .getResponses() + .then((rels) => setRelations(rels)) + .catch(() => {}); + + const onResponses = (rels: Relations) => setRelations(rels); + poll.on(PollModelEvent.Responses, onResponses); + return () => { + poll.off(PollModelEvent.Responses, onResponses); + }; + }, [room, eventId]); + + const tally = tallyCounts(answers, relations, myUserId, maxSelections); + + const handleVote = useCallback( + async (answerId: string) => { + const isSelected = tally.myAnswers.includes(answerId); + let newAnswers: string[]; + if (maxSelections === 1) { + newAnswers = isSelected ? [] : [answerId]; + } else { + newAnswers = isSelected + ? tally.myAnswers.filter((id) => id !== answerId) + : [...tally.myAnswers, answerId].slice(0, maxSelections); + } + + const voteContent: Record = { + [M_POLL_RESPONSE.name]: { answers: newAnswers }, + [M_POLL_RESPONSE.altName]: { answers: newAnswers }, + 'm.relates_to': { + rel_type: 'm.reference', + event_id: eventId, + }, + }; + + type SendEventContent = Parameters[3]; + await ( + mx as unknown as { + sendEvent( + roomId: string, + threadId: null, + eventType: string, + content: SendEventContent + ): Promise; + } + ).sendEvent( + roomId, + null, + M_POLL_RESPONSE.name, + voteContent as unknown as SendEventContent + ); + }, + [mx, roomId, eventId, tally.myAnswers, maxSelections] + ); + + return ( + + + {question} + + + {isDisclosed ? 'Poll · Results visible while voting' : 'Poll · Results hidden until ended'} + + + {answers.map((answer, idx) => { + const text = getAnswerText(answer); + const isSelected = tally.myAnswers.includes(answer.id); + const voteCount = tally.counts.get(answer.id) ?? 0; + const pct = + tally.totalVoters > 0 ? Math.round((voteCount / tally.totalVoters) * 100) : 0; + + return ( + + + + ); + })} + + {tally.totalVoters > 0 && ( + + {tally.totalVoters} {tally.totalVoters === 1 ? 'vote' : 'votes'} + + )} + + ); +} diff --git a/src/app/features/room/PollCreator.tsx b/src/app/features/room/PollCreator.tsx new file mode 100644 index 000000000..f26201a46 --- /dev/null +++ b/src/app/features/room/PollCreator.tsx @@ -0,0 +1,299 @@ +import { useCallback, useRef, useState } from 'react'; +import FocusTrap from 'focus-trap-react'; +import { + Box, + Button, + Dialog, + Header, + Icon, + IconButton, + Icons, + Input, + Overlay, + OverlayBackdrop, + OverlayCenter, + Scroll, + Switch, + Text, + config, +} from 'folds'; +import { PollStartEvent } from 'matrix-js-sdk/lib/extensible_events_v1/PollStartEvent'; +import { + M_POLL_KIND_DISCLOSED, + M_POLL_KIND_UNDISCLOSED, +} from 'matrix-js-sdk/lib/@types/polls'; +import type { Room } from '$types/matrix-sdk'; +import { useMatrixClient } from '$hooks/useMatrixClient'; + + +const MIN_ANSWERS = 2; +const MAX_ANSWERS = 20; + +let answerIdSeed = 0; +function newId(): string { + answerIdSeed += 1; + return `a${answerIdSeed}`; +} + +type AnswerDraft = { id: string; text: string }; + +type PollCreatorProps = { + room: Room; + onClose: () => void; +}; + +export function PollCreator({ room, onClose }: PollCreatorProps) { + const mx = useMatrixClient(); + + const [question, setQuestion] = useState(''); + const [answers, setAnswers] = useState([ + { id: newId(), text: '' }, + { id: newId(), text: '' }, + ]); + const [multiSelect, setMultiSelect] = useState(false); + const [maxSelections, setMaxSelections] = useState(2); + const [disclosed, setDisclosed] = useState(true); + const [sending, setSending] = useState(false); + const [error, setError] = useState(); + + const lastInputRef = useRef(null); + + const handleAddAnswer = useCallback(() => { + if (answers.length >= MAX_ANSWERS) return; + setAnswers((prev) => [...prev, { id: newId(), text: '' }]); + requestAnimationFrame(() => lastInputRef.current?.focus()); + }, [answers.length]); + + const handleRemoveAnswer = useCallback( + (id: string) => { + if (answers.length <= MIN_ANSWERS) return; + setAnswers((prev) => prev.filter((a) => a.id !== id)); + }, + [answers.length] + ); + + const handleAnswerChange = useCallback((id: string, text: string) => { + setAnswers((prev) => prev.map((a) => (a.id === id ? { ...a, text } : a))); + }, []); + + const handleMultiSelectToggle = useCallback((v: boolean) => { + setMultiSelect(v); + if (v) setMaxSelections(2); + }, []); + + const handleSend = useCallback(async () => { + const q = question.trim(); + if (!q) { + setError('Please enter a question.'); + return; + } + const validAnswers = answers.map((a) => a.text.trim()).filter(Boolean); + if (validAnswers.length < MIN_ANSWERS) { + setError(`Please fill in at least ${MIN_ANSWERS} answer options.`); + return; + } + + const kind = disclosed ? M_POLL_KIND_DISCLOSED : M_POLL_KIND_UNDISCLOSED; + const maxSel = multiSelect ? Math.max(2, Math.min(maxSelections, validAnswers.length)) : 1; + const pollEvent = PollStartEvent.from(q, validAnswers, kind, maxSel); + const serialized = pollEvent.serialize(); + + setSending(true); + setError(undefined); + try { + type SendEventContent = Parameters[3]; + await ( + mx as unknown as { + sendEvent( + roomId: string, + threadId: null, + eventType: string, + content: SendEventContent + ): Promise; + } + ).sendEvent( + room.roomId, + null, + serialized.type, + serialized.content as unknown as SendEventContent + ); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to send poll.'); + setSending(false); + } + }, [question, answers, multiSelect, maxSelections, disclosed, mx, room.roomId, onClose]); + + return ( + }> + + + +
+ + + Create Poll + + + + +
+ + + + + {/* Question */} + + Question + setQuestion((e.target as HTMLInputElement).value)} + maxLength={340} + /> + + + {/* Answers */} + + Options + {answers.map((ans, idx) => ( + + + handleAnswerChange(ans.id, (e.target as HTMLInputElement).value)} + maxLength={340} + /> + + handleRemoveAnswer(ans.id)} + variant="Surface" + size="300" + radii="300" + disabled={answers.length <= MIN_ANSWERS} + aria-label={`Remove option ${idx + 1}`} + > + + + + ))} + {answers.length < MAX_ANSWERS && ( + + )} + + + {/* Multi-select */} + + + + Allow multiple selections + + {multiSelect && ( + + Up to + { + const v = parseInt((e.target as HTMLInputElement).value, 10); + if (!Number.isNaN(v)) { + setMaxSelections(Math.max(2, Math.min(v, answers.length))); + } + }} + style={{ width: '4rem' }} + /> + + )} + + + {/* Disclosed toggle */} + + + + {disclosed ? 'Disclosed poll' : 'Undisclosed poll'} + + {disclosed + ? 'Results visible while voting' + : 'Results hidden until poll ends'} + + + + + {error && ( + + {error} + + )} + + + + {/* Footer */} + + + + + +
+
+
+
+ ); +} diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index db21b3544..65ea63b1b 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -144,6 +144,7 @@ import type { AudioRecordingCompletePayload, } from './AudioMessageRecorder'; import { AudioMessageRecorder } from './AudioMessageRecorder'; +import { PollCreator } from './PollCreator'; // Returns the event ID of the most recent non-reaction/non-edit event in a thread, // falling back to the thread root if no replies exist yet. @@ -362,6 +363,8 @@ export const RoomInput = forwardRef( ); const [scheduleMenuAnchor, setScheduleMenuAnchor] = useState(); const [showSchedulePicker, setShowSchedulePicker] = useState(false); + const [pollCreatorOpen, setPollCreatorOpen] = useState(false); + const [attachMenuAnchor, setAttachMenuAnchor] = useState(); const [silentReply, setSilentReply] = useState(!mentionInReplies); const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); const isEncrypted = room.hasEncryptionStateEvent(); @@ -775,6 +778,12 @@ export const RoomInput = forwardRef( } else if (commandName === Command.UnFlip) { plainText = `${UNFLIP} ${plainText}`; customHtml = `${UNFLIP} ${customHtml}`; + } else if (commandName === Command.CreatePoll) { + setPollCreatorOpen(true); + resetEditor(editor); + resetEditorHistory(editor); + sendTypingStatus(false); + return; } else if (commandName) { const commandContent = commands[commandName as Command]; if (commandContent) { @@ -1396,16 +1405,63 @@ export const RoomInput = forwardRef( } before={ - pickFile('*')} - variant="SurfaceVariant" - size="300" - radii="300" - title="Upload File" - aria-label="Upload and attach a File" + setAttachMenuAnchor(undefined), + clickOutsideDeactivates: true, + escapeDeactivates: stopPropagation, + }} + > + +
+ } + onClick={() => { + setAttachMenuAnchor(undefined); + pickFile('*'); + }} + > + Upload File + + } + onClick={() => { + setAttachMenuAnchor(undefined); + setPollCreatorOpen(true); + }} + > + Create Poll + +
+
+ + } > - -
+ + setAttachMenuAnchor(evt.currentTarget.getBoundingClientRect()) + } + variant="SurfaceVariant" + size="300" + radii="300" + title="Attach" + aria-label="Attach or create poll" + > + + + } after={ <> @@ -1663,6 +1719,9 @@ export const RoomInput = forwardRef( }} /> )} + {pollCreatorOpen && ( + setPollCreatorOpen(false)} /> + )} ); } diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 8bc5d770c..d9223e6a5 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -137,6 +137,7 @@ export function RoomTimeline({ const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing'); const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents'); const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents'); + const [messageGroupingThreshold] = useSetting(settingsAtom, 'messageGroupingThreshold'); const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); const [showBundledPreview] = useSetting(settingsAtom, 'bundledPreview'); const [urlPreview] = useSetting(settingsAtom, 'urlPreview'); @@ -748,6 +749,7 @@ export function RoomTimeline({ readUptoEventId: readUptoEventIdRef.current, hideMembershipEvents, hideNickAvatarEvents, + messageGroupingThreshold, isReadOnly, hideMemberInReadOnly, }); diff --git a/src/app/features/room/ThreadDrawer.tsx b/src/app/features/room/ThreadDrawer.tsx index 3171e3ba4..a275df9ff 100644 --- a/src/app/features/room/ThreadDrawer.tsx +++ b/src/app/features/room/ThreadDrawer.tsx @@ -132,6 +132,7 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents'); const [showTombstoneEvents] = useSetting(settingsAtom, 'showTombstoneEvents'); const [hideMemberInReadOnly] = useSetting(settingsAtom, 'hideMembershipInReadOnly'); + const [messageGroupingThreshold] = useSetting(settingsAtom, 'messageGroupingThreshold'); const [showBundledPreview] = useSetting(settingsAtom, 'bundledPreview'); const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview; const showClientUrlPreview = room.hasEncryptionStateEvent() @@ -244,6 +245,7 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra readUptoEventId: undefined, hideMembershipEvents: true, hideNickAvatarEvents: true, + messageGroupingThreshold, isReadOnly, hideMemberInReadOnly, }); diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 7bc04b46e..4d131bf37 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -66,6 +66,7 @@ import { MessageSourceCodeItem } from '$components/message/modals/MessageSource' import { MessageForwardItem } from '$components/message/modals/MessageForward'; import { MessageDeleteItem } from '$components/message/modals/MessageDelete'; import { MessageReportItem } from '$components/message/modals/MessageReport'; +import { useBookmarks, isBookmarked, toggleBookmark } from '$hooks/useBookmarks'; import { filterPronounsByLanguage, getParsedPronouns } from '$utils/pronouns'; import type { PronounSet } from '$utils/pronouns'; import { useMentionClickHandler } from '$hooks/useMentionClickHandler'; @@ -193,6 +194,41 @@ export const MessagePinItem = as< ); }); +// Bookmark message item +export const MessageBookmarkItem = as< + 'button', + { + room: Room; + mEvent: MatrixEvent; + onClose?: () => void; + } +>(({ room, mEvent, onClose, ...props }, ref) => { + const mx = useMatrixClient(); + const bookmarks = useBookmarks(); + const eventId = mEvent.getId() ?? ''; + const bookmarked = isBookmarked(bookmarks, eventId); + + const handleToggle = () => { + toggleBookmark(mx, room.roomId, eventId, bookmarks).catch(() => {}); + onClose?.(); + }; + + return ( + } + radii="300" + onClick={handleToggle} + {...props} + ref={ref} + > + + {bookmarked ? 'Remove Bookmark' : 'Bookmark'} + + + ); +}); + export type ForwardedMessageProps = { originalTimestamp: number; isForwarded: boolean; @@ -1100,6 +1136,7 @@ function MessageInternal( )} + {canPinEvent && ( )} @@ -1438,6 +1475,7 @@ export const Event = as<'div', EventProps>( )} + {((!mEvent.isRedacted() && canDelete && !stateEvent) || (mEvent.getSender() !== mx.getUserId() && !stateEvent)) && ( diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx index 100119726..e15ae476f 100644 --- a/src/app/features/settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/settings/developer-tools/DevelopTools.tsx @@ -15,6 +15,7 @@ import { AccountData } from './AccountData'; import { SyncDiagnostics } from './SyncDiagnostics'; import { DebugLogViewer } from './DebugLogViewer'; import { SentrySettings } from './SentrySettings'; +import { ExperimentsPanel } from './ExperimentsPanel'; type DeveloperToolsProps = { requestBack?: () => void; @@ -127,6 +128,11 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp )} + {developerTools && ( + + + + )} diff --git a/src/app/features/settings/developer-tools/ExperimentsPanel.tsx b/src/app/features/settings/developer-tools/ExperimentsPanel.tsx new file mode 100644 index 000000000..86dcec3b5 --- /dev/null +++ b/src/app/features/settings/developer-tools/ExperimentsPanel.tsx @@ -0,0 +1,102 @@ +import { useState, useCallback } from 'react'; +import { Box, Text, Switch, Button } from 'folds'; +import { SequenceCard } from '$components/sequence-card'; +import { SettingTile } from '$components/setting-tile'; +import { SequenceCardStyle } from '$features/settings/styles.css'; +import { useClientConfig, setExperimentOverride } from '$hooks/useClientConfig'; + +const EXPERIMENT_OVERRIDE_PREFIX = 'sable_exp_'; + +function getActiveExperimentKeys(configExperiments?: Record): string[] { + const fromConfig = Object.keys(configExperiments ?? {}); + const fromBuild = Object.keys(INJECTED_EXPERIMENT_FLAGS); + const fromStorage = Object.keys(localStorage) + .filter((k) => k.startsWith(EXPERIMENT_OVERRIDE_PREFIX)) + .map((k) => k.slice(EXPERIMENT_OVERRIDE_PREFIX.length)); + + return Array.from(new Set([...fromConfig, ...fromBuild, ...fromStorage])).sort(); +} + +function getEffectiveValue( + key: string, + configExperiments?: Record +): { value: boolean; source: 'override' | 'config' | 'build' | 'default' } { + const lsValue = localStorage.getItem(`${EXPERIMENT_OVERRIDE_PREFIX}${key}`); + if (lsValue !== null) return { value: lsValue === 'true', source: 'override' }; + if (configExperiments && key in configExperiments) + return { value: configExperiments[key] ?? false, source: 'config' }; + if (key in INJECTED_EXPERIMENT_FLAGS) + return { value: INJECTED_EXPERIMENT_FLAGS[key] ?? false, source: 'build' }; + return { value: false, source: 'default' }; +} + +export function ExperimentsPanel() { + const config = useClientConfig(); + const [, forceUpdate] = useState(0); + const refresh = useCallback(() => forceUpdate((n) => n + 1), []); + + const keys = getActiveExperimentKeys(config.experiments); + + if (keys.length === 0) { + return ( + + Experiments + + No experiment flags are defined. Set VITE_FEATURE_* env vars at build time + or add an experiments field to config.json. + + + ); + } + + return ( + + Experiments + + Override experiment flags for this session. Changes are stored in localStorage and take + effect immediately on next render. + + + {keys.map((key) => { + const { value, source } = getEffectiveValue(key, config.experiments); + const hasOverride = source === 'override'; + return ( + + {hasOverride && ( + + )} + { + setExperimentOverride(key, v); + refresh(); + }} + /> + + } + /> + ); + })} + + + ); +} diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 061953d14..b671f84a1 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -419,6 +419,8 @@ function Editor({ isMobile }: Readonly<{ isMobile: boolean }>) { const [hideActivity, setHideActivity] = useSetting(settingsAtom, 'hideActivity'); const [hideReads, setHideReads] = useSetting(settingsAtom, 'hideReads'); const [sendPresence, setSendPresence] = useSetting(settingsAtom, 'sendPresence'); + const [autoIdlePresence, setAutoIdlePresence] = useSetting(settingsAtom, 'autoIdlePresence'); + const [showRoomMessagePreview, setShowRoomMessagePreview] = useSetting(settingsAtom, 'showRoomMessagePreview'); const [mentionInReplies, setMentionInReplies] = useSetting(settingsAtom, 'mentionInReplies'); return ( @@ -475,6 +477,36 @@ function Editor({ isMobile }: Readonly<{ isMobile: boolean }>) { after={} /> + {sendPresence && ( + + + } + /> + + )} + + + } + /> + = (evt) => { + const val = evt.target.value; + setInputValue(val); + + const parsed = Number.parseInt(val, 10); + if (!Number.isNaN(parsed) && parsed >= 1 && parsed <= 60) { + setThreshold(parsed); + } + }; + + const handleKeyDown: KeyboardEventHandler = (evt) => { + if (isKeyHotkey('escape', evt)) { + evt.stopPropagation(); + setInputValue(threshold.toString()); + (evt.target as HTMLInputElement).blur(); + } + + if (isKeyHotkey('enter', evt)) { + (evt.target as HTMLInputElement).blur(); + } + }; + + return ( + + ); +} + function Messages() { const [hideMembershipEvents, setHideMembershipEvents] = useSetting( settingsAtom, @@ -941,6 +1016,14 @@ function Messages() { } /> + + } + /> + { const match = useMatch({ @@ -30,3 +35,13 @@ export const useInboxInvitesSelected = (): boolean => { return !!match; }; + +export const useInboxBookmarksSelected = (): boolean => { + const match = useMatch({ + path: getInboxBookmarksPath(), + caseSensitive: true, + end: false, + }); + + return !!match; +}; diff --git a/src/app/hooks/timeline/useProcessedTimeline.ts b/src/app/hooks/timeline/useProcessedTimeline.ts index 1dd6d11c5..32d7b8720 100644 --- a/src/app/hooks/timeline/useProcessedTimeline.ts +++ b/src/app/hooks/timeline/useProcessedTimeline.ts @@ -18,6 +18,7 @@ export interface UseProcessedTimelineOptions { readUptoEventId: string | undefined; hideMembershipEvents: boolean; hideNickAvatarEvents: boolean; + messageGroupingThreshold: number; isReadOnly: boolean; hideMemberInReadOnly: boolean; /** @@ -62,6 +63,7 @@ export function useProcessedTimeline({ readUptoEventId, hideMembershipEvents, hideNickAvatarEvents, + messageGroupingThreshold, isReadOnly, hideMemberInReadOnly, skipThreadFilter, @@ -138,7 +140,7 @@ export function useProcessedTimeline({ let collapsed = false; if (isPrevRendered && !dayDivider && prevEvent !== undefined) { if (isMessageEvent) { - const withinTimeThreshold = minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 2; + const withinTimeThreshold = minuteDifference(prevEvent.getTs(), mEvent.getTs()) < messageGroupingThreshold; const senderMatch = prevEvent.getSender() === eventSender; const typeMatch = normalizeMessageType(prevEvent.getType()) === normalizeMessageType(type); diff --git a/src/app/hooks/timeline/useTimelineEventRenderer.tsx b/src/app/hooks/timeline/useTimelineEventRenderer.tsx index 3ceba3c9f..ad62934c9 100644 --- a/src/app/hooks/timeline/useTimelineEventRenderer.tsx +++ b/src/app/hooks/timeline/useTimelineEventRenderer.tsx @@ -12,6 +12,7 @@ import type { } from '$types/matrix-sdk'; import type { IImageContent } from '$types/matrix/common'; import { NotificationCountType, RoomEvent, ThreadEvent, EventType } from '$types/matrix-sdk'; +import { M_POLL_START } from 'matrix-js-sdk/lib/@types/polls'; import type { SessionMembershipData } from '$types/matrix-sdk'; import type { HTMLReactParserOptions } from 'html-react-parser'; import type { Opts as LinkifyOpts } from 'linkifyjs'; @@ -55,6 +56,7 @@ import * as customHtmlCss from '$styles/CustomHtml.css'; import { UnreadBadge, UnreadBadgeCenter } from '$components/unread-badge'; import type { ForwardedMessageProps } from '$features/room/message'; import { EncryptedContent, Event, Message, Reactions } from '$features/room/message'; +import { PollContent } from '$features/room/PollContent'; import { useSableCosmetics } from '$hooks/useSableCosmetics'; @@ -629,6 +631,11 @@ export function useTimelineEventRenderer({ )} /> ); + if ( + type === (M_POLL_START.name as string) || + type === (M_POLL_START.altName as string) + ) + return ; if (type === (EventType.RoomMessage as string)) { const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet); let editedNewContent: unknown; @@ -671,6 +678,8 @@ export function useTimelineEventRenderer({ ); }, + [M_POLL_START.name]: (_mEventId, mEvent) => , + [M_POLL_START.altName]: (_mEventId, mEvent) => , [EventType.Sticker]: (mEventId, mEvent, item, timelineSet, collapse) => { const { replyEventId: rawReplyEventId, threadRootId } = mEvent; const replyEventId = diff --git a/src/app/hooks/useBookmarks.ts b/src/app/hooks/useBookmarks.ts new file mode 100644 index 000000000..098dbd059 --- /dev/null +++ b/src/app/hooks/useBookmarks.ts @@ -0,0 +1,112 @@ +import { useCallback, useEffect, useState } from 'react'; +import type { MatrixClient, MatrixEvent } from '$types/matrix-sdk'; +import { ClientEvent } from '$types/matrix-sdk'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { CustomAccountDataEvent } from '$types/matrix/accountData'; + +export type BookmarkEntry = { + event_id: string; + room_id: string; + /** MSC4438 bookmark key suffix, e.g. "bmk_a1b2c3d4" */ + id: string; +}; + +// --------------------------------------------------------------------------- +// MSC4438 helpers +// --------------------------------------------------------------------------- + +const BOOKMARK_PREFIX = CustomAccountDataEvent.MSC4438BookmarkPrefix; // 'org.matrix.msc4438.bookmark.' +const INDEX_KEY = CustomAccountDataEvent.MSC4438BookmarksIndex; // 'org.matrix.msc4438.bookmarks.index' + +function generateBookmarkId(): string { + // 8 random hex chars, prefixed with "bmk_" + const bytes = new Uint8Array(4); + crypto.getRandomValues(bytes); + return `bmk_${Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join('')}`; +} + +function getIndexIds(mx: MatrixClient): string[] { + const ev = mx.getAccountData(INDEX_KEY); + if (!ev) return []; + const content = ev.getContent<{ bookmark_ids?: string[] }>(); + return Array.isArray(content.bookmark_ids) ? content.bookmark_ids : []; +} + +export function readBookmarks(mx: MatrixClient): BookmarkEntry[] { + const ids = getIndexIds(mx); + const entries: BookmarkEntry[] = []; + for (const id of ids) { + const ev = mx.getAccountData(`${BOOKMARK_PREFIX}${id}`); + if (!ev) continue; + const c = ev.getContent<{ room_id?: string; event_id?: string; deleted?: boolean }>(); + if (!c.deleted && c.room_id && c.event_id) { + entries.push({ id, room_id: c.room_id, event_id: c.event_id }); + } + } + return entries; +} + +// --------------------------------------------------------------------------- +// Hook +// --------------------------------------------------------------------------- + +export function useBookmarks(): BookmarkEntry[] { + const mx = useMatrixClient(); + const [bookmarks, setBookmarks] = useState(() => readBookmarks(mx)); + + const refresh = useCallback(() => setBookmarks(readBookmarks(mx)), [mx]); + + useEffect(() => { + refresh(); + const handler = (event: MatrixEvent) => { + const type = event.getType(); + if (type === INDEX_KEY || type.startsWith(BOOKMARK_PREFIX)) { + refresh(); + } + }; + mx.on(ClientEvent.AccountData, handler); + return () => { + mx.off(ClientEvent.AccountData, handler); + }; + }, [mx, refresh]); + + return bookmarks; +} + +// --------------------------------------------------------------------------- +// Utilities +// --------------------------------------------------------------------------- + +export function isBookmarked(bookmarks: BookmarkEntry[], eventId: string): boolean { + return bookmarks.some((b) => b.event_id === eventId); +} + +export async function toggleBookmark( + mx: MatrixClient, + roomId: string, + eventId: string, + currentBookmarks: BookmarkEntry[] +): Promise { + const existing = currentBookmarks.find((b) => b.event_id === eventId); + if (existing) { + // Remove: update index first, then mark individual event deleted + const newIds = currentBookmarks.filter((b) => b.event_id !== eventId).map((b) => b.id); + await mx.setAccountData(INDEX_KEY, { bookmark_ids: newIds }); + await mx.setAccountData(`${BOOKMARK_PREFIX}${existing.id}`, { + deleted: true, + bookmark_id: existing.id, + }); + } else { + // Add: write individual event, then update index + const id = generateBookmarkId(); + await mx.setAccountData(`${BOOKMARK_PREFIX}${id}`, { + room_id: roomId, + event_id: eventId, + bookmark_id: id, + }); + const newIds = [...currentBookmarks.map((b) => b.id), id]; + await mx.setAccountData(INDEX_KEY, { bookmark_ids: newIds }); + } +} diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index 87685337d..46c8804b3 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -42,6 +42,8 @@ export type ClientConfig = { hashRouter?: HashRouterConfig; matrixToBaseUrl?: string; + + experiments?: Record; }; const ClientConfigContext = createContext(null); @@ -64,3 +66,30 @@ export const clientAllowedServer = (clientConfig: ClientConfig, server: string): return homeserverList?.includes(server) === true; }; + +const EXPERIMENT_OVERRIDE_PREFIX = 'sable_exp_'; + +/** + * Returns the value of an experiment flag. Resolution order: + * 1. localStorage override (set by developer tools panel) + * 2. config.json `experiments` field (deploy-time config) + * 3. Build-time injected flags from VITE_FEATURE_* env vars + * 4. false (default) + */ +export function useExperimentFlag(key: string): boolean { + const config = useClientConfig(); + const lsKey = `${EXPERIMENT_OVERRIDE_PREFIX}${key}`; + const lsValue = localStorage.getItem(lsKey); + if (lsValue !== null) return lsValue === 'true'; + if (config.experiments && key in config.experiments) return config.experiments[key] ?? false; + return INJECTED_EXPERIMENT_FLAGS[key] ?? false; +} + +export function setExperimentOverride(key: string, value: boolean | null): void { + const lsKey = `${EXPERIMENT_OVERRIDE_PREFIX}${key}`; + if (value === null) { + localStorage.removeItem(lsKey); + } else { + localStorage.setItem(lsKey, value ? 'true' : 'false'); + } +} diff --git a/src/app/hooks/useCommands.ts b/src/app/hooks/useCommands.ts index 07b987a51..b58747b18 100644 --- a/src/app/hooks/useCommands.ts +++ b/src/app/hooks/useCommands.ts @@ -285,6 +285,8 @@ export enum Command { // Spec missing from cinny Location = 'location', ShareMyLocation = 'sharemylocation', + // Polls + CreatePoll = 'poll', } export type CommandContent = { @@ -1589,6 +1591,11 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { navigator.geolocation.getCurrentPosition(success, error, options); }, }, + [Command.CreatePoll]: { + name: Command.CreatePoll, + description: 'Create a poll', + exe: async () => undefined, + }, }), [ mx, diff --git a/src/app/hooks/usePollTally.test.ts b/src/app/hooks/usePollTally.test.ts new file mode 100644 index 000000000..db2fa2a05 --- /dev/null +++ b/src/app/hooks/usePollTally.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect } from 'vitest'; +import type { Relations } from '$types/matrix-sdk'; +import type { MatrixEvent } from '$types/matrix-sdk'; +import { M_POLL_RESPONSE } from 'matrix-js-sdk/lib/@types/polls'; +import type { PollAnswer } from 'matrix-js-sdk/lib/@types/polls'; +import { tallyCounts } from '$hooks/usePollTally'; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function makeRelations(events: Partial[]): Relations { + return { + getRelations: () => events as MatrixEvent[], + } as unknown as Relations; +} + +function makeVote( + sender: string, + answerIds: string[], + ts = 1000 +): Partial { + return { + getSender: () => sender, + getTs: () => ts, + getContent: (() => ({ + [M_POLL_RESPONSE.name]: { answers: answerIds }, + })) as MatrixEvent['getContent'], + }; +} + +const ANSWERS: PollAnswer[] = [ + { id: 'a', body: 'Option A', mimetype: 'text/plain' } as unknown as PollAnswer, + { id: 'b', body: 'Option B', mimetype: 'text/plain' } as unknown as PollAnswer, + { id: 'c', body: 'Option C', mimetype: 'text/plain' } as unknown as PollAnswer, +]; + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe('tallyCounts', () => { + it('returns zero counts and no voters for empty relations', () => { + const result = tallyCounts(ANSWERS, makeRelations([]), '@me:example.com', 1); + expect(result.totalVoters).toBe(0); + expect(result.counts.get('a')).toBe(0); + expect(result.myAnswers).toEqual([]); + }); + + it('counts a single vote correctly', () => { + const rel = makeRelations([makeVote('@alice:example.com', ['a'])]); + const result = tallyCounts(ANSWERS, rel, '@me:example.com', 1); + expect(result.counts.get('a')).toBe(1); + expect(result.counts.get('b')).toBe(0); + expect(result.totalVoters).toBe(1); + }); + + it('only counts the last vote per user', () => { + const rel = makeRelations([ + makeVote('@alice:example.com', ['a'], 1000), + makeVote('@alice:example.com', ['b'], 2000), // later — should win + ]); + const result = tallyCounts(ANSWERS, rel, '@me:example.com', 1); + expect(result.counts.get('a')).toBe(0); + expect(result.counts.get('b')).toBe(1); + expect(result.totalVoters).toBe(1); + }); + + it('ignores invalid answer IDs (not in poll answers)', () => { + const rel = makeRelations([makeVote('@alice:example.com', ['z'])]); + const result = tallyCounts(ANSWERS, rel, '@me:example.com', 1); + expect(result.totalVoters).toBe(0); + expect([...result.counts.values()].every((v) => v === 0)).toBe(true); + }); + + it('tracks the current user vote in myAnswers', () => { + const rel = makeRelations([makeVote('@me:example.com', ['c'])]); + const result = tallyCounts(ANSWERS, rel, '@me:example.com', 1); + expect(result.myAnswers).toEqual(['c']); + }); + + it('supports multi-select up to max_selections', () => { + const rel = makeRelations([makeVote('@alice:example.com', ['a', 'b', 'c'])]); + // max_selections = 2 → only first 2 are kept + const result = tallyCounts(ANSWERS, rel, '@me:example.com', 2); + expect(result.counts.get('a')).toBe(1); + expect(result.counts.get('b')).toBe(1); + expect(result.counts.get('c')).toBe(0); + }); + + it('handles null/undefined relations gracefully', () => { + const result = tallyCounts(ANSWERS, null, '@me:example.com', 1); + expect(result.totalVoters).toBe(0); + }); + + it('counts multiple distinct voters independently', () => { + const rel = makeRelations([ + makeVote('@alice:example.com', ['a']), + makeVote('@bob:example.com', ['a']), + makeVote('@carol:example.com', ['b']), + ]); + const result = tallyCounts(ANSWERS, rel, '@me:example.com', 1); + expect(result.counts.get('a')).toBe(2); + expect(result.counts.get('b')).toBe(1); + expect(result.totalVoters).toBe(3); + }); + + it('treats empty answers array as a spoil (abstain) — not counted in totalVoters', () => { + const rel = makeRelations([makeVote('@alice:example.com', [])]); + const result = tallyCounts(ANSWERS, rel, '@me:example.com', 1); + expect(result.totalVoters).toBe(0); + }); +}); diff --git a/src/app/hooks/usePollTally.ts b/src/app/hooks/usePollTally.ts new file mode 100644 index 000000000..ff4e6d01d --- /dev/null +++ b/src/app/hooks/usePollTally.ts @@ -0,0 +1,78 @@ +import type { Relations } from '$types/matrix-sdk'; +import type { PollAnswer } from 'matrix-js-sdk/lib/@types/polls'; +import { M_POLL_RESPONSE } from 'matrix-js-sdk/lib/@types/polls'; + +export type PollTally = { + /** Map from answerId → vote count (deduplicated to last vote per user) */ + counts: Map; + /** Total number of users who cast at least one valid answer */ + totalVoters: number; + /** The current user's selected answer IDs (empty = not voted) */ + myAnswers: string[]; +}; + +/** + * Pure function — tallies poll votes from a Relations object. + * + * Rules per MSC3381: + * - Only the last response per user is counted. + * - Answers that don't exist in the poll's answer list are ignored (spoiled). + * - A response with an empty answers array is a deliberate spoil (abstain). + */ +export function tallyCounts( + answers: PollAnswer[], + relations: Relations | null | undefined, + myUserId: string, + maxSelections: number +): PollTally { + const validIds = new Set(answers.map((a) => a.id)); + const answerIds = answers.map((a) => a.id); + + // Map userId → their last response's answer IDs (already validated) + const lastVoteByUser = new Map(); + + const events = relations?.getRelations() ?? []; + + // Sort ascending so iterating gives chronological order; last write wins + const sorted = [...events].sort((a, b) => a.getTs() - b.getTs()); + + for (const event of sorted) { + const sender = event.getSender(); + if (!sender) continue; + + const content = event.getContent(); + // Support both stable (m.poll.response) and unstable (org.matrix.msc3381.poll.response) keys + const responsePart = + (content[M_POLL_RESPONSE.name] as { answers?: unknown } | undefined) ?? + (content[M_POLL_RESPONSE.altName] as { answers?: unknown } | undefined); + + if (!responsePart || !Array.isArray(responsePart.answers)) { + continue; + } + + const rawAnswers = responsePart.answers as unknown[]; + // Filter to only valid answer IDs; enforce max_selections limit + const validAnswers = (rawAnswers.filter((id) => typeof id === 'string' && validIds.has(id)) as string[]).slice( + 0, + Math.max(1, maxSelections) + ); + + lastVoteByUser.set(sender, validAnswers); + } + + const counts = new Map(answerIds.map((id) => [id, 0])); + let myAnswers: string[] = []; + + for (const [userId, selectedIds] of lastVoteByUser) { + for (const id of selectedIds) { + counts.set(id, (counts.get(id) ?? 0) + 1); + } + if (userId === myUserId) { + myAnswers = selectedIds; + } + } + + const totalVoters = Array.from(lastVoteByUser.values()).filter((ids) => ids.length > 0).length; + + return { counts, totalVoters, myAnswers }; +} diff --git a/src/app/hooks/useRoomLastMessagePreview.ts b/src/app/hooks/useRoomLastMessagePreview.ts new file mode 100644 index 000000000..1f156b4d2 --- /dev/null +++ b/src/app/hooks/useRoomLastMessagePreview.ts @@ -0,0 +1,91 @@ +import { useEffect, useState } from 'react'; +import type { Room, MatrixEvent } from '$types/matrix-sdk'; +import { EventType, RoomEvent } from '$types/matrix-sdk'; + +const MAX_PREVIEW_LEN = 80; + +function truncate(text: string): string { + return text.length > MAX_PREVIEW_LEN ? `${text.slice(0, MAX_PREVIEW_LEN)}…` : text; +} + +function getEventPreviewBody(event: MatrixEvent): string | null { + const type = event.getType(); + const content = event.getContent() as Record; + + if (type === (EventType.RoomMessage as string)) { + const msgtype = content.msgtype as string | undefined; + const body = content.body as string | undefined; + if (!body) return null; + + if (msgtype === 'm.image') return '📷 Image'; + if (msgtype === 'm.video') return '🎬 Video'; + if (msgtype === 'm.audio') return '🎵 Audio'; + if (msgtype === 'm.file') return '📎 File'; + if (msgtype === 'm.sticker' || type === (EventType.Sticker as string)) return '🎭 Sticker'; + + return truncate(body); + } + + if (type === (EventType.Sticker as string)) return '🎭 Sticker'; + if (type === (EventType.RoomMessageEncrypted as string)) return '🔒 Encrypted message'; + + return null; +} + +type RoomLastMessagePreview = { + senderId: string; + senderDisplayName: string; + body: string; + ts: number; +} | null; + +export function useRoomLastMessagePreview( + room: Room, + includeSender: boolean +): RoomLastMessagePreview { + const [preview, setPreview] = useState(() => + buildPreview(room, includeSender) + ); + + useEffect(() => { + setPreview(buildPreview(room, includeSender)); + + const update = () => setPreview(buildPreview(room, includeSender)); + room.on(RoomEvent.Timeline, update); + room.on(RoomEvent.Redaction, update); + return () => { + room.off(RoomEvent.Timeline, update); + room.off(RoomEvent.Redaction, update); + }; + }, [room, includeSender]); + + return preview; +} + +function buildPreview(room: Room, includeSender: boolean): RoomLastMessagePreview { + const events = room.getLiveTimeline().getEvents(); + + for (let i = events.length - 1; i >= 0; i -= 1) { + const event = events[i]; + if (!event) continue; + if (event.isRedacted()) continue; + + const body = getEventPreviewBody(event); + if (!body) continue; + + const senderId = event.getSender() ?? ''; + const member = room.getMember(senderId); + const senderDisplayName = includeSender + ? (member?.name ?? senderId.split(':')[0]?.slice(1) ?? senderId) + : ''; + + return { + senderId, + senderDisplayName, + body, + ts: event.getTs(), + }; + } + + return null; +} diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index aec62cc86..4a16786ec 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -29,6 +29,7 @@ import { getFallbackSession, MATRIX_SESSIONS_KEY } from '$state/sessions'; import { getLocalStorageItem } from '$state/utils/atomWithLocalStorage'; import { NotificationJumper } from '$hooks/useNotificationJumper'; import { SearchModalRenderer } from '$features/search'; +import { BookmarksPanelRenderer } from '$features/bookmarks/BookmarksPanel'; import { GlobalKeyboardShortcuts } from '$components/GlobalKeyboardShortcuts'; import { CallEmbedProvider } from '$components/CallEmbedProvider'; import { AuthLayout, Login, Register, ResetPassword } from './auth'; @@ -44,6 +45,7 @@ import { CREATE_PATH_SEGMENT, FEATURED_PATH_SEGMENT, INVITES_PATH_SEGMENT, + BOOKMARKS_PATH_SEGMENT, JOIN_PATH_SEGMENT, LOBBY_PATH_SEGMENT, NOTIFICATIONS_PATH_SEGMENT, @@ -69,7 +71,7 @@ import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home'; import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct'; import { RouteSpaceProvider, Space, SpaceRouteRoomProvider, SpaceSearch } from './client/space'; import { Explore, FeaturedRooms, PublicRooms } from './client/explore'; -import { Notifications, Inbox, Invites } from './client/inbox'; +import { Notifications, Inbox, Invites, Bookmarks } from './client/inbox'; import { setAfterLoginRedirectPath } from './afterLoginRedirectPath'; import { WelcomePage } from './client/WelcomePage'; import { SidebarNav } from './client/SidebarNav'; @@ -191,6 +193,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) + @@ -369,6 +372,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) )} } /> } /> + } /> } /> diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 8282e5daf..78921cfc0 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -844,6 +844,7 @@ function HandleDecryptPushEvent() { function PresenceFeature() { const mx = useMatrixClient(); const [sendPresence] = useSetting(settingsAtom, 'sendPresence'); + const [autoIdlePresence] = useSetting(settingsAtom, 'autoIdlePresence'); useEffect(() => { // Classic sync: set_presence query param on every /sync poll. @@ -853,6 +854,64 @@ function PresenceFeature() { getSlidingSyncManager(mx)?.setPresenceEnabled(sendPresence); }, [mx, sendPresence]); + // Auto-idle: set presence to unavailable after 5 minutes of inactivity or + // when the tab is hidden, and restore online on activity. + useEffect(() => { + if (!sendPresence || !autoIdlePresence) return undefined; + + const IDLE_TIMEOUT_MS = 5 * 60 * 1000; + let idleTimer: ReturnType | undefined; + let isIdle = false; + + const goOnline = () => { + if (!isIdle) return; + isIdle = false; + mx.setPresence({ presence: 'online' }).catch(() => {}); + }; + + const goIdle = () => { + if (isIdle) return; + isIdle = true; + mx.setPresence({ presence: 'unavailable' }).catch(() => {}); + }; + + const resetTimer = () => { + goOnline(); + clearTimeout(idleTimer); + idleTimer = setTimeout(goIdle, IDLE_TIMEOUT_MS); + }; + + const handleVisibilityChange = () => { + if (document.visibilityState === 'hidden') { + clearTimeout(idleTimer); + goIdle(); + } else { + resetTimer(); + } + }; + + const ACTIVITY_EVENTS: (keyof DocumentEventMap)[] = [ + 'mousemove', + 'keydown', + 'pointerdown', + 'scroll', + ]; + + ACTIVITY_EVENTS.forEach((e) => document.addEventListener(e, resetTimer, { passive: true })); + document.addEventListener('visibilitychange', handleVisibilityChange); + resetTimer(); + + return () => { + clearTimeout(idleTimer); + ACTIVITY_EVENTS.forEach((e) => document.removeEventListener(e, resetTimer)); + document.removeEventListener('visibilitychange', handleVisibilityChange); + // Restore online when feature is disabled + if (isIdle) { + mx.setPresence({ presence: 'online' }).catch(() => {}); + } + }; + }, [mx, sendPresence, autoIdlePresence]); + return null; } diff --git a/src/app/pages/client/SidebarNav.tsx b/src/app/pages/client/SidebarNav.tsx index 6cf397807..e8be0f616 100644 --- a/src/app/pages/client/SidebarNav.tsx +++ b/src/app/pages/client/SidebarNav.tsx @@ -16,6 +16,7 @@ import { UnverifiedTab, SearchTab, AccountSwitcherTab, + BookmarksTab, } from './sidebar'; import { CreateTab } from './sidebar/CreateTab'; @@ -133,6 +134,7 @@ export function SidebarNav() { sticky={ + diff --git a/src/app/pages/client/inbox/Bookmarks.tsx b/src/app/pages/client/inbox/Bookmarks.tsx new file mode 100644 index 000000000..1ea404c4d --- /dev/null +++ b/src/app/pages/client/inbox/Bookmarks.tsx @@ -0,0 +1,28 @@ +import { Box, Icon, Icons, Text } from 'folds'; +import { Page, PageContent, PageHeader } from '$components/page'; +import { BookmarksList } from '$features/bookmarks/BookmarksList'; + +export function Bookmarks() { + return ( + + + + + + + Bookmarks + + + + + + + + + + + ); +} diff --git a/src/app/pages/client/inbox/Inbox.tsx b/src/app/pages/client/inbox/Inbox.tsx index 661435513..fa7b901d4 100644 --- a/src/app/pages/client/inbox/Inbox.tsx +++ b/src/app/pages/client/inbox/Inbox.tsx @@ -1,8 +1,16 @@ import { Avatar, Box, Icon, Icons, Text } from 'folds'; import { useAtomValue } from 'jotai'; import { NavCategory, NavItem, NavItemContent, NavLink } from '$components/nav'; -import { getInboxInvitesPath, getInboxNotificationsPath } from '$pages/pathUtils'; -import { useInboxInvitesSelected, useInboxNotificationsSelected } from '$hooks/router/useInbox'; +import { + getInboxBookmarksPath, + getInboxInvitesPath, + getInboxNotificationsPath, +} from '$pages/pathUtils'; +import { + useInboxBookmarksSelected, + useInboxInvitesSelected, + useInboxNotificationsSelected, +} from '$hooks/router/useInbox'; import { UnreadBadge } from '$components/unread-badge'; import { allInvitesAtom } from '$state/room-list/inviteList'; import { useNavToActivePathMapper } from '$hooks/useNavToActivePathMapper'; @@ -42,6 +50,7 @@ function InvitesNavItem() { export function Inbox() { useNavToActivePathMapper('inbox'); const notificationsSelected = useInboxNotificationsSelected(); + const bookmarksSelected = useInboxBookmarksSelected(); return ( @@ -75,6 +84,22 @@ export function Inbox() { + + + + + + + + + + Bookmarks + + + + + + diff --git a/src/app/pages/client/inbox/index.ts b/src/app/pages/client/inbox/index.ts index c8036b471..dc02ccee6 100644 --- a/src/app/pages/client/inbox/index.ts +++ b/src/app/pages/client/inbox/index.ts @@ -1,3 +1,4 @@ export * from './Inbox'; export * from './Notifications'; export * from './Invites'; +export * from './Bookmarks'; diff --git a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx index a3ec48466..ed29252e5 100644 --- a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx +++ b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx @@ -45,6 +45,8 @@ import { createLogger } from '$utils/debug'; import { createDebugLogger } from '$utils/debugLogger'; import { useClientConfig } from '$hooks/useClientConfig'; import { UnreadBadge, UnreadBadgeCenter } from '$components/unread-badge'; +import { Presence, useUserPresence } from '$hooks/useUserPresence'; +import { AvatarPresence, PresenceBadge } from '$components/presence'; const log = createLogger('AccountSwitcherTab'); const debugLog = createDebugLogger('AccountSwitcherTab'); @@ -174,6 +176,7 @@ export function AccountSwitcherTab() { ? (mxcUrlToHttp(mx, activeProfile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined) : undefined; const activeDisplayName = activeProfile.displayName; + const myPresence = useUserPresence(myUserId); const sessionProfiles = useSessionProfiles(sessions); @@ -269,19 +272,28 @@ export function AccountSwitcherTab() { {(triggerRef) => ( - 1} + + ) + } > - {nameInitials(label)}} - /> - + 1} + > + {nameInitials(label)}} + /> + +
)} {(totalBackgroundUnread > 0 || anyBackgroundHighlight) && ( diff --git a/src/app/pages/client/sidebar/BookmarksTab.tsx b/src/app/pages/client/sidebar/BookmarksTab.tsx new file mode 100644 index 000000000..089d96966 --- /dev/null +++ b/src/app/pages/client/sidebar/BookmarksTab.tsx @@ -0,0 +1,20 @@ +import { Icon, Icons } from 'folds'; +import { useAtom } from 'jotai'; +import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '$components/sidebar'; +import { bookmarksPanelAtom } from '$state/bookmarksPanelAtom'; + +export function BookmarksTab() { + const [opened, setOpen] = useAtom(bookmarksPanelAtom); + + return ( + + + {(triggerRef) => ( + setOpen((o) => !o)}> + + + )} + + + ); +} diff --git a/src/app/pages/client/sidebar/DirectDMsList.tsx b/src/app/pages/client/sidebar/DirectDMsList.tsx index 8c3313335..31d17d68a 100644 --- a/src/app/pages/client/sidebar/DirectDMsList.tsx +++ b/src/app/pages/client/sidebar/DirectDMsList.tsx @@ -22,6 +22,8 @@ import { getCanonicalAliasOrRoomId, mxcUrlToHttp } from '$utils/matrix'; import { useSelectedRoom } from '$hooks/router/useSelectedRoom'; import { useGroupDMMembers } from '$hooks/useGroupDMMembers'; import { useSidebarDirectRoomIds } from './useSidebarDirectRoomIds'; +import { Presence, useUserPresence } from '$hooks/useUserPresence'; +import { AvatarPresence, PresenceBadge } from '$components/presence'; import * as css from './DirectDMsList.css'; const MAX_GROUP_MEMBERS = 3; @@ -44,6 +46,9 @@ function DMItem({ room, selected }: DMItemProps) { // Check if this is a group DM (more than 2 members) const isGroupDM = room.getJoinedMemberCount() > 2; + const dmUserId = !isGroupDM ? room.getAvatarFallbackMember()?.userId : undefined; + const dmPresence = useUserPresence(dmUserId ?? ''); + // Get member info for group DMs using m.direct and profile API (doesn't require full room state) // Members are sorted by who last sent messages (most recent first) const groupMembers = useGroupDMMembers(mx, room, MAX_GROUP_MEMBERS); @@ -135,9 +140,19 @@ function DMItem({ room, selected }: DMItemProps) { {(triggerRef) => ( - - {renderAvatar()} - + + ) + } + > + + {renderAvatar()} + + )} {unread && (unread.total > 0 || unread.highlight > 0) && ( diff --git a/src/app/pages/client/sidebar/index.ts b/src/app/pages/client/sidebar/index.ts index 08a9099c0..27bbfef75 100644 --- a/src/app/pages/client/sidebar/index.ts +++ b/src/app/pages/client/sidebar/index.ts @@ -7,3 +7,4 @@ export * from './ExploreTab'; export * from './UnverifiedTab'; export * from './SearchTab'; export * from './AccountSwitcherTab'; +export * from './BookmarksTab'; diff --git a/src/app/pages/pathUtils.ts b/src/app/pages/pathUtils.ts index 4a95f47fc..f942adf0a 100644 --- a/src/app/pages/pathUtils.ts +++ b/src/app/pages/pathUtils.ts @@ -18,6 +18,7 @@ import { LOGIN_PATH, INBOX_INVITES_PATH, INBOX_NOTIFICATIONS_PATH, + INBOX_BOOKMARKS_PATH, INBOX_PATH, REGISTER_PATH, RESET_PASSWORD_PATH, @@ -158,6 +159,7 @@ export const getCreatePath = (): string => CREATE_PATH; export const getInboxPath = (): string => INBOX_PATH; export const getInboxNotificationsPath = (): string => INBOX_NOTIFICATIONS_PATH; export const getInboxInvitesPath = (): string => INBOX_INVITES_PATH; +export const getInboxBookmarksPath = (): string => INBOX_BOOKMARKS_PATH; export const getSettingsPath = (section?: string, focus?: string): string => { const path = trimTrailingSlash(generatePath(SETTINGS_PATH, { section: section ?? null })); diff --git a/src/app/pages/paths.ts b/src/app/pages/paths.ts index 1ac57b756..c36743930 100644 --- a/src/app/pages/paths.ts +++ b/src/app/pages/paths.ts @@ -82,12 +82,14 @@ export const CREATE_PATH = '/create'; export const NOTIFICATIONS_PATH_SEGMENT = 'notifications/'; export const INVITES_PATH_SEGMENT = 'invites/'; +export const BOOKMARKS_PATH_SEGMENT = 'bookmarks/'; export const INBOX_PATH = '/inbox/'; export type InboxNotificationsPathSearchParams = { only?: string; }; export const INBOX_NOTIFICATIONS_PATH = `/inbox/${NOTIFICATIONS_PATH_SEGMENT}`; export const INBOX_INVITES_PATH = `/inbox/${INVITES_PATH_SEGMENT}`; +export const INBOX_BOOKMARKS_PATH = `/inbox/${BOOKMARKS_PATH_SEGMENT}`; export const TO_PATH = '/to'; // Deep-link route used by push notification click-back URLs. diff --git a/src/app/state/bookmarksPanelAtom.ts b/src/app/state/bookmarksPanelAtom.ts new file mode 100644 index 000000000..0981751c0 --- /dev/null +++ b/src/app/state/bookmarksPanelAtom.ts @@ -0,0 +1,3 @@ +import { atom } from 'jotai'; + +export const bookmarksPanelAtom = atom(false); diff --git a/src/app/state/room/roomToUnread.ts b/src/app/state/room/roomToUnread.ts index 208df289c..3e70f2870 100644 --- a/src/app/state/room/roomToUnread.ts +++ b/src/app/state/room/roomToUnread.ts @@ -311,8 +311,12 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo if (room.isSpaceRoom()) return; if (room.getMyMembership() !== (KnownMembership.Join as string)) return; + // Do NOT pass applyFixup here: RoomEvent.UnreadNotifications is itself fired *by* + // fixupNotifications(), so calling room.fixupNotifications() again from within this + // handler causes infinite recursion (fixupNotifications → setUnread → + // setUnreadNotificationCount → emit → this handler → fixupNotifications → …). const unreadInfo = getUnreadInfo(room, { - applyFixup: shouldApplyUnreadFixup(), + applyFixup: false, mDirects, }); if (unreadInfo.total === 0 && unreadInfo.highlight === 0) { diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 83532c673..6d514b025 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -48,6 +48,7 @@ export interface Settings { messageSpacing: MessageSpacing; hideMembershipEvents: boolean; hideNickAvatarEvents: boolean; + messageGroupingThreshold: number; showHiddenEvents: boolean; showTombstoneEvents: boolean; legacyUsernameColor: boolean; @@ -93,6 +94,8 @@ export interface Settings { // Sable features! sendPresence: boolean; + autoIdlePresence: boolean; + showRoomMessagePreview: boolean; mobileGestures: boolean; rightSwipeAction: RightSwipeAction; hideMembershipInReadOnly: boolean; @@ -148,6 +151,7 @@ const defaultSettings: Settings = { messageSpacing: '400', hideMembershipEvents: false, hideNickAvatarEvents: true, + messageGroupingThreshold: 2, mediaAutoLoad: true, multiplePreviews: true, bundledPreview: true, @@ -194,6 +198,8 @@ const defaultSettings: Settings = { // Sable features! sendPresence: true, + autoIdlePresence: true, + showRoomMessagePreview: true, mobileGestures: true, rightSwipeAction: RightSwipeAction.Reply, hideMembershipInReadOnly: true, @@ -225,27 +231,35 @@ const defaultSettings: Settings = { }; export const getSettings = () => { - const settings = localStorage.getItem(STORAGE_KEY); - if (settings === null) return defaultSettings; + try { + const settings = localStorage.getItem(STORAGE_KEY); + if (settings === null) return defaultSettings; - // migration for old keys - // monochrome -> saturation - const parsed = JSON.parse(settings); - if (parsed.monochromeMode === true && parsed.saturationLevel === undefined) { - parsed.saturationLevel = 0; - } else if (parsed.monochromeMode === false && parsed.saturationLevel === undefined) { - parsed.saturationLevel = 100; - } - delete parsed.monochromeMode; + // migration for old keys + // monochrome -> saturation + const parsed = JSON.parse(settings); + if (parsed.monochromeMode === true && parsed.saturationLevel === undefined) { + parsed.saturationLevel = 0; + } else if (parsed.monochromeMode === false && parsed.saturationLevel === undefined) { + parsed.saturationLevel = 100; + } + delete parsed.monochromeMode; - return { - ...defaultSettings, - ...(parsed as Settings), - }; + return { + ...defaultSettings, + ...(parsed as Settings), + }; + } catch { + return defaultSettings; + } }; export const setSettings = (settings: Settings) => { - localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); + } catch { + // Storage may be unavailable (e.g. private browsing quota exceeded) + } }; const baseSettings = atom(getSettings()); diff --git a/src/app/utils/debugLogger.ts b/src/app/utils/debugLogger.ts index 88f787328..e727fa5ab 100644 --- a/src/app/utils/debugLogger.ts +++ b/src/app/utils/debugLogger.ts @@ -47,8 +47,15 @@ class DebugLoggerService { private sentryStats = { errors: 0, warnings: 0 }; constructor() { - // Check if debug logging is enabled from localStorage - this.enabled = localStorage.getItem('sable_internal_debug') === '1'; + // Check if debug logging is enabled from localStorage. + // Guarded with try/catch because this module is instantiated as a singleton + // at import time, which in Node.js 22+ can run before a jsdom environment + // is ready (Node has a built-in but non-functional localStorage stub). + try { + this.enabled = localStorage.getItem('sable_internal_debug') === '1'; + } catch { + this.enabled = false; + } // Load disabled breadcrumb categories try { const stored = localStorage.getItem(BREADCRUMB_DISABLED_KEY); diff --git a/src/ext.d.ts b/src/ext.d.ts index 7ee0a20a8..cbded3fb2 100644 --- a/src/ext.d.ts +++ b/src/ext.d.ts @@ -3,6 +3,7 @@ declare const APP_VERSION: string; declare const BUILD_HASH: string; declare const IS_RELEASE_TAG: boolean; +declare const INJECTED_EXPERIMENT_FLAGS: Record; declare module 'browser-encrypt-attachment' { export interface EncryptedAttachmentInfo { diff --git a/src/test/setup.ts b/src/test/setup.ts index 7b0828bfa..28c93a12c 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -1 +1,29 @@ import '@testing-library/jest-dom'; + +// Node.js 22+ ships a built-in `localStorage` stub that throws for getItem/setItem +// unless --localstorage-file is supplied at startup. jsdom relies on being able to +// define window.localStorage, but Node's version can prevent that. We install an +// in-memory implementation unconditionally so every test environment starts with a +// working, isolated localStorage regardless of runtime version. +const _store = new Map(); +const _localStorage = { + getItem: (key: string): string | null => _store.get(key) ?? null, + setItem: (key: string, value: string): void => { + _store.set(key, value); + }, + removeItem: (key: string): void => { + _store.delete(key); + }, + clear: (): void => { + _store.clear(); + }, + get length(): number { + return _store.size; + }, + key: (index: number): string | null => [..._store.keys()][index] ?? null, +}; +Object.defineProperty(globalThis, 'localStorage', { + value: _localStorage, + writable: true, + configurable: true, +}); diff --git a/src/types/matrix-sdk-events.d.ts b/src/types/matrix-sdk-events.d.ts index 1f6ad2f83..5de192774 100644 --- a/src/types/matrix-sdk-events.d.ts +++ b/src/types/matrix-sdk-events.d.ts @@ -49,5 +49,9 @@ declare module 'matrix-js-sdk/lib/@types/event' { 'im.ponies.emote_rooms': EmoteRoomsContent; 'moe.sable.app.nicknames': Record; 'moe.sable.app.settings': Record; + // MSC4438 bookmark index — lists the per-bookmark event keys in order + 'org.matrix.msc4438.bookmarks.index': { bookmark_ids: string[] }; + // Individual MSC4438 bookmark events (dynamic keys handled by prefix convention) + [key: `org.matrix.msc4438.bookmark.${string}`]: { room_id: string; event_id: string }; } } diff --git a/src/types/matrix/accountData.ts b/src/types/matrix/accountData.ts index 283859cd3..488aeff10 100644 --- a/src/types/matrix/accountData.ts +++ b/src/types/matrix/accountData.ts @@ -8,6 +8,9 @@ export const CustomAccountDataEvent = { // because of a mistake hasn't been renamed in time SablePerProfileMessageProfiles: 'fyi.cisnt.permessageprofile', SableSettings: 'moe.sable.app.settings', + // MSC4438 bookmarks — individual bookmark events + an index + MSC4438BookmarkPrefix: 'org.matrix.msc4438.bookmark.', + MSC4438BookmarksIndex: 'org.matrix.msc4438.bookmarks.index', } as const; export type CustomAccountDataEvent = (typeof CustomAccountDataEvent)[keyof typeof CustomAccountDataEvent]; diff --git a/vite.config.ts b/vite.config.ts index 7b482ef78..52c4ae5e0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -50,6 +50,12 @@ const resolveBuildHash = (): string | undefined => { const appVersion = packageJson.version; const buildHash = resolveBuildHash(); +const injectedExperimentFlags = Object.fromEntries( + Object.entries(process.env) + .filter(([k]) => k.startsWith('VITE_FEATURE_')) + .map(([k, v]) => [k.slice('VITE_FEATURE_'.length).toLowerCase().replace(/_/g, '-'), v === 'true']) +); + const isReleaseTag = (() => { const envVal = process.env.VITE_IS_RELEASE_TAG; if (envVal !== undefined && envVal !== '') return envVal === 'true'; @@ -131,6 +137,7 @@ export default defineConfig(({ command }) => ({ APP_VERSION: JSON.stringify(appVersion), BUILD_HASH: JSON.stringify(buildHash ?? ''), IS_RELEASE_TAG: JSON.stringify(isReleaseTag), + INJECTED_EXPERIMENT_FLAGS: JSON.stringify(injectedExperimentFlags), }, resolve: { alias: { diff --git a/vitest.config.ts b/vitest.config.ts index fedea1151..e786534ef 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -26,9 +26,13 @@ export default defineConfig({ APP_VERSION: JSON.stringify('test'), BUILD_HASH: JSON.stringify(''), IS_RELEASE_TAG: JSON.stringify(false), + INJECTED_EXPERIMENT_FLAGS: JSON.stringify({}), }, test: { environment: 'jsdom', + environmentOptions: { + jsdom: { url: 'http://localhost/' }, + }, globals: true, setupFiles: ['./src/test/setup.ts'], include: ['src/**/*.{test,spec}.{ts,tsx}'],