From 8d0b7e6f2309742c7d0a5a1075a5e8ed24084db0 Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Thu, 28 May 2026 21:07:58 -0700 Subject: [PATCH 001/303] chore: establish MindRoom build and dependency baseline --- .dockerignore | 3 + .eslintrc.cjs | 50 +- .gitignore | 18 +- Dockerfile | 7 +- build.config.ts | 11 +- config.mindroom.json | 124 + docker-entrypoint.d/99-runtime-config.sh | 18 + docker-nginx.conf | 16 + index.html | 149 +- package-lock.json | 2736 ++++++++++++++++--- package.json | 58 +- playwright.config.ts | 55 + public/runtime-config.js | 2 + scripts/create-mindroom-e2e-account.sh | 78 + scripts/e2e-matrix-down.sh | 8 + scripts/e2e-matrix-up.sh | 36 + scripts/ensure-e2e-account.sh | 115 + scripts/fork_release_tag.py | 194 ++ scripts/report-non-mindroom-source-diff.mjs | 56 + scripts/report-package-dependency-diff.mjs | 198 ++ scripts/test-e2e-docker-matrix.sh | 99 + scripts/test-e2e-mindroom.sh | 51 + scripts/with-mindroom-tunnel.sh | 36 + src/ext.d.ts | 8 + src/sw-session.ts | 45 + src/sw.ts | 120 +- tsconfig.json | 6 +- vite.config.js | 109 +- vitest.config.ts | 17 + 29 files changed, 4041 insertions(+), 382 deletions(-) create mode 100644 config.mindroom.json create mode 100755 docker-entrypoint.d/99-runtime-config.sh create mode 100644 playwright.config.ts create mode 100644 public/runtime-config.js create mode 100755 scripts/create-mindroom-e2e-account.sh create mode 100755 scripts/e2e-matrix-down.sh create mode 100755 scripts/e2e-matrix-up.sh create mode 100755 scripts/ensure-e2e-account.sh create mode 100644 scripts/fork_release_tag.py create mode 100644 scripts/report-non-mindroom-source-diff.mjs create mode 100644 scripts/report-package-dependency-diff.mjs create mode 100755 scripts/test-e2e-docker-matrix.sh create mode 100755 scripts/test-e2e-mindroom.sh create mode 100755 scripts/with-mindroom-tunnel.sh create mode 100644 vitest.config.ts diff --git a/.dockerignore b/.dockerignore index be0443aea8..741b13c5b5 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,2 +1,5 @@ node_modules/ .git/ +dist/ +playwright-report/ +test-results/ diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 9d5a27d90b..3a70ee5035 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -3,6 +3,7 @@ module.exports = { browser: true, es2021: true, }, + ignorePatterns: ['dist', 'node_modules', '**/*.css'], extends: [ "eslint:recommended", "plugin:react/recommended", @@ -31,10 +32,39 @@ module.exports = { 'linebreak-style': 0, 'no-underscore-dangle': 0, "no-shadow": "off", + 'no-undef': 'off', + 'no-unused-vars': 'off', + 'no-use-before-define': 'off', + 'no-continue': 'off', + 'no-restricted-syntax': 'off', + 'no-param-reassign': 'off', + 'no-await-in-loop': 'off', + 'no-nested-ternary': 'off', + 'no-bitwise': 'off', + 'no-void': 'off', + 'no-plusplus': 'off', + 'no-loop-func': 'off', + 'no-empty': 'off', + 'no-empty-function': 'off', + 'no-useless-constructor': 'off', + 'no-useless-return': 'off', + 'no-lonely-if': 'off', + 'default-param-last': 'off', + 'consistent-return': 'off', + 'prefer-destructuring': 'off', + 'prefer-template': 'off', + 'operator-assignment': 'off', + 'max-classes-per-file': 'off', + 'class-methods-use-this': 'off', + 'arrow-body-style': 'off', + 'no-console': 'warn', + 'prefer-const': 'warn', "import/prefer-default-export": "off", "import/extensions": "off", "import/no-unresolved": "off", + 'import/no-duplicates': 'off', + 'import/first': 'off', "import/no-extraneous-dependencies": [ "error", { @@ -42,6 +72,10 @@ module.exports = { }, ], + 'react/button-has-type': 'off', + 'react/function-component-definition': 'off', + 'react/no-unused-prop-types': 'off', + 'react/jsx-no-useless-fragment': 'off', 'react/no-unstable-nested-components': [ 'error', { allowAsProps: true }, @@ -55,11 +89,21 @@ module.exports = { "react/require-default-props": "off", "react/jsx-props-no-spreading": "off", + 'jsx-a11y/role-supports-aria-props': 'off', + 'jsx-a11y/no-static-element-interactions': 'off', "react-hooks/rules-of-hooks": "error", - "react-hooks/exhaustive-deps": "error", + "react-hooks/exhaustive-deps": "warn", - "@typescript-eslint/no-unused-vars": "error", - "@typescript-eslint/no-shadow": "error" + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-shadow': 'off', + "@typescript-eslint/no-unused-vars": [ + 'warn', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + ignoreRestSiblings: true, + }, + ] }, overrides: [ { diff --git a/.gitignore b/.gitignore index 1af58a970d..7dd1206951 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,22 @@ experiment dist node_modules devAssets +playwright-report +test-results .DS_Store -.idea \ No newline at end of file +.idea +.claude/TASK.md +.claude/ +dist.*/ +dist-*/ +result + +# Capacitor Android build outputs +android/.gradle/ +android/build/ +android/app/build/ +android/app/release/ +android/local.properties +android/captures/ +android/.idea/ diff --git a/Dockerfile b/Dockerfile index 5772e56ddb..9e47c6d8f6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,14 @@ ## Builder -FROM node:24.13.1-alpine AS builder +FROM --platform=$BUILDPLATFORM node:24.13.1-alpine AS builder WORKDIR /src COPY .npmrc package.json package-lock.json /src/ -RUN npm ci +RUN npm ci --fetch-retries=5 --fetch-retry-mintimeout=20000 --fetch-retry-maxtimeout=120000 COPY . /src/ ENV NODE_OPTIONS=--max_old_space_size=4096 +ARG APP_BUILD_BASE_PATH +ENV APP_BUILD_BASE_PATH=${APP_BUILD_BASE_PATH} RUN npm run build @@ -15,6 +17,7 @@ FROM nginx:1.29.8-alpine COPY --from=builder /src/dist /app COPY --from=builder /src/docker-nginx.conf /etc/nginx/conf.d/default.conf +COPY docker-entrypoint.d/99-runtime-config.sh /docker-entrypoint.d/99-runtime-config.sh RUN rm -rf /usr/share/nginx/html \ && ln -s /app /usr/share/nginx/html diff --git a/build.config.ts b/build.config.ts index ec8a41d087..2c0496d6ad 100644 --- a/build.config.ts +++ b/build.config.ts @@ -1,3 +1,12 @@ +import { normalizeBasePath } from './src/app/utils/basePathShared'; + +const buildBasePath = process.env.APP_BUILD_BASE_PATH; +const normalizedBuildBasePath = buildBasePath ? normalizeBasePath(buildBasePath) : undefined; + export default { - base: '/', + base: normalizedBuildBasePath + ? normalizedBuildBasePath === '/' + ? '/' + : `${normalizedBuildBasePath}/` + : './', }; diff --git a/config.mindroom.json b/config.mindroom.json new file mode 100644 index 0000000000..2b711598ad --- /dev/null +++ b/config.mindroom.json @@ -0,0 +1,124 @@ +{ + "defaultHomeserver": 0, + "homeserverList": ["mindroom.chat"], + "allowCustomHomeservers": true, + + "push": { + "ios": { + "enabled": false, + "appId": "com.mindroom-ai.app.ios", + "gatewayUrl": "", + "appDisplayName": "MindRoom iOS", + "deviceDisplayName": "MindRoom iOS", + "append": true, + "format": "event_id_only" + } + }, + + "featuredCommunities": { + "openAsDefault": false, + "spaces": [], + "rooms": [], + "servers": ["https://mindroom.chat"] + }, + + "hashRouter": { + "enabled": false, + "basename": "/" + }, + + "splash": { + "loadingMessages": [ + "Loading MindRoom", + "Booting the agents", + "Warming the agents", + "Assembling context", + "Opening the room" + ] + }, + + "mindroom": { + "thinkingPlaceholderMessages": [ + "Thinking", + "Making progress", + "Almost there", + "Boosting the GPUs", + "Checking the thread", + "Composing the reply", + "Herding photons", + "Negotiating with tensors", + "Summoning context", + "Asking the Matrix nicely", + "Reticulating tokens", + "Sharpening the embeddings", + "Consulting the gradients", + "Warming the context window", + "Polishing the logits", + "Rehydrating memories", + "Aligning the vibes", + "Convincing the GPU", + "Reading between tokens", + "Untangling the prompt", + "Brewing an answer", + "Distilling context", + "Spinning up neurons", + "Syncing the room state", + "Resolving event DAGs", + "Backfilling context", + "Joining the right room", + "Checking the homeserver", + "Sending typing events", + "Waiting for federation", + "Power-leveling the prompt", + "Redacting bad ideas", + "Finding the thread root", + "Paging the event timeline", + "Assembling Matrix events", + "Inviting agents to the room" + ] + }, + + "sidebar": { + "showThreads": true, + "showExploreCommunity": false, + "showAddSpace": false, + "showMindRoom": true, + "mindRoomUrl": "https://docs.mindroom.chat/" + }, + + "auth": { + "hideServerPickerWhenSingle": false, + "allowRegistration": true, + "requireAppleProvider": true, + "supportUrl": "https://docs.mindroom.chat/support", + "privacyPolicyUrl": "https://docs.mindroom.chat/privacy", + "termsUrl": "https://docs.mindroom.chat/terms" + }, + + "welcome": { + "title": "Welcome to MindRoom", + "subtitle": "Your AI is trapped in apps. We set it free.", + "sourceLabel": "Source Code", + "sourceUrl": "https://github.com/mindroom-ai/mindroom", + "docsLabel": "Docs", + "docsUrl": "https://docs.mindroom.chat/", + "poweredBy": [ + { + "label": "MindRoom", + "url": "https://github.com/mindroom-ai/mindroom" + }, + { + "label": "Matrix", + "url": "https://matrix.org" + }, + { + "label": "Cinny", + "url": "https://github.com/cinnyapp/cinny" + }, + { + "label": "MindRoom Cinny Fork", + "url": "https://github.com/mindroom-ai/mindroom-cinny" + } + ] + } +} diff --git a/docker-entrypoint.d/99-runtime-config.sh b/docker-entrypoint.d/99-runtime-config.sh new file mode 100755 index 0000000000..1b64d99db5 --- /dev/null +++ b/docker-entrypoint.d/99-runtime-config.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env sh +set -eu + +BASE_PATH="${APP_BASE_PATH:-/}" +RAW_ENABLE_SERVICE_WORKER="${APP_ENABLE_SERVICE_WORKER:-true}" +case "${RAW_ENABLE_SERVICE_WORKER}" in + 1|true|TRUE|yes|YES|on|ON) + ENABLE_SERVICE_WORKER="true" + ;; + *) + ENABLE_SERVICE_WORKER="false" + ;; +esac + +cat > /usr/share/nginx/html/runtime-config.js < + + + + + + + + + - - Cinny - - + + MindRoom + + - - - + + + - - + - - + + - + =16.0.0" @@ -1807,6 +1833,314 @@ "node": ">=6.9.0" } }, + "node_modules/@basnijholt/particular-drift": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@basnijholt/particular-drift/-/particular-drift-0.1.0.tgz", + "integrity": "sha512-3Q9cVkDsXJvJzGfF5VjESFiO++kxzxX+L0ekUMw1CQwx91/XyDhOL2uxfKvJ6r+UlaL2xPsqYHGhGy2KX8sOmg==", + "license": "MIT", + "peerDependencies": { + "react": ">=18" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } + }, + "node_modules/@capacitor/android": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@capacitor/android/-/android-8.1.0.tgz", + "integrity": "sha512-z0acTPxj5DCy/U2FU7w+GA93oC+wdyKnsOcRg5rutDmSYa8Do1tzYqApKgf+hnuTNPbtrCTHp0Zy1cLiK/4MEw==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": "^8.1.0" + } + }, + "node_modules/@capacitor/app": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@capacitor/app/-/app-8.0.1.tgz", + "integrity": "sha512-yeG3yyA0ETKqvgqexwHMBlmVOF13A1hRXzv/km0Ptv5TrNIZvZJK4MTI3uiqvnbHrzoJGP5DwWAjEXEfi90v3Q==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/app-launcher": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@capacitor/app-launcher/-/app-launcher-8.0.1.tgz", + "integrity": "sha512-23D8zi74sn7kxvISix8qYwgqdxGJN+4NImcNGvHen98LB1zb4eZfkJvFvp7pwntxGw4OjIE7yuf4wbzZxQHpog==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/browser": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@capacitor/browser/-/browser-8.0.1.tgz", + "integrity": "sha512-cCEYK9DGJvamfCKOh95QP+VafApblySDR/eodOcl1Qt+d/Y+NNPzc8NYm/dztQlRf3aMLJ7ne1HrRCfu2UOMnw==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/cli": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-8.1.0.tgz", + "integrity": "sha512-JAzA/ckPgTCjZz6YumBLV2dNCFEVXAuR1oOKLD7AJ4LAI5pF5RtRZrf5FoaxvJVb0S4CouZT5cD+7NwsNJX/nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/cli-framework-output": "^2.2.8", + "@ionic/utils-subprocess": "^3.0.1", + "@ionic/utils-terminal": "^2.3.5", + "commander": "^12.1.0", + "debug": "^4.4.0", + "env-paths": "^2.2.0", + "fs-extra": "^11.2.0", + "kleur": "^4.1.5", + "native-run": "^2.0.3", + "open": "^8.4.0", + "plist": "^3.1.0", + "prompts": "^2.4.2", + "rimraf": "^6.0.1", + "semver": "^7.6.3", + "tar": "^7.5.3", + "tslib": "^2.8.1", + "xml2js": "^0.6.2" + }, + "bin": { + "cap": "bin/capacitor", + "capacitor": "bin/capacitor" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@capacitor/cli/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@capacitor/cli/node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@capacitor/cli/node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@capacitor/cli/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@capacitor/cli/node_modules/fs-extra": { + "version": "11.3.6", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.6.tgz", + "integrity": "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@capacitor/cli/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@capacitor/cli/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@capacitor/cli/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/@capacitor/cli/node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@capacitor/cli/node_modules/rimraf": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", + "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "glob": "^13.0.3", + "package-json-from-dist": "^1.0.1" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@capacitor/cli/node_modules/tar": { + "version": "7.5.19", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.19.tgz", + "integrity": "sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@capacitor/cli/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@capacitor/core": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.1.0.tgz", + "integrity": "sha512-UfMBMWc1v7J+14AhH03QmeNwV3HZx3qnOWhpwnHfzALEwAwlV/itQOQqcasMQYhOHWL0tiymc5ByaLTn7KKQxw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@capacitor/haptics": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@capacitor/haptics/-/haptics-8.0.0.tgz", + "integrity": "sha512-DY1IUOjke1T4ITl7mFHQIKCaJJyHYAYRYHG9bVApU7PDOZiMVGMp48Yjzdqjya+wv/AHS5mDabSTUmhJ5uDvBA==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/ios": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-8.1.0.tgz", + "integrity": "sha512-wPaQ8ayL0UY+SxydZbrmfzPs8TryZVEB6hrL/XBnXxQtb6rpEmIy/3GIltPwM+/T4m/IuaSDZOToWK65ruC00g==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": "^8.1.0" + } + }, + "node_modules/@capacitor/keyboard": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@capacitor/keyboard/-/keyboard-8.0.0.tgz", + "integrity": "sha512-ycPW6iQyFwzDK95jihesj5EGiyyGSfbBqNek11iNp9tBOB7zDeYkUA2S/vPpOETt3dhP6pWr7a9gNVGuEfj11g==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/push-notifications": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@capacitor/push-notifications/-/push-notifications-8.0.1.tgz", + "integrity": "sha512-CDY6syMxdG81Epn+V4fCYRR4L9tVS72F1kVrTY2ka6umWy4BmwXOXPsxGxlE5PmHLOhmBwKpy3uOiIJtSpMhOw==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/splash-screen": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@capacitor/splash-screen/-/splash-screen-8.0.1.tgz", + "integrity": "sha512-c/ew/Z3eA7z8l06WoRAtzVF16VwYYrExmHmfGq1Cg675pVzaC/yuucB8/1xG1vhEfnW4fZ1KhSf/kzR1RiVYgg==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, + "node_modules/@capacitor/status-bar": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-8.0.1.tgz", + "integrity": "sha512-OR59dlbwvmrV5dKsC9lvwv48QaGbqcbSTBpk+9/WXWxXYSdXXdzJZU9p8oyNPAkuJhCdnSa3XmU43fZRPBJJ5w==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, "node_modules/@commitlint/config-validator": { "version": "21.0.1", "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-21.0.1.tgz", @@ -1888,6 +2222,59 @@ "node": ">=22.12.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@element-hq/element-call-embedded": { "version": "0.20.1", "resolved": "https://registry.npmjs.org/@element-hq/element-call-embedded/-/element-call-embedded-0.20.1.tgz", @@ -2278,16 +2665,45 @@ "node": ">=12" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, "node_modules/@eslint/eslintrc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.4.1.tgz", - "integrity": "sha512-XXrH9Uarn0stsyldqDYq8r++mROmWRI1xKMXa640Bb//SY1+ECYX6VzT6Lcx5frD0V30XieqJ0oX9I2Xj5aoMA==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", "dev": true, "license": "MIT", "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", - "espree": "^9.4.0", + "espree": "^9.6.0", "globals": "^13.19.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", @@ -2326,6 +2742,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/@fontsource/inter": { "version": "4.5.14", "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-4.5.14.tgz", @@ -2384,14 +2810,14 @@ } }, "node_modules/@humanwhocodes/config-array": { - "version": "0.11.14", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", - "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", "deprecated": "Use @eslint/config-array instead", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanwhocodes/object-schema": "^2.0.2", + "@humanwhocodes/object-schema": "^2.0.3", "debug": "^4.3.1", "minimatch": "^3.0.5" }, @@ -2480,6 +2906,184 @@ "@swc/helpers": "^0.5.0" } }, + "node_modules/@ionic/cli-framework-output": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/@ionic/cli-framework-output/-/cli-framework-output-2.2.8.tgz", + "integrity": "sha512-TshtaFQsovB4NWRBydbNFawql6yul7d5bMiW1WYYf17hd99V6xdDdk3vtF51bw6sLkxON3bDQpWsnUc9/hVo3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-terminal": "2.3.5", + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-array": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@ionic/utils-array/-/utils-array-2.1.6.tgz", + "integrity": "sha512-0JZ1Zkp3wURnv8oq6Qt7fMPo5MpjbLoUoa9Bu2Q4PJuSDWM8H8gwF3dQO7VTeUj3/0o1IB1wGkFWZZYgUXZMUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-fs": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@ionic/utils-fs/-/utils-fs-3.1.7.tgz", + "integrity": "sha512-2EknRvMVfhnyhL1VhFkSLa5gOcycK91VnjfrTB0kbqkTFCOXyXgVLI5whzq7SLrgD9t1aqos3lMMQyVzaQ5gVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^8.0.0", + "debug": "^4.0.0", + "fs-extra": "^9.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-object": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@ionic/utils-object/-/utils-object-2.1.6.tgz", + "integrity": "sha512-vCl7sl6JjBHFw99CuAqHljYJpcE88YaH2ZW4ELiC/Zwxl5tiwn4kbdP/gxi2OT3MQb1vOtgAmSNRtusvgxI8ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-process": { + "version": "2.1.12", + "resolved": "https://registry.npmjs.org/@ionic/utils-process/-/utils-process-2.1.12.tgz", + "integrity": "sha512-Jqkgyq7zBs/v/J3YvKtQQiIcxfJyplPgECMWgdO0E1fKrrH8EF0QGHNJ9mJCn6PYe2UtHNS8JJf5G21e09DfYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-object": "2.1.6", + "@ionic/utils-terminal": "2.3.5", + "debug": "^4.0.0", + "signal-exit": "^3.0.3", + "tree-kill": "^1.2.2", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@ionic/utils-stream/-/utils-stream-3.1.7.tgz", + "integrity": "sha512-eSELBE7NWNFIHTbTC2jiMvh1ABKGIpGdUIvARsNPMNQhxJB3wpwdiVnoBoTYp+5a6UUIww4Kpg7v6S7iTctH1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-subprocess": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@ionic/utils-subprocess/-/utils-subprocess-3.0.1.tgz", + "integrity": "sha512-cT4te3AQQPeIM9WCwIg8ohroJ8TjsYaMb2G4ZEgv9YzeDqHZ4JpeIKqG2SoaA3GmVQ3sOfhPM6Ox9sxphV/d1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-array": "2.1.6", + "@ionic/utils-fs": "3.1.7", + "@ionic/utils-process": "2.1.12", + "@ionic/utils-stream": "3.1.7", + "@ionic/utils-terminal": "2.3.5", + "cross-spawn": "^7.0.3", + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-terminal": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@ionic/utils-terminal/-/utils-terminal-2.3.5.tgz", + "integrity": "sha512-3cKScz9Jx2/Pr9ijj1OzGlBDfcmx7OMVBt4+P1uRR0SSW4cm1/y3Mo4OY3lfkuaYifMNBW8Wz6lQHbs1bihr7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/slice-ansi": "^4.0.0", + "debug": "^4.0.0", + "signal-exit": "^3.0.3", + "slice-ansi": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "tslib": "^2.0.1", + "untildify": "^4.0.0", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-terminal/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@ionic/utils-terminal/node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/@ionic/utils-terminal/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/@isaacs/cliui": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", @@ -2490,6 +3094,29 @@ "node": ">=18" } }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@isaacs/fs-minipass/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -2620,6 +3247,22 @@ "node": ">= 8" } }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@react-aria/breadcrumbs": { "version": "3.6.1", "resolved": "https://registry.npmjs.org/@react-aria/breadcrumbs/-/breadcrumbs-3.6.1.tgz", @@ -4699,6 +5342,32 @@ "@swc/counter": "^0.1.3" } }, + "node_modules/@tabler/icons": { + "version": "3.40.0", + "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.40.0.tgz", + "integrity": "sha512-V/Q4VgNPKubRTiLdmWjV/zscYcj5IIk+euicUtaVVqF6luSC9rDngYWgST5/yh3Mrg/mYUwRv1YVTk71Jp0twQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/codecalm" + } + }, + "node_modules/@tabler/icons-react": { + "version": "3.40.0", + "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.40.0.tgz", + "integrity": "sha512-oO5+6QCnna4a//mYubx4euZfECtzQZFDGsDMIdzZUhbdyBCT+3bRVFBPueGIcemWld4Vb/0UQ39C/cmGfGylAg==", + "license": "MIT", + "dependencies": { + "@tabler/icons": "3.40.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/codecalm" + }, + "peerDependencies": { + "react": ">= 16" + } + }, "node_modules/@tanstack/query-core": { "version": "5.24.1", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.24.1.tgz", @@ -4779,6 +5448,16 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tootallnate/once": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz", + "integrity": "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/@trickfilm400/rollup-plugin-off-main-thread": { "version": "3.0.0-pre1", "resolved": "https://registry.npmjs.org/@trickfilm400/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-3.0.0-pre1.tgz", @@ -4876,6 +5555,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/fs-extra": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-8.1.5.tgz", + "integrity": "sha512-0dzKcwO+S8s2kuF5Z9oUWatQJj5Uq/iqphEtE3GQJVRRYm/tD1LglU2UnXi2A8jLq5umkGouOXOR9y0n613ZwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/is-hotkey": { "version": "0.1.10", "resolved": "https://registry.npmjs.org/@types/is-hotkey/-/is-hotkey-0.1.10.tgz", @@ -4949,6 +5638,16 @@ "@types/react": "*" } }, + "node_modules/@types/react-test-renderer": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-18.3.1.tgz", + "integrity": "sha512-vAhnk0tG2eGa37lkU9+s5SoroCsRI08xnsWFiAXOuPH2jqzMbcXvKExXViPi1P5fIklDeCvXqyrdmipFaSkZrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "^18" + } + }, "node_modules/@types/resolve": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", @@ -4980,6 +5679,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-+OpjSaq85gvlZAYINyzKpLeiFkSC4EsC6IIiT6v6TLSU5k5U83fHGj9Lel8oKEXM0HqgrMVCjXPDPVICtxF7EQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -4995,32 +5701,34 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "5.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.46.1.tgz", - "integrity": "sha512-YpzNv3aayRBwjs4J3oz65eVLXc9xx0PDbIRisHj+dYhvBn02MjYOD96P8YGiWEIFBrojaUjxvkaUpakD82phsA==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", + "integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "5.46.1", - "@typescript-eslint/type-utils": "5.46.1", - "@typescript-eslint/utils": "5.46.1", + "@eslint-community/regexpp": "^4.5.1", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/type-utils": "6.21.0", + "@typescript-eslint/utils": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", "debug": "^4.3.4", - "ignore": "^5.2.0", - "natural-compare-lite": "^1.4.0", - "regexpp": "^3.2.0", - "semver": "^7.3.7", - "tsutils": "^3.21.0" + "graphemer": "^1.4.0", + "ignore": "^5.2.4", + "natural-compare": "^1.4.0", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^16.0.0 || >=18.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^5.0.0", - "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + "@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha", + "eslint": "^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "typescript": { @@ -5029,26 +5737,27 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "5.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.46.1.tgz", - "integrity": "sha512-RelQ5cGypPh4ySAtfIMBzBGyrNerQcmfA1oJvPj5f+H4jI59rl9xxpn4bonC0tQvUKOEN7eGBFWxFLK3Xepneg==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", + "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/scope-manager": "5.46.1", - "@typescript-eslint/types": "5.46.1", - "@typescript-eslint/typescript-estree": "5.46.1", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", "debug": "^4.3.4" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^16.0.0 || >=18.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + "eslint": "^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "typescript": { @@ -5057,17 +5766,17 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "5.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.46.1.tgz", - "integrity": "sha512-iOChVivo4jpwUdrJZyXSMrEIM/PvsbbDOX1y3UCKjSgWn+W89skxWaYXACQfxmIGhPVpRWK/VWPYc+bad6smIA==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", + "integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "5.46.1", - "@typescript-eslint/visitor-keys": "5.46.1" + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^16.0.0 || >=18.0.0" }, "funding": { "type": "opencollective", @@ -5075,26 +5784,26 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "5.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.46.1.tgz", - "integrity": "sha512-V/zMyfI+jDmL1ADxfDxjZ0EMbtiVqj8LUGPAGyBkXXStWmCUErMpW873zEHsyguWCuq2iN4BrlWUkmuVj84yng==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz", + "integrity": "sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "5.46.1", - "@typescript-eslint/utils": "5.46.1", + "@typescript-eslint/typescript-estree": "6.21.0", + "@typescript-eslint/utils": "6.21.0", "debug": "^4.3.4", - "tsutils": "^3.21.0" + "ts-api-utils": "^1.0.1" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^16.0.0 || >=18.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "*" + "eslint": "^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "typescript": { @@ -5103,13 +5812,13 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "5.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.46.1.tgz", - "integrity": "sha512-Z5pvlCaZgU+93ryiYUwGwLl9AQVB/PQ1TsJ9NZ/gHzZjN7g9IAn6RSDkpCV8hqTwAiaj6fmCcKSQeBPlIpW28w==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", + "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", "dev": true, "license": "MIT", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^16.0.0 || >=18.0.0" }, "funding": { "type": "opencollective", @@ -5117,22 +5826,23 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "5.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.46.1.tgz", - "integrity": "sha512-j9W4t67QiNp90kh5Nbr1w92wzt+toiIsaVPnEblB2Ih2U9fqBTyqV9T3pYWZBRt6QoMh/zVWP59EpuCjc4VRBg==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", + "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/types": "5.46.1", - "@typescript-eslint/visitor-keys": "5.46.1", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", - "semver": "^7.3.7", - "tsutils": "^3.21.0" + "minimatch": "9.0.3", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^16.0.0 || >=18.0.0" }, "funding": { "type": "opencollective", @@ -5144,51 +5854,83 @@ } } }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@typescript-eslint/utils": { - "version": "5.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.46.1.tgz", - "integrity": "sha512-RBdBAGv3oEpFojaCYT4Ghn4775pdjvwfDOfQ2P6qzNVgQOVrnSPe5/Pb88kv7xzYQjoio0eKHKB9GJ16ieSxvA==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz", + "integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==", "dev": true, "license": "MIT", "dependencies": { - "@types/json-schema": "^7.0.9", - "@types/semver": "^7.3.12", - "@typescript-eslint/scope-manager": "5.46.1", - "@typescript-eslint/types": "5.46.1", - "@typescript-eslint/typescript-estree": "5.46.1", - "eslint-scope": "^5.1.1", - "eslint-utils": "^3.0.0", - "semver": "^7.3.7" + "@eslint-community/eslint-utils": "^4.4.0", + "@types/json-schema": "^7.0.12", + "@types/semver": "^7.5.0", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "semver": "^7.5.4" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^16.0.0 || >=18.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + "eslint": "^7.0.0 || ^8.0.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "5.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.46.1.tgz", - "integrity": "sha512-jczZ9noovXwy59KjRTk1OftT78pwygdcmCuBf8yMoWt/8O8l+6x2LSEze0E4TeepXK4MezW3zGSyoDRZK7Y9cg==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", + "integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "5.46.1", - "eslint-visitor-keys": "^3.3.0" + "@typescript-eslint/types": "6.21.0", + "eslint-visitor-keys": "^3.4.1" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^16.0.0 || >=18.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.2.tgz", + "integrity": "sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA==", + "dev": true, + "license": "ISC" + }, "node_modules/@vanilla-extract/babel-plugin-debug-ids": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@vanilla-extract/babel-plugin-debug-ids/-/babel-plugin-debug-ids-1.2.2.tgz", @@ -5238,107 +5980,308 @@ "vite-node": "^1.2.0" } }, - "node_modules/@vanilla-extract/integration/node_modules/@vanilla-extract/css": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/@vanilla-extract/css/-/css-1.20.1.tgz", - "integrity": "sha512-5I9RNo5uZW9tsBnqrWzJqELegOqTHBrZyDFnES0gR9gJJHBB9dom1N0bwITM9tKwBcfKrTX4a6DHVeQdJ2ubQA==", + "node_modules/@vanilla-extract/integration/node_modules/@vanilla-extract/css": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/@vanilla-extract/css/-/css-1.20.1.tgz", + "integrity": "sha512-5I9RNo5uZW9tsBnqrWzJqELegOqTHBrZyDFnES0gR9gJJHBB9dom1N0bwITM9tKwBcfKrTX4a6DHVeQdJ2ubQA==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.0", + "@vanilla-extract/private": "^1.0.9", + "css-what": "^6.1.0", + "csstype": "^3.2.3", + "dedent": "^1.5.3", + "deep-object-diff": "^1.1.9", + "deepmerge": "^4.2.2", + "lru-cache": "^10.4.3", + "media-query-parser": "^2.0.2", + "modern-ahocorasick": "^1.0.0", + "picocolors": "^1.0.0" + } + }, + "node_modules/@vanilla-extract/integration/node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/@vanilla-extract/integration/node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/@vanilla-extract/integration/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/@vanilla-extract/private": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@vanilla-extract/private/-/private-1.0.9.tgz", + "integrity": "sha512-gT2jbfZuaaCLrAxwXbRgIhGhcXbRZCG3v4TTUnjw0EJ7ArdBRxkq4msNJkbuRkCgfIK5ATmprB5t9ljvLeFDEA==", + "license": "MIT" + }, + "node_modules/@vanilla-extract/recipes": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@vanilla-extract/recipes/-/recipes-0.3.0.tgz", + "integrity": "sha512-7wXrgfq1oldKdBfCKen4XmSlDmQR+4o0CQ3WnnLfhQaEtI65xJ774yyQF6dD2CC+hHdW2LFKVXgH5NZRbMQ8Sg==", + "license": "MIT", + "peerDependencies": { + "@vanilla-extract/css": "^1.0.0" + } + }, + "node_modules/@vanilla-extract/vite-plugin": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/@vanilla-extract/vite-plugin/-/vite-plugin-3.7.1.tgz", + "integrity": "sha512-KFeTSEJKtJDfQhUJh4jGmrJDLCU59DSA3YKZSdys4jTOLZ1ZFsKzDP2pnFwH/24Oc2ebK+EV5x3OPlWxvRYthg==", + "license": "MIT", + "dependencies": { + "@vanilla-extract/integration": "^6.0.2", + "outdent": "^0.8.0", + "postcss": "^8.3.6", + "postcss-load-config": "^3.1.0" + }, + "peerDependencies": { + "vite": "^2.2.3 || ^3.0.0 || ^4.0.3" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.2.0.tgz", + "integrity": "sha512-+MHTH/e6H12kRp5HUkzOGqPMksezRMmW+TNzlh/QXfI8rRf6l2Z2yH/v12no1UvTwhZgEDMuQ7g7rrfMseU6FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.23.3", + "@babel/plugin-transform-react-jsx-self": "^7.23.3", + "@babel/plugin-transform-react-jsx-source": "^7.23.3", + "@types/babel__core": "^7.20.4", + "react-refresh": "^0.14.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.8.tgz", + "integrity": "sha512-8ytZ/fFHq2g4PJVAtDX57mayemKgDR6X3Oa2Foro+EygiOJHUXhCqBAAKQYYajZpFoIfvBCF1j6R6IYRSIUFuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.8", + "@vitest/utils": "2.1.8", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.8.tgz", + "integrity": "sha512-7guJ/47I6uqfttp33mgo6ga5Gr1VnL58rcqYKyShoRK9ebu8T5Rs6HN3s1NABiBeVTdWNrwUMcHH54uXZBN4zA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.8", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/mocker/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.8.tgz", + "integrity": "sha512-17ub8vQstRnRlIU5k50bG+QOMLHRhYPAna5tw8tYbj+jzjcspnwnwtPtiOlkuKC4+ixDPTuLZiqiWWQ2PSXHVg==", + "dev": true, "license": "MIT", "dependencies": { - "@emotion/hash": "^0.9.0", - "@vanilla-extract/private": "^1.0.9", - "css-what": "^6.1.0", - "csstype": "^3.2.3", - "dedent": "^1.5.3", - "deep-object-diff": "^1.1.9", - "deepmerge": "^4.2.2", - "lru-cache": "^10.4.3", - "media-query-parser": "^2.0.2", - "modern-ahocorasick": "^1.0.0", - "picocolors": "^1.0.0" + "@vitest/utils": "2.1.8", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@vanilla-extract/integration/node_modules/css-what": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", - "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", - "license": "BSD-2-Clause", - "engines": { - "node": ">= 6" + "node_modules/@vitest/runner/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.8.tgz", + "integrity": "sha512-20T7xRFbmnkfcmgVEz+z3AU/3b0cEzZOt/zmnvZEctg64/QZbSDJEVm9fLnnlSi74KibmRsO9/Qabi+t0vCRPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.8", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" }, "funding": { - "url": "https://github.com/sponsors/fb55" + "url": "https://opencollective.com/vitest" } }, - "node_modules/@vanilla-extract/integration/node_modules/dedent": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", - "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.8.tgz", + "integrity": "sha512-9HiSZ9zpqNLKlbIDRWOnAWqgcA7xu+8YxXSekhr0Ykab7PAYFkhkwoqVArPOtJhPmYeE2YHgKZlj3CP36z2AJQ==", + "dev": true, "license": "MIT", - "peerDependencies": { - "babel-plugin-macros": "^3.1.0" + "dependencies": { + "tinyrainbow": "^1.2.0" }, - "peerDependenciesMeta": { - "babel-plugin-macros": { - "optional": true - } + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@vanilla-extract/integration/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "license": "ISC" + "node_modules/@vitest/snapshot/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } }, - "node_modules/@vanilla-extract/private": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@vanilla-extract/private/-/private-1.0.9.tgz", - "integrity": "sha512-gT2jbfZuaaCLrAxwXbRgIhGhcXbRZCG3v4TTUnjw0EJ7ArdBRxkq4msNJkbuRkCgfIK5ATmprB5t9ljvLeFDEA==", + "node_modules/@vitest/snapshot/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, "license": "MIT" }, - "node_modules/@vanilla-extract/recipes": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@vanilla-extract/recipes/-/recipes-0.3.0.tgz", - "integrity": "sha512-7wXrgfq1oldKdBfCKen4XmSlDmQR+4o0CQ3WnnLfhQaEtI65xJ774yyQF6dD2CC+hHdW2LFKVXgH5NZRbMQ8Sg==", + "node_modules/@vitest/spy": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.8.tgz", + "integrity": "sha512-5swjf2q95gXeYPevtW0BLk6H8+bPlMb4Vw/9Em4hFxDcaOxS+e0LOX4yqNxoHzMR2akEB2xfpnWUzkZokmgWDg==", + "dev": true, "license": "MIT", - "peerDependencies": { - "@vanilla-extract/css": "^1.0.0" + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@vanilla-extract/vite-plugin": { - "version": "3.7.1", - "resolved": "https://registry.npmjs.org/@vanilla-extract/vite-plugin/-/vite-plugin-3.7.1.tgz", - "integrity": "sha512-KFeTSEJKtJDfQhUJh4jGmrJDLCU59DSA3YKZSdys4jTOLZ1ZFsKzDP2pnFwH/24Oc2ebK+EV5x3OPlWxvRYthg==", + "node_modules/@vitest/utils": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.8.tgz", + "integrity": "sha512-dwSoui6djdwbfFmIgbIjX2ZhIoG7Ex/+xpxyiEgIGzjliY8xGkcpITKTlp6B4MgtGkF2ilvm97cPM96XZaAgcA==", + "dev": true, "license": "MIT", "dependencies": { - "@vanilla-extract/integration": "^6.0.2", - "outdent": "^0.8.0", - "postcss": "^8.3.6", - "postcss-load-config": "^3.1.0" + "@vitest/pretty-format": "2.1.8", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" }, - "peerDependencies": { - "vite": "^2.2.3 || ^3.0.0 || ^4.0.3" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitejs/plugin-react": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.2.0.tgz", - "integrity": "sha512-+MHTH/e6H12kRp5HUkzOGqPMksezRMmW+TNzlh/QXfI8rRf6l2Z2yH/v12no1UvTwhZgEDMuQ7g7rrfMseU6FQ==", + "node_modules/@vitest/utils/node_modules/@vitest/pretty-format": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.8.tgz", + "integrity": "sha512-9HiSZ9zpqNLKlbIDRWOnAWqgcA7xu+8YxXSekhr0Ykab7PAYFkhkwoqVArPOtJhPmYeE2YHgKZlj3CP36z2AJQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.23.3", - "@babel/plugin-transform-react-jsx-self": "^7.23.3", - "@babel/plugin-transform-react-jsx-source": "^7.23.3", - "@types/babel__core": "^7.20.4", - "react-refresh": "^0.14.0" + "tinyrainbow": "^1.2.0" }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.9.10", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz", + "integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==", + "dev": true, + "license": "MIT", "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0" + "node": ">=14.6" } }, + "node_modules/abab": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", + "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", + "deprecated": "Use your platform's native atob() and btoa() methods instead", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -5372,8 +6315,8 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "debug": "4" }, @@ -5694,6 +6637,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types-flow": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.7.tgz", @@ -5701,6 +6654,16 @@ "dev": true, "license": "ISC" }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -5718,6 +6681,13 @@ "node": ">= 0.4" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/at-least-node": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", @@ -5874,6 +6844,16 @@ "node": ">=6.0.0" } }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "dev": true, + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -5936,6 +6916,19 @@ "integrity": "sha512-r/As72u2FbucLoK5NTegM/GucxJc3d8GvHc4ngo13IO/nt2HU4gONxNLq1XPN6EM/V8Y9URIa7PcSz2RZu553A==", "license": "MIT" }, + "node_modules/bplist-parser": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.2.tgz", + "integrity": "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "big-integer": "1.6.x" + }, + "engines": { + "node": ">= 5.10.0" + } + }, "node_modules/brace-expansion": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", @@ -6033,6 +7026,16 @@ "ieee754": "^1.2.1" } }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -6155,6 +7158,23 @@ "node": ">=6" } }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -6178,6 +7198,16 @@ "dev": true, "license": "MIT" }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -6422,6 +7452,19 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "14.0.3", "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", @@ -6681,6 +7724,19 @@ "node": ">=4" } }, + "node_modules/cssstyle": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-3.0.0.tgz", + "integrity": "sha512-N4u2ABATi3Qplzf0hWbVCdjenim8F3ojEXpBDF5hBpjzW182MjNGLqfmQ0SkSPeQ+V86ZXgeH8aXj6kayd4jgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "rrweb-cssom": "^0.6.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -6793,6 +7849,58 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/data-urls": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-4.0.0.tgz", + "integrity": "sha512-/mMTei/JXPqvFqQtfyTowxmJVwr2PVAeCcDxyFf6LhoOu/09TX2OX3kb2wzi4DMXcfj4OItwDOnhl5oziPnT6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "abab": "^2.0.6", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^12.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-4.1.1.tgz", + "integrity": "sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-12.0.1.tgz", + "integrity": "sha512-Ed/LrqB8EPlGxjS+TrsXcpUond1mhccS3pchLhzSgPCnTimUCKj3IZE75pAs5m6heB2U2TMerKFUXheyHY+VDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^4.1.1", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -6905,6 +8013,16 @@ "dev": true, "license": "MIT" }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -6958,6 +8076,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/define-properties": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", @@ -6976,6 +8104,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/delegates": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", @@ -7090,6 +8228,30 @@ ], "license": "BSD-2-Clause" }, + "node_modules/domexception": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", + "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "license": "MIT", + "dependencies": { + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/domexception/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, "node_modules/domhandler": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", @@ -7156,6 +8318,19 @@ "integrity": "sha512-cUVA7/RvbFTEuw/i3obUwDTRIXojaxkResf+ibByPFxjc6XK3VNtcQXV0NSbAlJ0FMjcJGgftVVB4Qo184EXvA==", "license": "ISC" }, + "node_modules/elementtree": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/elementtree/-/elementtree-0.1.7.tgz", + "integrity": "sha512-wkgGT6kugeQk/P6VZ/f4T+4HB41BVgNBq5CDIZVbQ02nvTVqAiVTbskxxu3eA/X96lMlfYOwnLQpN2v5E1zDEg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "sax": "1.1.4" + }, + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -7204,7 +8379,6 @@ "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">=6" } @@ -7341,6 +8515,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -7475,51 +8656,50 @@ } }, "node_modules/eslint": { - "version": "8.29.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.29.0.tgz", - "integrity": "sha512-isQ4EEiyUjZFbEKvEGJKKGBwXtvXX+zJbkVKCgTuB9t/+jUBcy8avhkEwWJecI15BkRkOYmvIM5ynbhRjEkoeg==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", "dependencies": { - "@eslint/eslintrc": "^1.3.3", - "@humanwhocodes/config-array": "^0.11.6", + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", - "ajv": "^6.10.0", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", "debug": "^4.3.2", "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.1.1", - "eslint-utils": "^3.0.0", - "eslint-visitor-keys": "^3.3.0", - "espree": "^9.4.0", - "esquery": "^1.4.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^6.0.1", "find-up": "^5.0.0", "glob-parent": "^6.0.2", - "globals": "^13.15.0", - "grapheme-splitter": "^1.0.4", + "globals": "^13.19.0", + "graphemer": "^1.4.0", "ignore": "^5.2.0", - "import-fresh": "^3.0.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "is-path-inside": "^3.0.3", - "js-sdsl": "^4.1.4", "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", "levn": "^0.4.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", - "optionator": "^0.9.1", - "regexpp": "^3.2.0", + "optionator": "^0.9.3", "strip-ansi": "^6.0.1", - "strip-json-comments": "^3.1.0", "text-table": "^0.2.0" }, "bin": { @@ -7816,59 +8996,6 @@ "semver": "bin/semver.js" } }, - "node_modules/eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/eslint-scope/node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/eslint-utils": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz", - "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^2.0.0" - }, - "engines": { - "node": "^10.0.0 || ^12.0.0 || >= 14.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/mysticatea" - }, - "peerDependencies": { - "eslint": ">=5" - } - }, - "node_modules/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", - "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10" - } - }, "node_modules/eslint-visitor-keys": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", @@ -8048,6 +9175,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -8126,6 +9263,16 @@ "reusify": "^1.0.4" } }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -8390,6 +9537,23 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fs-extra": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", @@ -8504,6 +9668,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fuse.js": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.3.0.tgz", + "integrity": "sha512-plz8RVjfcDedTGfVngWH1jmJvBvAwi1v2jecfDerbEnMcmOYUEEwKFTHbNoCiYyzaK2Ws8lABkTCcRSqCY1q4w==", + "license": "Apache-2.0", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/krisk" + } + }, "node_modules/gauge": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", @@ -8809,10 +9986,10 @@ "dev": true, "license": "ISC" }, - "node_modules/grapheme-splitter": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz", - "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true, "license": "MIT" }, @@ -8989,6 +10166,19 @@ "entities": "^4.5.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", + "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/html-parse-stringify": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", @@ -9032,12 +10222,27 @@ "entities": "^7.0.1" } }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "agent-base": "6", "debug": "4" @@ -9451,6 +10656,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-document.all": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", @@ -9673,6 +10894,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -9871,6 +11099,19 @@ "node": ">=0.10.0" } }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", @@ -9957,17 +11198,6 @@ } } }, - "node_modules/js-sdsl": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.4.2.tgz", - "integrity": "sha512-dwXFwByc/ajSV6m5bcKAPwe4yDDF6D614pxmIi5odytzxRlwqF6nwoiCek80Ixc7Cvma5awClxrzFtxCQvcM8w==", - "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/js-sdsl" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -9997,6 +11227,86 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "22.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-22.1.0.tgz", + "integrity": "sha512-/9AVW7xNbsBv6GfWho4TTNjEo9fe6Zhf9O7s0Fhhr3u+awPwAJMKwAMXnkk5vBxflqLW9hTHX/0cs+P3gW+cQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "abab": "^2.0.6", + "cssstyle": "^3.0.0", + "data-urls": "^4.0.0", + "decimal.js": "^10.4.3", + "domexception": "^4.0.0", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.1", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.4", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.6.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.2", + "w3c-xmlserializer": "^4.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^2.0.0", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^12.0.1", + "ws": "^8.13.0", + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "canvas": "^2.5.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-4.1.1.tgz", + "integrity": "sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-12.0.1.tgz", + "integrity": "sha512-Ed/LrqB8EPlGxjS+TrsXcpUond1mhccS3pchLhzSgPCnTimUCKj3IZE75pAs5m6heB2U2TMerKFUXheyHY+VDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^4.1.1", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -10098,6 +11408,31 @@ "node": ">=18" } }, + "node_modules/katex": { + "version": "0.16.44", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.44.tgz", + "integrity": "sha512-EkxoDTk8ufHqHlf9QxGwcxeLkWRR3iOuYfRpfORgYfqc8s13bgb+YtRY59NK5ZpRaCwq1kqA6a5lpX8C/eLphQ==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -10108,6 +11443,16 @@ "json-buffer": "3.0.1" } }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/language-subtag-registry": { "version": "0.3.23", "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", @@ -10622,6 +11967,13 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -10799,6 +12151,29 @@ "millify": "bin/millify" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", @@ -10971,6 +12346,42 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/native-run": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/native-run/-/native-run-2.0.3.tgz", + "integrity": "sha512-U1PllBuzW5d1gfan+88L+Hky2eZx+9gv3Pf6rNBxKbORxi7boHzqiA6QFGSnqMem4j0A9tZ08NMIs5+0m/VS1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-fs": "^3.1.7", + "@ionic/utils-terminal": "^2.3.4", + "bplist-parser": "^0.3.2", + "debug": "^4.3.4", + "elementtree": "^0.1.7", + "ini": "^4.1.1", + "plist": "^3.1.0", + "split2": "^4.2.0", + "through2": "^4.0.2", + "tslib": "^2.6.2", + "yauzl": "^2.10.0" + }, + "bin": { + "native-run": "bin/native-run" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/native-run/node_modules/ini": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", + "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -10978,13 +12389,6 @@ "dev": true, "license": "MIT" }, - "node_modules/natural-compare-lite": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz", - "integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==", - "dev": true, - "license": "MIT" - }, "node_modules/node-exports-info": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", @@ -11083,6 +12487,13 @@ "set-blocking": "^2.0.0" } }, + "node_modules/nwsapi": { + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", + "dev": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -11245,17 +12656,35 @@ "wrappy": "1" } }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", "dev": true, "license": "MIT", "dependencies": { - "mimic-fn": "^2.1.0" + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" }, "engines": { - "node": ">=6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -11428,6 +12857,32 @@ "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==", "license": "MIT" }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -11527,6 +12982,16 @@ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "license": "MIT" }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/pdfjs-dist": { "version": "4.2.67", "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-4.2.67.tgz", @@ -11540,6 +13005,13 @@ "path2d": "^0.2.0" } }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -11570,6 +13042,68 @@ "pathe": "^2.0.1" } }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/plist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz", + "integrity": "sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.9.10", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -11694,6 +13228,30 @@ "node": ">=6" } }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prompts/node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -11705,6 +13263,19 @@ "react-is": "^16.13.1" } }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -11715,6 +13286,13 @@ "node": ">=6" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true, + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -11959,6 +13537,20 @@ "react-dom": ">=16.8" } }, + "node_modules/react-shallow-renderer": { + "version": "16.15.0", + "resolved": "https://registry.npmjs.org/react-shallow-renderer/-/react-shallow-renderer-16.15.0.tgz", + "integrity": "sha512-oScf2FqQ9LFVQgA73vr86xl2NaOIX73rh+YFqcOp68CWj56tSfgtGKrEbyhCj0rSijyG9M1CYprTh39fBi5hzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4.1.1", + "react-is": "^16.12.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-stately": { "version": "3.48.0", "resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.48.0.tgz", @@ -11976,6 +13568,28 @@ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, + "node_modules/react-test-renderer": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-18.2.0.tgz", + "integrity": "sha512-JWD+aQ0lh2gvh4NM3bBM42Kx+XybOxCpgYK7F8ugAlpaTSnWsX+39Z4XkOykGZAHrjwwTZT3x3KxswVWxHPUqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "react-is": "^18.2.0", + "react-shallow-renderer": "^16.15.0", + "scheduler": "^0.23.0" + }, + "peerDependencies": { + "react": "^18.2.0" + } + }, + "node_modules/react-test-renderer/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -12081,19 +13695,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/regexpp": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", - "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/mysticatea" - } - }, "node_modules/regexpu-core": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", @@ -12159,6 +13760,13 @@ "node": "*" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT" + }, "node_modules/resolve": { "version": "2.0.0-next.7", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", @@ -12301,6 +13909,13 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz", + "integrity": "sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==", + "dev": true, + "license": "MIT" + }, "node_modules/run-async": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", @@ -12443,6 +14058,26 @@ "postcss": "^8.3.11" } }, + "node_modules/sax": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.1.4.tgz", + "integrity": "sha512-5f3k2PbGGp+YtKJjOItpg3P99IMD84E4HOvcfleTb5joCHNXYLsR9yWFPOYGgaeMPDubQILTCMdsFb2OMeOjtg==", + "dev": true, + "license": "ISC" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -12648,6 +14283,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -12688,6 +14330,13 @@ "simple-concat": "^1.0.0" } }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -12867,6 +14516,30 @@ "webidl-conversions": "^4.0.2" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -13121,6 +14794,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tabbable": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz", @@ -13235,12 +14915,29 @@ "dev": true, "license": "MIT" }, + "node_modules/through2": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", + "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "3" + } + }, "node_modules/tiny-invariant": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.1.tgz", "integrity": "sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", @@ -13268,6 +14965,36 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -13281,12 +15008,61 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie/node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "license": "MIT" }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-api-utils": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -13320,36 +15096,13 @@ "dev": true, "license": "MIT", "engines": { - "node": ">=4" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/tsutils": { - "version": "3.21.0", - "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", - "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^1.8.1" - }, - "engines": { - "node": ">= 6" - }, - "peerDependencies": { - "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" + "node": ">=4" } }, - "node_modules/tsutils/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, "node_modules/type-check": { @@ -13457,9 +15210,9 @@ } }, "node_modules/typescript": { - "version": "4.9.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.4.tgz", - "integrity": "sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg==", + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.2.tgz", + "integrity": "sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -13467,7 +15220,7 @@ "tsserver": "bin/tsserver" }, "engines": { - "node": ">=4.2.0" + "node": ">=14.17" } }, "node_modules/ua-parser-js": { @@ -13587,6 +15340,16 @@ "node": ">= 10.0.0" } }, + "node_modules/untildify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", + "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/upath": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz", @@ -13638,6 +15401,17 @@ "punycode": "^2.1.0" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/use-sync-external-store": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", @@ -14242,6 +16016,119 @@ "@esbuild/win32-x64": "0.21.5" } }, + "node_modules/vitest": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.8.tgz", + "integrity": "sha512-1vBKTZskHw/aosXqQUlVWWlGUxSJR8YtiyZDJAFeW2kPAeX6S3Sool0mjspO+kXLuxVWlEDDowBAeqeAQefqLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.8", + "@vitest/mocker": "2.1.8", + "@vitest/pretty-format": "^2.1.8", + "@vitest/runner": "2.1.8", + "@vitest/snapshot": "2.1.8", + "@vitest/spy": "2.1.8", + "@vitest/utils": "2.1.8", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.8", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.8", + "@vitest/ui": "2.1.8", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/vitest/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vitest/node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/vitest/node_modules/vite-node": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.8.tgz", + "integrity": "sha512-uPAwSr57kYjAUux+8E2j0q0Fxpn8M9VoyfGiRI8Kfktz9NcYMCenwY5RnZxnF1WTu3TGiYipirIzacLL3VVGFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", @@ -14251,6 +16138,19 @@ "node": ">=0.10.0" } }, + "node_modules/w3c-xmlserializer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", + "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/wcwidth": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", @@ -14267,6 +16167,43 @@ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "license": "BSD-2-Clause" }, + "node_modules/whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -14382,6 +16319,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wide-align": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", @@ -14559,6 +16513,28 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/workbox-build/node_modules/workbox-precaching": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.4.1.tgz", + "integrity": "sha512-cdr/9qByww7yzEp7zg/qI4ukUrrNjQLgN+ONQRpjy/VqGQXwkgHwr00KksGJK8v0VifwDXBb8a4cWNZH71jn3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1", + "workbox-routing": "7.4.1", + "workbox-strategies": "7.4.1" + } + }, + "node_modules/workbox-build/node_modules/workbox-routing": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.1.tgz", + "integrity": "sha512-yubJGErZOusuidAenaL5ypfhQOa7urxP/f8E0ws7FPb4039RiWXUWBAyUkmUoOL/BcQGen3h0J8872d51IYxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1" + } + }, "node_modules/workbox-cacheable-response": { "version": "7.4.1", "resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-7.4.1.tgz", @@ -14600,6 +16576,16 @@ "workbox-strategies": "7.4.1" } }, + "node_modules/workbox-google-analytics/node_modules/workbox-routing": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.1.tgz", + "integrity": "sha512-yubJGErZOusuidAenaL5ypfhQOa7urxP/f8E0ws7FPb4039RiWXUWBAyUkmUoOL/BcQGen3h0J8872d51IYxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1" + } + }, "node_modules/workbox-navigation-preload": { "version": "7.4.1", "resolved": "https://registry.npmjs.org/workbox-navigation-preload/-/workbox-navigation-preload-7.4.1.tgz", @@ -14611,15 +16597,32 @@ } }, "node_modules/workbox-precaching": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.4.1.tgz", - "integrity": "sha512-cdr/9qByww7yzEp7zg/qI4ukUrrNjQLgN+ONQRpjy/VqGQXwkgHwr00KksGJK8v0VifwDXBb8a4cWNZH71jn3Q==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.3.0.tgz", + "integrity": "sha512-ckp/3t0msgXclVAYaNndAGeAoWQUv7Rwc4fdhWL69CCAb2UHo3Cef0KIUctqfQj1p8h6aGyz3w8Cy3Ihq9OmIw==", "dev": true, "license": "MIT", "dependencies": { - "workbox-core": "7.4.1", - "workbox-routing": "7.4.1", - "workbox-strategies": "7.4.1" + "workbox-core": "7.3.0", + "workbox-routing": "7.3.0", + "workbox-strategies": "7.3.0" + } + }, + "node_modules/workbox-precaching/node_modules/workbox-core": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.3.0.tgz", + "integrity": "sha512-Z+mYrErfh4t3zi7NVTvOuACB0A/jA3bgxUN3PwtAVHvfEsZxV9Iju580VEETug3zYJRc0Dmii/aixI/Uxj8fmw==", + "dev": true, + "license": "MIT" + }, + "node_modules/workbox-precaching/node_modules/workbox-strategies": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.3.0.tgz", + "integrity": "sha512-tmZydug+qzDFATwX7QiEL5Hdf7FrkhjaF9db1CbB39sDmEZJg3l9ayDvPxy8Y18C3Y66Nrr9kkN1f/RlkDgllg==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.3.0" } }, "node_modules/workbox-range-requests": { @@ -14647,7 +16650,19 @@ "workbox-strategies": "7.4.1" } }, - "node_modules/workbox-routing": { + "node_modules/workbox-recipes/node_modules/workbox-precaching": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.4.1.tgz", + "integrity": "sha512-cdr/9qByww7yzEp7zg/qI4ukUrrNjQLgN+ONQRpjy/VqGQXwkgHwr00KksGJK8v0VifwDXBb8a4cWNZH71jn3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1", + "workbox-routing": "7.4.1", + "workbox-strategies": "7.4.1" + } + }, + "node_modules/workbox-recipes/node_modules/workbox-routing": { "version": "7.4.1", "resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.1.tgz", "integrity": "sha512-yubJGErZOusuidAenaL5ypfhQOa7urxP/f8E0ws7FPb4039RiWXUWBAyUkmUoOL/BcQGen3h0J8872d51IYxtA==", @@ -14657,6 +16672,23 @@ "workbox-core": "7.4.1" } }, + "node_modules/workbox-routing": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.3.0.tgz", + "integrity": "sha512-ZUlysUVn5ZUzMOmQN3bqu+gK98vNfgX/gSTZ127izJg/pMMy4LryAthnYtjuqcjkN4HEAx1mdgxNiKJMZQM76A==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.3.0" + } + }, + "node_modules/workbox-routing/node_modules/workbox-core": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.3.0.tgz", + "integrity": "sha512-Z+mYrErfh4t3zi7NVTvOuACB0A/jA3bgxUN3PwtAVHvfEsZxV9Iju580VEETug3zYJRc0Dmii/aixI/Uxj8fmw==", + "dev": true, + "license": "MIT" + }, "node_modules/workbox-strategies": { "version": "7.4.1", "resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.4.1.tgz", @@ -14678,6 +16710,16 @@ "workbox-routing": "7.4.1" } }, + "node_modules/workbox-streams/node_modules/workbox-routing": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.1.tgz", + "integrity": "sha512-yubJGErZOusuidAenaL5ypfhQOa7urxP/f8E0ws7FPb4039RiWXUWBAyUkmUoOL/BcQGen3h0J8872d51IYxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1" + } + }, "node_modules/workbox-sw": { "version": "7.4.1", "resolved": "https://registry.npmjs.org/workbox-sw/-/workbox-sw-7.4.1.tgz", @@ -14718,6 +16760,79 @@ "devOptional": true, "license": "ISC" }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", + "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12" + } + }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "dev": true, + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xml2js/node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -14776,6 +16891,17 @@ "node": ">=12" } }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index d68709745c..ad5ebfad92 100644 --- a/package.json +++ b/package.json @@ -11,14 +11,30 @@ "start": "vite", "build": "vite build", "preview": "vite preview", - "lint": "npm run check:eslint && npm run check:prettier", - "check:eslint": "eslint src/*", + "lint": "npm run check:eslint", + "check:eslint": "eslint \"src/**/*.{js,jsx,ts,tsx}\"", "check:prettier": "prettier --check .", "fix:prettier": "prettier --write .", "typecheck": "tsc --noEmit", "prepare": "husky install", "commit": "git-cz", - "bump": "node scripts/update-version.js" + "bump": "node scripts/update-version.js", + "test": "vitest run", + "test:watch": "vitest", + "test:e2e": "playwright test", + "e2e:matrix:up": "./scripts/e2e-matrix-up.sh", + "e2e:matrix:down": "./scripts/e2e-matrix-down.sh", + "test:e2e:docker-matrix": "./scripts/test-e2e-docker-matrix.sh", + "test:e2e:headed": "playwright test --headed", + "test:e2e:mindroom": "./scripts/test-e2e-mindroom.sh", + "test:e2e:mindroom:headed": "./scripts/test-e2e-mindroom.sh --headed", + "ios:phone": "node scripts/ios-phone.mjs", + "ios:phone:watch": "node scripts/ios-phone.mjs --watch", + "ios:phone:bg": "node scripts/ios-phone.mjs --background", + "ios:phone:stop": "node scripts/ios-phone.mjs --stop", + "ios:icons": "bash scripts/generate-ios-icons.sh", + "appstore:preflight": "node scripts/appstore-preflight.mjs", + "release:next-tag": "python3 scripts/fork_release_tag.py" }, "lint-staged": { "*.{ts,tsx,js,jsx}": "eslint", @@ -36,7 +52,23 @@ "@atlaskit/pragmatic-drag-and-drop": "1.1.6", "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0", "@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3", + "@basnijholt/particular-drift": "0.1.0", + "@capacitor/android": "8.1.0", + "@capacitor/app": "8.0.1", + "@capacitor/app-launcher": "8.0.1", + "@capacitor/browser": "8.0.1", + "@capacitor/core": "8.1.0", + "@capacitor/haptics": "8.0.0", + "@capacitor/ios": "8.1.0", + "@capacitor/keyboard": "8.0.0", + "@capacitor/push-notifications": "8.0.1", + "@capacitor/splash-screen": "8.0.1", + "@capacitor/status-bar": "8.0.1", + "@dnd-kit/core": "6.3.1", + "@dnd-kit/sortable": "10.0.0", + "@dnd-kit/utilities": "3.2.2", "@fontsource/inter": "4.5.14", + "@tabler/icons-react": "3.40.0", "@tanstack/react-query": "5.24.1", "@tanstack/react-query-devtools": "5.24.1", "@tanstack/react-virtual": "3.2.0", @@ -57,6 +89,7 @@ "file-saver": "2.0.5", "focus-trap-react": "10.0.2", "folds": "2.6.2", + "fuse.js": "7.3.0", "html-dom-parser": "4.0.0", "html-react-parser": "4.2.0", "i18next": "23.12.2", @@ -65,6 +98,7 @@ "immer": "9.0.16", "is-hotkey": "0.2.0", "jotai": "2.6.0", + "katex": "0.16.44", "linkify-react": "4.3.2", "linkifyjs": "4.3.2", "matrix-js-sdk": "41.7.0", @@ -90,8 +124,10 @@ "ua-parser-js": "1.0.35" }, "devDependencies": { + "@capacitor/cli": "8.1.0", "@element-hq/element-call-embedded": "0.20.1", "@esbuild-plugins/node-globals-polyfill": "0.2.3", + "@playwright/test": "1.58.2", "@rollup/plugin-inject": "5.0.3", "@rollup/plugin-wasm": "6.1.1", "@types/chroma-js": "3.1.1", @@ -102,14 +138,15 @@ "@types/react": "18.2.39", "@types/react-dom": "18.2.17", "@types/react-google-recaptcha": "2.1.8", + "@types/react-test-renderer": "18.3.1", "@types/sanitize-html": "2.16.1", "@types/ua-parser-js": "0.7.36", - "@typescript-eslint/eslint-plugin": "5.46.1", - "@typescript-eslint/parser": "5.46.1", + "@typescript-eslint/eslint-plugin": "6.21.0", + "@typescript-eslint/parser": "6.21.0", "@vitejs/plugin-react": "4.2.0", "buffer": "6.0.3", "cz-conventional-changelog": "3.3.0", - "eslint": "8.29.0", + "eslint": "8.57.1", "eslint-config-airbnb": "19.0.4", "eslint-config-prettier": "8.5.0", "eslint-plugin-import": "2.29.1", @@ -117,12 +154,17 @@ "eslint-plugin-react": "7.31.11", "eslint-plugin-react-hooks": "4.6.0", "husky": "9.1.7", + "jsdom": "22.1.0", "lint-staged": "16.3.2", "prettier": "2.8.1", - "typescript": "4.9.4", + "react-test-renderer": "18.2.0", + "typescript": "5.4.2", "vite": "5.4.19", "vite-plugin-pwa": "0.20.5", "vite-plugin-static-copy": "1.0.4", - "vite-plugin-top-level-await": "1.4.4" + "vite-plugin-top-level-await": "1.4.4", + "vitest": "2.1.8", + "workbox-precaching": "7.3.0", + "workbox-routing": "7.3.0" } } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000000..708a6ae2ec --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,55 @@ +import { existsSync } from 'node:fs'; +import { defineConfig, devices } from '@playwright/test'; + +const port = Number(process.env.E2E_PORT ?? 4173); +const baseURL = process.env.E2E_BASE_URL ?? `http://127.0.0.1:${port}`; +const webServerCommand = + process.env.E2E_SERVER_COMMAND ?? + `npm run start -- --host 127.0.0.1 --port ${port} --strictPort`; +const chromiumExecutablePath = + process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE ?? + [ + '/run/current-system/sw/bin/chromium', + '/run/current-system/sw/bin/chromium-browser', + ].find((candidate) => existsSync(candidate)); + +export default defineConfig({ + testDir: './e2e', + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: 1, + timeout: 120_000, + expect: { + timeout: 30_000, + }, + reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : [['list']], + use: { + baseURL, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + launchOptions: chromiumExecutablePath + ? { + executablePath: chromiumExecutablePath, + } + : undefined, + }, + }, + ], + webServer: + process.env.E2E_NO_WEB_SERVER === '1' + ? undefined + : { + command: webServerCommand, + url: baseURL, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}); diff --git a/public/runtime-config.js b/public/runtime-config.js new file mode 100644 index 0000000000..5ddcb86bc6 --- /dev/null +++ b/public/runtime-config.js @@ -0,0 +1,2 @@ +window.__APP_BASE_PATH__ = '/'; +window.__ENABLE_SERVICE_WORKER__ = true; diff --git a/scripts/create-mindroom-e2e-account.sh b/scripts/create-mindroom-e2e-account.sh new file mode 100755 index 0000000000..595999b27d --- /dev/null +++ b/scripts/create-mindroom-e2e-account.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SSH_HOST="${MINDROOM_SSH_HOST:-mindroom}" +REMOTE_CONFIG_PATH="${MINDROOM_REMOTE_CONFIG_PATH:-/run/tuwunel/tuwunel.toml}" +REMOTE_BASE_URL="${MINDROOM_REMOTE_BASE_URL:-http://localhost:8008}" +VAR_PREFIX="${1:-E2E}" + +USERNAME="${E2E_ACCOUNT_USERNAME:-codexe2e$(date +%s)$(python3 - <<'PY' +import secrets +print(secrets.token_hex(2)) +PY +)}" +PASSWORD="${E2E_ACCOUNT_PASSWORD:-$(python3 - <<'PY' +import secrets +print("Pw" + secrets.token_hex(12) + "!") +PY +)}" + +ssh "${SSH_HOST}" "sudo USERNAME='${USERNAME}' PASSWORD='${PASSWORD}' REMOTE_CONFIG_PATH='${REMOTE_CONFIG_PATH}' REMOTE_BASE_URL='${REMOTE_BASE_URL}' python3 - <<\"PY\" +import json +import os +import re +import urllib.request +from pathlib import Path + +config = Path(os.environ['REMOTE_CONFIG_PATH']).read_text() +match = re.search(r'registration_token_file\\s*=\\s*\"([^\"]+)\"', config) +if not match: + raise SystemExit('registration token file not found') + +token = Path(match.group(1)).read_text().strip() +username = os.environ['USERNAME'] +password = os.environ['PASSWORD'] +base = os.environ['REMOTE_BASE_URL'].rstrip('/') + '/_matrix/client/v3/register' + +initial = { + 'username': username, + 'password': password, + 'initial_device_display_name': 'Codex E2E', +} + +request = urllib.request.Request( + base, + data=json.dumps(initial).encode(), + headers={'Content-Type': 'application/json'}, +) + +try: + urllib.request.urlopen(request) + raise SystemExit('unexpected success without registration token') +except urllib.error.HTTPError as exc: + if exc.code != 401: + raise + challenge = json.loads(exc.read().decode()) + +final_payload = { + **initial, + 'auth': { + 'type': 'm.login.registration_token', + 'session': challenge['session'], + 'token': token, + }, +} + +request = urllib.request.Request( + base, + data=json.dumps(final_payload).encode(), + headers={'Content-Type': 'application/json'}, +) +with urllib.request.urlopen(request) as response: + if response.status != 200: + raise SystemExit(f'unexpected status {response.status}') +PY" + +printf 'export %s_USERNAME=%q\n' "${VAR_PREFIX}" "${USERNAME}" +printf 'export %s_PASSWORD=%q\n' "${VAR_PREFIX}" "${PASSWORD}" diff --git a/scripts/e2e-matrix-down.sh b/scripts/e2e-matrix-down.sh new file mode 100755 index 0000000000..af217bc7f4 --- /dev/null +++ b/scripts/e2e-matrix-down.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +COMPOSE_FILE="${E2E_MATRIX_COMPOSE_FILE:-${ROOT_DIR}/e2e/docker-compose.matrix.yaml}" + +docker compose -f "${COMPOSE_FILE}" down "$@" diff --git a/scripts/e2e-matrix-up.sh b/scripts/e2e-matrix-up.sh new file mode 100755 index 0000000000..3112ececfd --- /dev/null +++ b/scripts/e2e-matrix-up.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +COMPOSE_FILE="${E2E_MATRIX_COMPOSE_FILE:-${ROOT_DIR}/e2e/docker-compose.matrix.yaml}" +MATRIX_PORT="${E2E_MATRIX_PORT:-28008}" +HOMESERVER_URL="${E2E_HOMESERVER:-http://127.0.0.1:${MATRIX_PORT}}" + +docker compose -f "${COMPOSE_FILE}" up -d + +python3 - "${HOMESERVER_URL}" <<'PY' +import json +import sys +import time +import urllib.error +import urllib.request + +base = sys.argv[1].rstrip("/") +url = f"{base}/_matrix/client/versions" +deadline = time.time() + 60 +last_error = "unknown error" + +while time.time() < deadline: + try: + with urllib.request.urlopen(url, timeout=5) as response: + payload = json.loads(response.read().decode()) + if isinstance(payload, dict) and "versions" in payload: + sys.exit(0) + last_error = f"unexpected payload: {payload!r}" + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError) as exc: + last_error = str(exc) + time.sleep(1) + +raise SystemExit(f"Timed out waiting for Matrix homeserver at {url}: {last_error}") +PY diff --git a/scripts/ensure-e2e-account.sh b/scripts/ensure-e2e-account.sh new file mode 100755 index 0000000000..b704fcef00 --- /dev/null +++ b/scripts/ensure-e2e-account.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [ "$#" -ne 3 ]; then + echo "usage: $0 " >&2 + exit 64 +fi + +VAR_PREFIX="$1" +DEFAULT_USERNAME="$2" +DEFAULT_PASSWORD="$3" +MATRIX_PORT="${E2E_MATRIX_PORT:-28008}" +HOMESERVER_URL="${E2E_HOMESERVER:-http://127.0.0.1:${MATRIX_PORT}}" + +USERNAME_VAR="${VAR_PREFIX}_USERNAME" +PASSWORD_VAR="${VAR_PREFIX}_PASSWORD" +USERNAME="${!USERNAME_VAR:-${DEFAULT_USERNAME}}" +PASSWORD="${!PASSWORD_VAR:-${DEFAULT_PASSWORD}}" + +python3 - "${HOMESERVER_URL}" "${USERNAME}" "${PASSWORD}" <<'PY' +import json +import sys +import urllib.error +import urllib.request + +base, username, password = sys.argv[1:4] +base = base.rstrip("/") + + +class MatrixError(Exception): + def __init__(self, status, payload): + super().__init__(payload.get("error") or payload.get("errcode") or f"HTTP {status}") + self.status = status + self.payload = payload + + +def request(path, payload): + req = urllib.request.Request( + f"{base}/_matrix/client/v3{path}", + data=json.dumps(payload).encode(), + headers={"Content-Type": "application/json"}, + ) + try: + with urllib.request.urlopen(req, timeout=10) as response: + return json.loads(response.read().decode()) + except urllib.error.HTTPError as exc: + try: + payload = json.loads(exc.read().decode()) + except json.JSONDecodeError: + payload = {"error": exc.reason, "errcode": f"HTTP_{exc.code}"} + raise MatrixError(exc.code, payload) from exc + + +def login(): + return request( + "/login", + { + "type": "m.login.password", + "identifier": {"type": "m.id.user", "user": username}, + "password": password, + "initial_device_display_name": "Cinny E2E", + }, + ) + + +def register(): + initial = { + "username": username, + "password": password, + "initial_device_display_name": "Cinny E2E", + } + + try: + return request("/register", initial) + except MatrixError as exc: + if exc.payload.get("errcode") == "M_USER_IN_USE": + return login() + if exc.status != 401: + raise + + session = exc.payload.get("session") + flows = exc.payload.get("flows") or [] + if not session: + raise + + auth_type = None + for flow in flows: + stages = flow.get("stages") or [] + if "m.login.dummy" in stages: + auth_type = "m.login.dummy" + break + if auth_type is None: + raise SystemExit(f"Unsupported registration flows for local e2e stack: {flows!r}") + + return request( + "/register", + { + **initial, + "auth": { + "type": auth_type, + "session": session, + }, + }, + ) + + +try: + login() +except MatrixError: + register() +PY + +printf 'export %s_USERNAME=%q\n' "${VAR_PREFIX}" "${USERNAME}" +printf 'export %s_PASSWORD=%q\n' "${VAR_PREFIX}" "${PASSWORD}" diff --git a/scripts/fork_release_tag.py b/scripts/fork_release_tag.py new file mode 100644 index 0000000000..31d4c23f94 --- /dev/null +++ b/scripts/fork_release_tag.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + + +SEMVER_PREFIX_RE = re.compile(r"^(\d+\.\d+\.\d+)(?:[-+].*)?$") + + +@dataclass +class ReleaseTag: + base_version: str + release_iteration: int + release_tag: str + reused_tag_at_head: bool + + +def run_git_tag(*args: str) -> list[str]: + output = subprocess.check_output(["git", "tag", *args], text=True) + return [line.strip() for line in output.splitlines() if line.strip()] + + +def parse_base_version(raw_version: str) -> str: + match = SEMVER_PREFIX_RE.match(raw_version.strip()) + if not match: + raise ValueError( + f'Unsupported version "{raw_version}". Expected something like "4.10.3".' + ) + return match.group(1) + + +def semver_key(version: str) -> tuple[int, int, int]: + major, minor, patch = version.split(".") + return int(major), int(minor), int(patch) + + +def compatible_prefixes(prefix: str) -> list[str]: + variants = [prefix] + if len(prefix) == 1 and prefix.isalpha(): + swapped = prefix.swapcase() + if swapped != prefix: + variants.append(swapped) + return variants + + +def build_prefix_pattern(prefix: str) -> str: + options = "|".join(re.escape(value) for value in compatible_prefixes(prefix)) + return f"(?:{options})" + + +def get_base_version_from_tags(all_tags: list[str], base_prefix: str) -> str | None: + prefix_pattern = build_prefix_pattern(base_prefix) + base_re = re.compile(rf"^{prefix_pattern}(\d+\.\d+\.\d+)$") + versions = [match.group(1) for tag in all_tags if (match := base_re.fullmatch(tag))] + if not versions: + return None + return sorted(versions, key=semver_key)[-1] + + +def get_release_iteration( + tag: str, base_version: str, release_prefix: str, release_suffix: str +) -> int | None: + prefix_pattern = build_prefix_pattern(release_prefix) + suffix_pattern = re.escape(release_suffix) + release_re = re.compile( + rf"^{prefix_pattern}{re.escape(base_version)}-{suffix_pattern}\.(\d+)$" + ) + match = release_re.fullmatch(tag) + return int(match.group(1)) if match else None + + +def compute_release_tag( + base_version: str, + all_tags: list[str], + head_tags: list[str], + release_prefix: str, + release_suffix: str, +) -> ReleaseTag: + all_iterations = [ + value + for value in ( + get_release_iteration(tag, base_version, release_prefix, release_suffix) + for tag in all_tags + ) + if value is not None + ] + max_iteration = max(all_iterations) if all_iterations else 0 + + head_iterations = [ + (tag, value) + for tag in head_tags + for value in [ + get_release_iteration(tag, base_version, release_prefix, release_suffix) + ] + if value is not None + ] + if head_iterations: + reused_tag, reused_iteration = sorted( + head_iterations, key=lambda item: item[1], reverse=True + )[0] + return ReleaseTag( + base_version=base_version, + release_iteration=reused_iteration, + release_tag=reused_tag, + reused_tag_at_head=True, + ) + + next_iteration = max_iteration + 1 + return ReleaseTag( + base_version=base_version, + release_iteration=next_iteration, + release_tag=f"{release_prefix}{base_version}-{release_suffix}.{next_iteration}", + reused_tag_at_head=False, + ) + + +def write_github_output(result: ReleaseTag) -> None: + output_path = os.getenv("GITHUB_OUTPUT") + if not output_path: + return + + with open(output_path, "a", encoding="utf-8") as output_file: + output_file.write(f"base_version={result.base_version}\n") + output_file.write(f"release_iteration={result.release_iteration}\n") + output_file.write(f"release_tag={result.release_tag}\n") + output_file.write(f"reused_tag_at_head={str(result.reused_tag_at_head).lower()}\n") + + +def read_package_version() -> str | None: + package_path = Path(__file__).resolve().parent.parent / "package.json" + if not package_path.exists(): + return None + package_json = json.loads(package_path.read_text(encoding="utf-8")) + version = package_json.get("version") + if not isinstance(version, str): + raise ValueError("package.json version must be a string") + return version.strip() + + +def main() -> int: + release_prefix = (os.getenv("RELEASE_TAG_PREFIX") or "v").strip() + base_prefix = (os.getenv("BASE_TAG_PREFIX") or release_prefix).strip() + release_suffix = (os.getenv("RELEASE_TAG_SUFFIX") or "mindroom").strip() + + if not release_prefix: + raise ValueError("RELEASE_TAG_PREFIX must not be empty") + if not release_suffix: + raise ValueError("RELEASE_TAG_SUFFIX must not be empty") + + all_tags = run_git_tag("--list") + head_tags = run_git_tag("--points-at", "HEAD") + raw_version = os.getenv("BASE_VERSION") + if raw_version: + base_version = parse_base_version(raw_version) + else: + package_version = read_package_version() + if package_version: + base_version = parse_base_version(package_version) + else: + base_version = get_base_version_from_tags(all_tags, base_prefix) + if not base_version: + raise ValueError( + "Could not auto-detect base version. Set BASE_VERSION or provide package.json version." + ) + + result = compute_release_tag( + base_version, + all_tags, + head_tags, + release_prefix, + release_suffix, + ) + write_github_output(result) + + print(f"base_version={result.base_version}") + print(f"release_iteration={result.release_iteration}") + print(f"release_tag={result.release_tag}") + print(f"reused_tag_at_head={str(result.reused_tag_at_head).lower()}") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except Exception as exc: + print(str(exc), file=sys.stderr) + raise SystemExit(1) from exc diff --git a/scripts/report-non-mindroom-source-diff.mjs b/scripts/report-non-mindroom-source-diff.mjs new file mode 100644 index 0000000000..b18acb015e --- /dev/null +++ b/scripts/report-non-mindroom-source-diff.mjs @@ -0,0 +1,56 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync, statSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const workTree = process.cwd(); +const gitFile = resolve(workTree, '.git'); +const env = { ...process.env }; + +if (existsSync(gitFile) && statSync(gitFile).isFile()) { + const gitFileBody = readFileSync(gitFile, 'utf8').trim(); + const gitdirPrefix = 'gitdir: '; + if (gitFileBody.startsWith(gitdirPrefix)) { + env.GIT_DIR = gitFileBody.slice(gitdirPrefix.length); + env.GIT_WORK_TREE = workTree; + } +} + +const [base = 'v4.11.1', target = 'HEAD'] = process.argv.slice(2); +const diffRange = `${base}..${target}`; +const output = execFileSync( + 'git', + ['diff', '--name-status', '--find-renames', diffRange, '--', 'src'], + { cwd: workTree, env, encoding: 'utf8' } +); + +const rows = output + .trim() + .split('\n') + .filter(Boolean) + .map((line) => { + const parts = line.split('\t'); + const path = parts[parts.length - 1]; + return { status: parts[0], path }; + }) + .filter(({ path }) => path.startsWith('src/') && !path.startsWith('src/app/mindroom/')); + +const groups = new Map(); +for (const row of rows) { + const parts = row.path.split('/'); + const key = parts[1] === 'app' ? `src/app/${parts[2] ?? ''}` : `src/${parts[1] ?? ''}`; + groups.set(key, (groups.get(key) ?? 0) + 1); +} + +console.log(`Non-MindRoom source files changed in ${diffRange}: ${rows.length}`); +for (const [group, count] of [...groups.entries()].sort((a, b) => b[1] - a[1])) { + console.log(`${String(count).padStart(4, ' ')} ${group}`); +} + +if (rows.length > 0) { + console.log(''); + for (const row of rows) { + console.log(`${row.status}\t${row.path}`); + } +} diff --git a/scripts/report-package-dependency-diff.mjs b/scripts/report-package-dependency-diff.mjs new file mode 100644 index 0000000000..49c10a196f --- /dev/null +++ b/scripts/report-package-dependency-diff.mjs @@ -0,0 +1,198 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync, statSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const workTree = process.cwd(); +const gitFile = resolve(workTree, '.git'); +const env = { ...process.env }; + +if (existsSync(gitFile) && statSync(gitFile).isFile()) { + const gitFileBody = readFileSync(gitFile, 'utf8').trim(); + const gitdirPrefix = 'gitdir: '; + if (gitFileBody.startsWith(gitdirPrefix)) { + env.GIT_DIR = gitFileBody.slice(gitdirPrefix.length); + env.GIT_WORK_TREE = workTree; + } +} + +const [base = 'v4.11.1', target = 'HEAD'] = process.argv.slice(2); + +const forkOwnedPackages = new Map( + [ + [ + 'MindRoom product/runtime', + [ + '@basnijholt/particular-drift', + '@dnd-kit/core', + '@dnd-kit/sortable', + '@dnd-kit/utilities', + '@tabler/icons-react', + 'fuse.js', + 'katex', + 'workbox-precaching', + 'workbox-routing', + ], + ], + [ + 'Native mobile', + [ + '@capacitor/android', + '@capacitor/app', + '@capacitor/app-launcher', + '@capacitor/browser', + '@capacitor/cli', + '@capacitor/core', + '@capacitor/haptics', + '@capacitor/ios', + '@capacitor/keyboard', + '@capacitor/push-notifications', + '@capacitor/splash-screen', + '@capacitor/status-bar', + ], + ], + [ + 'Test/tooling', + [ + '@playwright/test', + '@types/react-test-renderer', + '@typescript-eslint/eslint-plugin', + '@typescript-eslint/parser', + 'eslint', + 'jsdom', + 'patch-package', + 'react-test-renderer', + 'typescript', + 'vitest', + ], + ], + ].flatMap(([label, packages]) => packages.map((name) => [name, label])) +); + +const upstreamAdoptionPackages = new Set([ + '@element-hq/element-call-embedded', + '@types/sanitize-html', + 'cz-conventional-changelog', + 'husky', + 'lint-staged', + 'matrix-js-sdk', + 'matrix-widget-api', + 'sanitize-html', +]); + +const showFile = (ref, path) => + execFileSync('git', ['show', `${ref}:${path}`], { + cwd: workTree, + env, + encoding: 'utf8', + }); + +const readPackageJson = (ref) => JSON.parse(showFile(ref, 'package.json')); + +const diffNameStatus = (pathspec) => + execFileSync( + 'git', + ['diff', '--name-status', '--find-renames', `${base}..${target}`, '--', pathspec], + { + cwd: workTree, + env, + encoding: 'utf8', + } + ) + .trim() + .split('\n') + .filter(Boolean); + +const basePackage = readPackageJson(base); +const targetPackage = readPackageJson(target); + +const compareRecord = (baseRecord = {}, targetRecord = {}) => { + const names = new Set([...Object.keys(baseRecord), ...Object.keys(targetRecord)]); + + return [...names].sort().flatMap((name) => { + const from = baseRecord[name]; + const to = targetRecord[name]; + if (from === to) return []; + if (from === undefined) return [{ name, from: '-', to, change: 'added' }]; + if (to === undefined) return [{ name, from, to: '-', change: 'removed' }]; + return [{ name, from, to, change: 'changed' }]; + }); +}; + +const classifyPackage = (name) => { + if (forkOwnedPackages.has(name)) return forkOwnedPackages.get(name); + if (upstreamAdoptionPackages.has(name)) return 'Upstream adoption/rebase'; + return 'Unclassified'; +}; + +const dependencyChanges = [ + ...compareRecord(basePackage.dependencies, targetPackage.dependencies).map((row) => ({ + ...row, + section: 'dependencies', + })), + ...compareRecord(basePackage.devDependencies, targetPackage.devDependencies).map((row) => ({ + ...row, + section: 'devDependencies', + })), +]; + +const scriptChanges = compareRecord(basePackage.scripts, targetPackage.scripts); +const lockRoot = JSON.parse(showFile(target, 'package-lock.json')).packages?.[''] ?? {}; +const lockRootDeps = { + ...(lockRoot.dependencies ?? {}), + ...(lockRoot.devDependencies ?? {}), +}; + +const printRows = (rows, format) => { + if (rows.length === 0) { + console.log(' none'); + return; + } + + for (const row of rows) console.log(` ${format(row)}`); +}; + +console.log(`Package dependency diff in ${base}..${target}`); +console.log(''); + +console.log('Direct package changes:'); +printRows( + dependencyChanges, + ({ section, name, from, to, change }) => + `${section} ${change}: ${name} ${from} -> ${to} [${classifyPackage(name)}]` +); + +console.log(''); +console.log('Script changes:'); +printRows(scriptChanges, ({ name, from, to, change }) => `${change}: ${name} ${from} -> ${to}`); + +console.log(''); +console.log('Patch files:'); +printRows(diffNameStatus('patches'), (line) => line); + +const missingFromLockRoot = dependencyChanges + .filter(({ change }) => change !== 'removed') + .filter(({ name, to }) => lockRootDeps[name] !== to); + +console.log(''); +console.log('Target lockfile root manifest check:'); +if (missingFromLockRoot.length === 0) { + console.log(' package-lock.json root dependency entries match package.json'); +} else { + printRows( + missingFromLockRoot, + ({ name, to }) => `${name} expected ${to}, package-lock root has ${lockRootDeps[name] ?? '-'}` + ); +} + +const unclassified = dependencyChanges.filter( + ({ name }) => classifyPackage(name) === 'Unclassified' +); +if (unclassified.length > 0) { + console.log(''); + console.log( + 'Unclassified package changes need an ownership decision before clean-history staging.' + ); + process.exitCode = 1; +} diff --git a/scripts/test-e2e-docker-matrix.sh b/scripts/test-e2e-docker-matrix.sh new file mode 100755 index 0000000000..23d54a10fc --- /dev/null +++ b/scripts/test-e2e-docker-matrix.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +MATRIX_PORT="${E2E_MATRIX_PORT:-28008}" +HOMESERVER_URL="${E2E_HOMESERVER:-http://127.0.0.1:${MATRIX_PORT}}" +SERVER_NAME="${E2E_MATRIX_SERVER_NAME:-matrix.localhost}" +FIXTURE_ALIAS_LOCAL="${E2E_FIXTURE_ROOM_ALIAS_LOCAL:-cinny-e2e-fixture}" +FIXTURE_ALIAS="${E2E_FIXTURE_ROOM_ALIAS:-#${FIXTURE_ALIAS_LOCAL}:${SERVER_NAME}}" +DEPLOYED_PORT="${E2E_DEPLOYED_PORT:-28090}" +DEPLOYED_BASE_URL="${E2E_DEPLOYED_BASE_URL:-http://127.0.0.1:${DEPLOYED_PORT}}" +AUTO_DOWN="${E2E_MATRIX_AUTO_DOWN:-0}" + +cleanup() { + if [ -n "${DEPLOYED_PREVIEW_PID:-}" ]; then + kill "${DEPLOYED_PREVIEW_PID}" 2>/dev/null || true + wait "${DEPLOYED_PREVIEW_PID}" 2>/dev/null || true + fi + + if [ "${AUTO_DOWN}" = "1" ]; then + "${ROOT_DIR}/scripts/e2e-matrix-down.sh" >/dev/null + fi +} + +trap cleanup EXIT INT TERM + +export E2E_HOMESERVER="${HOMESERVER_URL}" +export E2E_FIXTURE_ROOM_ALIAS="${FIXTURE_ALIAS}" + +"${ROOT_DIR}/scripts/e2e-matrix-up.sh" + +RUN_SUFFIX="$(date +%s)${RANDOM}" +eval "$("${ROOT_DIR}/scripts/ensure-e2e-account.sh" E2E "cinnye2eprimary${RUN_SUFFIX}" 'Pwcinnye2eprimary123!')" +eval "$("${ROOT_DIR}/scripts/ensure-e2e-account.sh" E2E_SECOND "cinnye2esecond${RUN_SUFFIX}" 'Pwcinnye2esecond123!')" +eval "$("${ROOT_DIR}/scripts/ensure-e2e-account.sh" E2E_THIRD "cinnye2ethird${RUN_SUFFIX}" 'Pwcinnye2ethird123!')" +eval "$("${ROOT_DIR}/scripts/ensure-e2e-account.sh" E2E_DEACTIVATE "cinnye2edeactivate${RUN_SUFFIX}" 'Pwcinnye2edeactivate123!')" + +cd "${ROOT_DIR}" +node ./e2e/live/seed-fixture-room.mjs + +export E2E_FIXTURE_ROOM_ID="$( + node --input-type=module -e ' + const homeserver = process.env.E2E_HOMESERVER; + const alias = process.env.E2E_FIXTURE_ROOM_ALIAS; + const url = `${homeserver}/_matrix/client/v3/directory/room/${encodeURIComponent(alias)}`; + const response = await fetch(url); + const body = await response.json(); + if (!response.ok || typeof body.room_id !== "string") { + console.error(`Failed to resolve fixture room alias ${alias}: ${JSON.stringify(body)}`); + process.exit(1); + } + process.stdout.write(body.room_id); + ' +)" + +if [ "${E2E_ENABLE_DEPLOYED_FIXTURE:-1}" = "1" ]; then + npm run build >/dev/null + export E2E_DEPLOYED_BASE_URL="${DEPLOYED_BASE_URL}" + export E2E_DEPLOYED_HOMESERVER="${E2E_DEPLOYED_HOMESERVER:-${E2E_HOMESERVER}}" + export E2E_DEPLOYED_USERNAME="${E2E_DEPLOYED_USERNAME:-${E2E_USERNAME}}" + export E2E_DEPLOYED_PASSWORD="${E2E_DEPLOYED_PASSWORD:-${E2E_PASSWORD}}" + + ( + cd "${ROOT_DIR}/dist" + python3 ../serve.py "${DEPLOYED_PORT}" + ) >/tmp/cinny-e2e-preview.log 2>&1 & + DEPLOYED_PREVIEW_PID=$! + + python3 - "${E2E_DEPLOYED_BASE_URL}" <<'PY' +import sys +import time +import urllib.error +import urllib.request + +url = sys.argv[1].rstrip("/") + "/" +deadline = time.time() + 60 +last_error = "unknown error" + +while time.time() < deadline: + try: + with urllib.request.urlopen(url, timeout=5) as response: + if response.status == 200: + sys.exit(0) + last_error = f"HTTP {response.status}" + except (urllib.error.URLError, TimeoutError) as exc: + last_error = str(exc) + time.sleep(1) + +raise SystemExit(f"Timed out waiting for deployed preview at {url}: {last_error}") +PY +fi + +set +e +npx playwright test "$@" +exit_code=$? +set -e + +exit "${exit_code}" diff --git a/scripts/test-e2e-mindroom.sh b/scripts/test-e2e-mindroom.sh new file mode 100755 index 0000000000..89a6a9ba4a --- /dev/null +++ b/scripts/test-e2e-mindroom.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +FIXTURE_ALIAS="${E2E_FIXTURE_ROOM_ALIAS:-#cinny-e2e-fixture:mindroom.lab.mindroom.chat}" + +if [ -z "${E2E_USERNAME:-}" ] || [ -z "${E2E_PASSWORD:-}" ]; then + eval "$("${ROOT_DIR}/scripts/create-mindroom-e2e-account.sh" E2E)" + echo "Provisioned disposable primary e2e account via ${MINDROOM_SSH_HOST:-mindroom}." >&2 +fi + +if [ -z "${E2E_SECOND_USERNAME:-}" ] || [ -z "${E2E_SECOND_PASSWORD:-}" ]; then + eval "$("${ROOT_DIR}/scripts/create-mindroom-e2e-account.sh" E2E_SECOND)" + echo "Provisioned disposable secondary e2e account via ${MINDROOM_SSH_HOST:-mindroom}." >&2 +fi + +if [ -z "${E2E_THIRD_USERNAME:-}" ] || [ -z "${E2E_THIRD_PASSWORD:-}" ]; then + eval "$("${ROOT_DIR}/scripts/create-mindroom-e2e-account.sh" E2E_THIRD)" + echo "Provisioned disposable third e2e account via ${MINDROOM_SSH_HOST:-mindroom}." >&2 +fi + +if [ -z "${E2E_DEACTIVATE_USERNAME:-}" ] || [ -z "${E2E_DEACTIVATE_PASSWORD:-}" ]; then + eval "$("${ROOT_DIR}/scripts/create-mindroom-e2e-account.sh" E2E_DEACTIVATE)" + echo "Provisioned disposable deactivation e2e account via ${MINDROOM_SSH_HOST:-mindroom}." >&2 +fi + +export E2E_FIXTURE_ROOM_ALIAS="${FIXTURE_ALIAS}" + +"${ROOT_DIR}/scripts/with-mindroom-tunnel.sh" bash -lc ' + set -euo pipefail + cd "'"${ROOT_DIR}"'" + node ./e2e/live/seed-fixture-room.mjs + + export E2E_FIXTURE_ROOM_ID="$( + node --input-type=module <<'\''NODE'\'' +const homeserver = process.env.E2E_HOMESERVER; +const alias = process.env.E2E_FIXTURE_ROOM_ALIAS; +const url = `${homeserver}/_matrix/client/v3/directory/room/${encodeURIComponent(alias)}`; +const response = await fetch(url); +const body = await response.json(); +if (!response.ok || typeof body.room_id !== "string") { + console.error(`Failed to resolve fixture room alias ${alias}: ${JSON.stringify(body)}`); + process.exit(1); +} +process.stdout.write(body.room_id); +NODE + )" + + exec npx playwright test "$@" +' bash "$@" diff --git a/scripts/with-mindroom-tunnel.sh b/scripts/with-mindroom-tunnel.sh new file mode 100755 index 0000000000..9ca0f80fb8 --- /dev/null +++ b/scripts/with-mindroom-tunnel.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +SSH_HOST="${MINDROOM_SSH_HOST:-mindroom}" +LOCAL_PORT="${MINDROOM_TUNNEL_PORT:-8808}" +REMOTE_BIND="${MINDROOM_REMOTE_BIND:-localhost}" +REMOTE_PORT="${MINDROOM_REMOTE_PORT:-8008}" + +if [ "$#" -eq 0 ]; then + echo "usage: $0 [args...]" >&2 + exit 64 +fi + +cleanup() { + if [ -n "${SSH_PID:-}" ]; then + kill "${SSH_PID}" 2>/dev/null || true + wait "${SSH_PID}" 2>/dev/null || true + fi +} + +trap cleanup EXIT INT TERM + +ssh \ + -o BatchMode=yes \ + -o ExitOnForwardFailure=yes \ + -L "${LOCAL_PORT}:${REMOTE_BIND}:${REMOTE_PORT}" \ + "${SSH_HOST}" \ + -N & +SSH_PID=$! + +export E2E_HOMESERVER="${E2E_HOMESERVER:-http://127.0.0.1:${LOCAL_PORT}}" + +cd "${ROOT_DIR}" +"$@" diff --git a/src/ext.d.ts b/src/ext.d.ts index 72acc587d7..ebf4c0b13f 100644 --- a/src/ext.d.ts +++ b/src/ext.d.ts @@ -33,3 +33,11 @@ declare module '*.svg' { const content: string; export default content; } + +declare module 'scheduler/tracing' { + export interface Interaction { + id: number; + name: string; + timestamp: number; + } +} diff --git a/src/sw-session.ts b/src/sw-session.ts index 4b2ec055d2..ac89b86d65 100644 --- a/src/sw-session.ts +++ b/src/sw-session.ts @@ -1,3 +1,48 @@ +/** + * Wait for a service worker to take control of the page. + * Resolves true immediately if already controlled. + * Resolves true on 'controllerchange' event. + * Resolves false after timeout (default 3000ms). + */ +export function waitForServiceWorkerControl(timeoutMs = 3000): Promise { + if (!('serviceWorker' in navigator)) return Promise.resolve(false); + const { serviceWorker } = navigator; + + if (serviceWorker.controller) return Promise.resolve(true); + + return new Promise((resolve) => { + let settled = false; + let timeoutId: ReturnType | undefined; + let finish: (controlled: boolean) => void = () => undefined; + + const handleControllerChange = () => { + finish(true); + }; + + finish = (controlled: boolean) => { + if (settled) return; + settled = true; + + if (timeoutId !== undefined) { + globalThis.clearTimeout(timeoutId); + } + + serviceWorker.removeEventListener('controllerchange', handleControllerChange); + resolve(controlled); + }; + + timeoutId = globalThis.setTimeout(() => { + finish(false); + }, timeoutMs); + + serviceWorker.addEventListener('controllerchange', handleControllerChange); + + if (serviceWorker.controller) { + finish(true); + } + }); +} + export function pushSessionToSW(baseUrl?: string, accessToken?: string) { if (!('serviceWorker' in navigator)) return; if (!navigator.serviceWorker.controller) return; diff --git a/src/sw.ts b/src/sw.ts index 69293b1d17..fde55ff86d 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -1,7 +1,35 @@ /// +import { + cleanupOutdatedCaches, + createHandlerBoundToURL, + precacheAndRoute, + type PrecacheEntry, +} from 'workbox-precaching'; +import { NavigationRoute, registerRoute } from 'workbox-routing'; +import { looksLikeMediaRequest, validMediaRequest } from './swMediaAuth'; +import { buildAuthenticatedMediaRequestInit } from './swMediaFetch'; + export type {}; -declare const self: ServiceWorkerGlobalScope; +declare const self: ServiceWorkerGlobalScope & { + __WB_MANIFEST: Array; +}; + +precacheAndRoute(self.__WB_MANIFEST); +cleanupOutdatedCaches(); + +const navigationFallbackDenylist = [ + /^\/api(?:\/|$)/, + /^\/(?:[^/]+\/)?_matrix(?:\/|$)/, + /^\/(?:[^/]+\/)?_synapse(?:\/|$)/, + /^\/(?:[^/]+\/)?\.well-known(?:\/|$)/, +]; + +registerRoute( + new NavigationRoute(createHandlerBoundToURL('index.html'), { + denylist: navigationFallbackDenylist, + }) +); type SessionInfo = { accessToken: string; @@ -104,55 +132,69 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => { } }); -const MEDIA_PATHS = ['/_matrix/client/v1/media/download', '/_matrix/client/v1/media/thumbnail']; +async function askForAccessToken(client: Client): Promise { + return new Promise((resolve) => { + const responseKey = Math.random().toString(36); + let listener: (messageEvent: ExtendableMessageEvent) => void = () => undefined; + const timeoutId = setTimeout(() => { + self.removeEventListener('message', listener); + resolve(undefined); + }, 1500); + + listener = (messageEvent: ExtendableMessageEvent) => { + if (messageEvent.data?.responseKey !== responseKey) return; + clearTimeout(timeoutId); + self.removeEventListener('message', listener); + resolve(typeof messageEvent.data?.token === 'string' ? messageEvent.data.token : undefined); + }; + + self.addEventListener('message', listener); + client.postMessage({ responseKey, type: 'token' }); + }); +} + +const fetchAuthenticatedMedia = async (request: Request, token: string): Promise => { + const init = buildAuthenticatedMediaRequestInit(request, token); + return request.mode === 'no-cors' ? fetch(request.url, init) : fetch(request, init); +}; -function mediaPath(url: string): boolean { +const fetchAuthenticatedMediaWithFallback = async ( + request: Request, + token: string +): Promise => { try { - const { pathname } = new URL(url); - return MEDIA_PATHS.some((p) => pathname.startsWith(p)); + return await fetchAuthenticatedMedia(request, token); } catch { - return false; + // If authenticated fetch fails unexpectedly, fall back to the original request. + return fetch(request); } -} - -function validMediaRequest(url: string, baseUrl: string): boolean { - return MEDIA_PATHS.some((p) => { - const validUrl = new URL(p, baseUrl); - return url.startsWith(validUrl.href); - }); -} - -function fetchConfig(token: string): RequestInit { - return { - headers: { - Authorization: `Bearer ${token}`, - }, - cache: 'default', - }; -} +}; self.addEventListener('fetch', (event: FetchEvent) => { const { url, method } = event.request; - if (method !== 'GET' || !mediaPath(url)) return; - - const { clientId } = event; - if (!clientId) return; - - const session = sessions.get(clientId); - if (session) { - if (validMediaRequest(url, session.baseUrl)) { - event.respondWith(fetch(url, fetchConfig(session.accessToken))); - } - return; - } + if (method !== 'GET') return; + if (!looksLikeMediaRequest(url)) return; event.respondWith( - requestSessionWithTimeout(clientId).then((s) => { - if (s && validMediaRequest(url, s.baseUrl)) { - return fetch(url, fetchConfig(s.accessToken)); + (async (): Promise => { + if (event.clientId) { + const session = sessions.get(event.clientId); + if (session && validMediaRequest(url, session.baseUrl)) { + return fetchAuthenticatedMediaWithFallback(event.request, session.accessToken); + } + + // Fallback for clients still on the older token request/response flow. + const client = await self.clients.get(event.clientId); + if (client) { + const token = await askForAccessToken(client); + if (token) { + return fetchAuthenticatedMediaWithFallback(event.request, token); + } + } } + return fetch(event.request); - }) + })() ); }); diff --git a/tsconfig.json b/tsconfig.json index 7608e5578b..fad08c50cf 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,12 +7,12 @@ "allowJs": true, "strict": true, "esModuleInterop": true, - "moduleResolution": "Node", + "moduleResolution": "Bundler", "resolveJsonModule": true, "outDir": "dist", "skipLibCheck": true, - "lib": ["ES2016", "DOM"] + "lib": ["ES2016", "DOM", "DOM.Iterable"] }, - "exclude": ["node_modules", "dist"], + "exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/**/*.test.tsx"], "include": ["src"] } diff --git a/vite.config.js b/vite.config.js index a5af94d8b9..56ebc3c886 100644 --- a/vite.config.js +++ b/vite.config.js @@ -11,12 +11,16 @@ import fs from 'fs'; import path from 'path'; import buildConfig from './build.config'; -const copyFiles = { +export const copyFiles = { targets: [ { src: 'node_modules/@element-hq/element-call-embedded/dist/*', dest: 'public/element-call', }, + { + src: 'public/runtime-config.js', + dest: '', + }, { src: 'node_modules/pdfjs-dist/build/pdf.worker.min.mjs', dest: '', @@ -27,8 +31,9 @@ const copyFiles = { dest: '', }, { - src: 'config.json', + src: 'config.mindroom.json', dest: '', + rename: 'config.json', }, { src: 'public/manifest.json', @@ -50,7 +55,8 @@ function serverMatrixSdkCryptoWasm(wasmFilePath) { name: 'vite-plugin-serve-matrix-sdk-crypto-wasm', configureServer(server) { server.middlewares.use((req, res, next) => { - if (req.url === wasmFilePath) { + const requestPath = req.url?.split('?')[0]; + if (requestPath === wasmFilePath) { const resolvedPath = path.join( path.resolve(), '/node_modules/@matrix-org/matrix-sdk-crypto-wasm/pkg/matrix_sdk_crypto_wasm_bg.wasm' @@ -74,6 +80,94 @@ function serverMatrixSdkCryptoWasm(wasmFilePath) { }; } +function serverRuntimeConfig(runtimeConfigPath) { + return { + name: 'vite-plugin-serve-runtime-config', + configureServer(server) { + server.middlewares.use((req, res, next) => { + if (req.url === runtimeConfigPath) { + const resolvedPath = path.join(path.resolve(), 'public/runtime-config.js'); + + if (fs.existsSync(resolvedPath)) { + res.setHeader('Content-Type', 'application/javascript'); + res.setHeader('Cache-Control', 'no-cache'); + + const fileStream = fs.createReadStream(resolvedPath); + fileStream.pipe(res); + } else { + res.writeHead(404); + res.end('File not found'); + } + } else { + next(); + } + }); + }, + }; +} + +const staleServiceWorkerCleanupScript = ` +self.addEventListener('install', () => { + self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil((async () => { + if ('caches' in self) { + const cacheNames = await self.caches.keys(); + await Promise.all(cacheNames.map((cacheName) => self.caches.delete(cacheName))); + } + + await self.registration.unregister(); + + const clients = await self.clients.matchAll({ + type: 'window', + includeUncontrolled: true, + }); + await Promise.all(clients.map((client) => client.navigate(client.url))); + })()); +}); +`; + +function serverStaleServiceWorkerCleanup(serviceWorkerPath) { + return { + name: 'vite-plugin-serve-stale-service-worker-cleanup', + apply: 'serve', + configureServer(server) { + server.middlewares.use((req, res, next) => { + const requestPath = req.url?.split('?')[0]; + if (requestPath === serviceWorkerPath) { + res.setHeader('Content-Type', 'application/javascript'); + res.setHeader('Cache-Control', 'no-store'); + res.end(staleServiceWorkerCleanupScript); + } else { + next(); + } + }); + }, + }; +} + +const appBasePath = + buildConfig.base === '/' || buildConfig.base === './' || buildConfig.base === '.' + ? '' + : buildConfig.base.replace(/\/+$/g, ''); +const matrixCryptoWasmPath = + appBasePath && appBasePath !== '.' + ? `${appBasePath}/node_modules/.vite/deps/pkg/matrix_sdk_crypto_wasm_bg.wasm` + : '/node_modules/.vite/deps/pkg/matrix_sdk_crypto_wasm_bg.wasm'; +const defaultAllowedHosts = [ + 'chat.lab.mindroom.chat', + 'chat.mindroom.chat', + 'cinny-dev.lab.nijho.lt', + 'localhost', + '127.0.0.1', +]; +const allowedHosts = (process.env.VITE_ALLOWED_HOSTS ?? defaultAllowedHosts.join(',')) + .split(',') + .map((host) => host.trim()) + .filter(Boolean); + export default defineConfig({ appType: 'spa', publicDir: false, @@ -81,13 +175,16 @@ export default defineConfig({ server: { port: 8080, host: true, + allowedHosts, fs: { // Allow serving files from one level up to the project root allow: ['..'], }, }, plugins: [ - serverMatrixSdkCryptoWasm('/node_modules/.vite/deps/pkg/matrix_sdk_crypto_wasm_bg.wasm'), + serverRuntimeConfig('/runtime-config.js'), + serverStaleServiceWorkerCleanup(`${appBasePath}/sw.js`), + serverMatrixSdkCryptoWasm(matrixCryptoWasmPath), topLevelAwait({ // The export name of top-level await promise for each chunk module promiseExportName: '__tla', @@ -105,7 +202,9 @@ export default defineConfig({ injectRegister: false, manifest: false, injectManifest: { - injectionPoint: undefined, + injectionPoint: 'self.__WB_MANIFEST', + maximumFileSizeToCacheInBytes: 6 * 1024 * 1024, + globIgnores: ['public/element-call/**', 'runtime-config.js'], }, devOptions: { enabled: true, diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000000..3887c250b4 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + setupFiles: ['./src/vitest.setup.ts'], + include: [ + 'src/**/*.test.ts', + 'src/app/components/invite-user-prompt/InviteUserAutocomplete.test.tsx', + 'src/app/components/invite-user-prompt/InviteUserPrompt.test.tsx', + 'src/app/components/message/content/AudioContent.test.tsx', + 'src/app/components/message/content/ImageContent.test.tsx', + 'src/app/features/room-nav/SortableRoomNavItem.test.tsx', + 'src/app/mindroom/native/useEdgeSwipeBack.test.tsx', + ], + }, +}); From 220f53bc8a94b85b95e3849c03f5754a61f9827d Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Thu, 28 May 2026 21:08:15 -0700 Subject: [PATCH 002/303] feat: add MindRoom thread core and settings --- scripts/migrate-thread-tags.mjs | 579 ++ .../mindroom/bridges/bridgeDetection.test.ts | 33 + src/app/mindroom/bridges/bridgeDetection.ts | 25 + .../mindroom/cache/clientStorageAtoms.test.ts | 96 + src/app/mindroom/cache/clientStorageAtoms.ts | 64 + src/app/mindroom/cache/sessionCleanup.test.ts | 135 + src/app/mindroom/cache/sessionCleanup.ts | 64 + .../mindroom/cache/sessionStoreConfig.test.ts | 9 + src/app/mindroom/cache/sessionStoreConfig.ts | 2 + .../MindroomClientNonUIFeatures.test.ts | 312 ++ .../client/MindroomClientNonUIFeatures.tsx | 261 + .../crossRoomThreadFilterPipeline.test.ts | 134 + .../__tests__/crossRoomThreadFilters.test.ts | 112 + .../__tests__/crossRoomThreadIndex.test.ts | 421 ++ .../crossRoomThreads.architecture.test.ts | 73 + .../__tests__/useCrossRoomThreadIndex.test.ts | 1132 ++++ .../crossRoomThreadFilterPipeline.ts | 91 + .../crossRoomThreadFilters.ts | 224 + .../crossRoomThreadIndex.ts | 495 ++ .../useCrossRoomThreadIndex.ts | 492 ++ .../mindroom/local-mindroom/LocalMindroom.tsx | 471 ++ src/app/mindroom/local-mindroom/api.test.ts | 223 + src/app/mindroom/local-mindroom/api.ts | 266 + .../mindroom/local-mindroom/mindroom.test.ts | 123 + src/app/mindroom/local-mindroom/mindroom.ts | 154 + .../settingsIntegration.test.ts | 43 + .../local-mindroom/settingsMenu.test.ts | 12 + .../mindroom/local-mindroom/settingsMenu.ts | 19 + .../mindroom/local-mindroom/settingsPage.ts | 20 + .../local-mindroom/settingsRenderer.tsx | 14 + .../matrix/matrixClientFactory.test.ts | 45 + .../mindroom/matrix/matrixClientFactory.ts | 50 + .../MindroomMarkRoomReadChip.test.ts | 73 + .../MindroomMarkRoomReadChip.tsx | 30 + .../MindroomMarkRoomReadMenuItem.test.ts | 108 + .../MindroomMarkRoomReadMenuItem.tsx | 40 + .../MindroomMarkRoomsReadMenuItem.test.ts | 126 + .../MindroomMarkRoomsReadMenuItem.tsx | 44 + ...stemNotificationMindroomExtensions.test.ts | 24 + .../SystemNotificationMindroomExtensions.tsx | 12 + .../notifications/readReceipts.test.ts | 146 + .../mindroom/notifications/readReceipts.ts | 129 + .../recent-threads/RecentThreadEntry.test.ts | 134 + .../recent-threads/RecentThreadEntry.tsx | 90 + .../RecentThreadsDivider.test.ts | 212 + .../recent-threads/RecentThreadsDivider.tsx | 249 + .../recent-threads/RecentThreadsPanel.test.ts | 237 + .../recent-threads/RecentThreadsPanel.tsx | 168 + .../recentThreadSummaryUtils.ts | 74 + .../recent-threads/recentThreads.css.ts | 186 + .../recent-threads/recentThreads.test.ts | 285 + .../mindroom/recent-threads/recentThreads.ts | 254 + .../recentThreadsPanelHeight.ts | 108 + .../recentThreadsPanelMobileExpanded.ts | 107 + .../recent-threads/recentThreadsPanelUtils.ts | 21 + .../useDebouncedViewportHeight.ts | 36 + .../useResolvedRecentThreadsLayout.test.ts | 67 + .../useResolvedRecentThreadsLayout.ts | 127 + .../routing/__tests__/ClientLayout.test.ts | 398 ++ .../routing/clientRouteRestore.test.ts | 148 + .../mindroom/routing/clientRouteRestore.ts | 393 ++ ...MindroomMessagePreloadLimitSetting.test.ts | 121 + .../MindroomMessagePreloadLimitSetting.tsx | 81 + .../settings/mindroomSettings.test.ts | 58 + src/app/mindroom/settings/mindroomSettings.ts | 31 + .../mindroom/settings/settingsExtensions.tsx | 18 + .../settings/settingsMenuExtensions.ts | 25 + .../settings/voiceMessageSettings.test.ts | 188 + .../mindroom/settings/voiceMessageSettings.ts | 113 + src/app/mindroom/sidebar/MindroomTab.css.ts | 14 + src/app/mindroom/sidebar/MindroomTab.test.ts | 46 + src/app/mindroom/sidebar/MindroomTab.tsx | 50 + .../threads/CollapsibleMessage.css.ts | 79 + .../threads/CollapsibleMessage.test.ts | 748 +++ .../mindroom/threads/CollapsibleMessage.tsx | 261 + .../mindroom/threads/CompactRoomView.css.ts | 191 + .../mindroom/threads/CompactRoomView.test.ts | 186 + src/app/mindroom/threads/CompactRoomView.tsx | 53 + .../mindroom/threads/CompactThreadCard.tsx | 204 + .../mindroom/threads/MindroomCallChatView.tsx | 58 + src/app/mindroom/threads/MindroomRoom.tsx | 91 + .../mindroom/threads/MindroomRoomTimeline.tsx | 2944 ++++++++++ src/app/mindroom/threads/MindroomRoomView.tsx | 212 + .../threads/MindroomRoomViewHeader.tsx | 559 ++ .../threads/RoomThreadOverview.css.ts | 529 ++ .../threads/RoomThreadOverview.test.ts | 1024 ++++ .../mindroom/threads/RoomThreadOverview.tsx | 1030 ++++ .../threads/ThreadBadgeRenderer.test.ts | 78 + .../mindroom/threads/ThreadBadgeRenderer.tsx | 51 + .../threads/ThreadContextBanner.css.ts | 154 + .../threads/ThreadContextBanner.test.ts | 403 ++ .../mindroom/threads/ThreadContextBanner.tsx | 248 + .../mindroom/threads/ThreadIndicator.css.ts | 106 + .../mindroom/threads/ThreadIndicator.test.ts | 29 + src/app/mindroom/threads/ThreadIndicator.tsx | 207 + .../mindroom/threads/ThreadTagPicker.test.ts | 94 + src/app/mindroom/threads/ThreadTagPicker.tsx | 194 + src/app/mindroom/threads/ThreadTagPill.tsx | 66 + .../mindroom/threads/__tests__/Room.test.ts | 314 ++ .../__tests__/RoomTimeline.approval.test.ts | 204 + .../RoomTimeline.architecture.test.ts | 2388 +++++++++ .../__tests__/RoomTimeline.cache.test.ts | 4732 +++++++++++++++++ .../RoomTimeline.filter-query.test.ts | 165 + .../__tests__/RoomTimeline.navigation.test.ts | 1053 ++++ .../RoomTimeline.permalink-refresh.test.ts | 924 ++++ .../__tests__/RoomTimelineCollapsible.test.ts | 1412 +++++ .../threads/__tests__/RoomView.test.ts | 1123 ++++ .../__tests__/RoomView.threadSummary.test.ts | 283 + .../threads/cacheDbMigrationUtils.test.ts | 145 + .../mindroom/threads/cacheDbMigrationUtils.ts | 112 + .../threads/commandPaletteThreadItems.test.ts | 178 + .../threads/commandPaletteThreadItems.ts | 313 ++ .../commandPaletteThreadViewModel.test.ts | 99 + .../threads/commandPaletteThreadViewModel.ts | 49 + .../compactRootEditBackfillController.ts | 183 + .../threads/compactThreadCardUtils.test.ts | 39 + .../threads/compactThreadCardUtils.ts | 64 + .../compactThreadCardViewModel.test.ts | 248 + .../threads/compactThreadCardViewModel.ts | 212 + .../threads/compactThreadRootData.test.ts | 678 +++ .../mindroom/threads/compactThreadRootData.ts | 338 ++ .../threads/composeMessageRelation.test.ts | 77 + .../threads/composeMessageRelation.ts | 51 + .../threads/eventCacheEditUtils.test.ts | 351 ++ .../mindroom/threads/eventCacheEditUtils.ts | 331 ++ .../threads/eventCacheTokenUtils.test.ts | 71 + .../mindroom/threads/eventCacheTokenUtils.ts | 38 + .../mindroom/threads/eventRepository.test.ts | 534 ++ src/app/mindroom/threads/eventRepository.ts | 808 +++ .../mindroom/threads/lastExitedThread.test.ts | 37 + src/app/mindroom/threads/lastExitedThread.ts | 8 + .../mindroom/threads/lastOpenThread.test.ts | 97 + src/app/mindroom/threads/lastOpenThread.ts | 126 + .../threads/migrateThreadTagsScript.test.ts | 53 + src/app/mindroom/threads/preloadController.ts | 316 ++ .../mindroom/threads/preloadSettings.test.ts | 33 + src/app/mindroom/threads/preloadSettings.ts | 9 + .../threads/recentThreadViewModel.test.ts | 120 + .../mindroom/threads/recentThreadViewModel.ts | 180 + .../threads/roomCacheHydrationController.ts | 180 + .../threads/roomCacheLifecycleController.ts | 184 + src/app/mindroom/threads/roomDeepLink.ts | 128 + .../mindroom/threads/roomEventCache.test.ts | 80 + src/app/mindroom/threads/roomEventCache.ts | 386 ++ .../threads/roomEventOpenController.ts | 509 ++ .../threads/roomFocusScrollController.test.ts | 127 + .../threads/roomFocusScrollController.ts | 465 ++ .../threads/roomInputSendSession.test.ts | 399 ++ .../mindroom/threads/roomInputSendSession.ts | 266 + .../mindroom/threads/roomLiveEventArrive.ts | 48 + .../threads/roomLiveEventController.ts | 328 ++ .../threads/roomNavigateState.test.ts | 115 + src/app/mindroom/threads/roomNavigateState.ts | 101 + .../roomPaginationCommandController.test.ts | 296 ++ .../roomPaginationCommandController.ts | 209 + .../threads/roomPreloadTarget.test.ts | 34 + src/app/mindroom/threads/roomPreloadTarget.ts | 11 + .../threads/roomThreadFilterState.test.ts | 313 ++ .../mindroom/threads/roomThreadFilterState.ts | 64 + .../mindroom/threads/roomThreadList.test.ts | 266 + src/app/mindroom/threads/roomThreadList.ts | 132 + .../threads/roomThreadOverviewModel.test.ts | 277 + .../threads/roomThreadOverviewModel.ts | 385 ++ .../threads/roomTimelineEvents.test.ts | 135 + .../mindroom/threads/roomTimelineEvents.ts | 376 ++ .../roomTimelineMessageExtensions.test.ts | 136 + .../threads/roomTimelineMessageExtensions.tsx | 96 + .../roomTimelineNavigationController.ts | 179 + .../threads/roomTimelineReplyDraft.test.ts | 110 + .../threads/roomTimelineReplyDraft.ts | 39 + .../threads/roomTimelineViewState.test.ts | 90 + .../mindroom/threads/roomTimelineViewState.ts | 46 + .../threads/roomTimelineWindowController.ts | 232 + src/app/mindroom/threads/roomViewMode.test.ts | 111 + src/app/mindroom/threads/roomViewMode.ts | 45 + .../threads/scheduledTaskContract.test.ts | 127 + .../mindroom/threads/scheduledTaskContract.ts | 119 + .../test-utils/RoomTimeline.test.shared.ts | 1693 ++++++ .../threadBackPaginationController.test.ts | 162 + .../threads/threadBackPaginationController.ts | 130 + .../threads/threadBadgeViewModel.test.ts | 133 + .../mindroom/threads/threadBadgeViewModel.ts | 40 + .../mindroom/threads/threadBootstrap.test.ts | 298 ++ src/app/mindroom/threads/threadBootstrap.ts | 320 ++ .../threads/threadCacheCoverage.test.ts | 100 + .../mindroom/threads/threadCacheCoverage.ts | 79 + .../threadCachePersistenceController.ts | 177 + .../threads/threadCacheSnapshot.test.ts | 91 + .../mindroom/threads/threadCacheSnapshot.ts | 104 + .../threads/threadCollapsibleMessages.ts | 169 + .../threads/threadEditBackfill.test.ts | 115 + .../mindroom/threads/threadEditBackfill.ts | 63 + .../threads/threadEditBackfillController.ts | 237 + .../mindroom/threads/threadEventCache.test.ts | 250 + src/app/mindroom/threads/threadEventCache.ts | 639 +++ .../mindroom/threads/threadFilterDsl.test.ts | 91 + src/app/mindroom/threads/threadFilterDsl.ts | 128 + .../threads/threadHeaderViewModel.test.ts | 127 + .../mindroom/threads/threadHeaderViewModel.ts | 77 + .../threads/threadIndexRecords.test.ts | 185 + .../mindroom/threads/threadIndexRecords.ts | 113 + .../threads/threadIndicatorViewModel.ts | 11 + .../mindroom/threads/threadMessagePreview.ts | 50 + .../mindroom/threads/threadNavigation.test.ts | 141 + src/app/mindroom/threads/threadNavigation.ts | 73 + .../threads/threadOpenCacheController.ts | 566 ++ .../threads/threadOpenCacheFirst.test.ts | 152 + .../mindroom/threads/threadOpenCacheFirst.ts | 164 + .../threads/threadOpenLifecycleController.ts | 322 ++ .../threadOpenPostBootstrapRefresh.test.ts | 90 + .../threads/threadOpenPostBootstrapRefresh.ts | 114 + .../threads/threadOpenSdkBootstrap.test.ts | 48 + .../threads/threadOpenSdkBootstrap.ts | 264 + .../mindroom/threads/threadOpenSeedCache.ts | 28 + .../threads/threadOpenSeedController.test.ts | 78 + .../threads/threadOpenSeedController.ts | 223 + .../threads/threadOpenTargetEvent.test.ts | 127 + .../mindroom/threads/threadOpenTargetEvent.ts | 63 + .../threadOverviewCacheHydration.test.ts | 260 + .../threads/threadOverviewCacheHydration.ts | 490 ++ .../threadOverviewCacheMetadata.test.ts | 86 + .../threads/threadOverviewCacheMetadata.ts | 178 + .../threadOverviewRefreshCounter.test.ts | 89 + .../threads/threadOverviewRefreshCounter.ts | 50 + .../threadOverviewRefreshTargets.test.ts | 96 + .../threads/threadOverviewRefreshTargets.ts | 67 + .../threads/threadOverviewResumeController.ts | 282 + .../threadPaginationCommandController.ts | 222 + .../threads/threadPaginationUtils.test.ts | 230 + .../mindroom/threads/threadPaginationUtils.ts | 85 + .../threads/threadPresentation.test.ts | 184 + .../mindroom/threads/threadPresentation.ts | 134 + src/app/mindroom/threads/threadRecord.test.ts | 331 ++ src/app/mindroom/threads/threadRecord.ts | 389 ++ .../threads/threadRecordOverview.test.ts | 273 + .../mindroom/threads/threadRecordOverview.ts | 197 + .../threads/threadRenderUtils.test.ts | 381 ++ src/app/mindroom/threads/threadRenderUtils.ts | 299 ++ .../threads/threadResolvableRoot.test.ts | 73 + .../mindroom/threads/threadResolvableRoot.ts | 25 + src/app/mindroom/threads/threadRoomFocus.ts | 278 + .../mindroom/threads/threadRouteUtils.test.ts | 126 + src/app/mindroom/threads/threadRouteUtils.ts | 126 + .../threads/threadScheduledStatus.test.ts | 94 + .../mindroom/threads/threadScheduledStatus.ts | 75 + .../threads/threadSeedPrewarmController.ts | 226 + .../threadSortFreezeController.test.ts | 45 + .../threads/threadSortFreezeController.ts | 55 + .../mindroom/threads/threadSummaryCache.ts | 139 + .../threadSummaryPublishController.test.ts | 52 + .../threads/threadSummaryPublishController.ts | 60 + .../threads/threadSummarySelection.test.ts | 96 + .../threads/threadSummarySelection.ts | 38 + .../mindroom/threads/threadSummaryState.ts | 157 + .../mindroom/threads/threadSummaryStore.ts | 16 + src/app/mindroom/threads/threadTagColor.ts | 31 + src/app/mindroom/threads/threadTagPending.ts | 109 + .../threads/threadTagSnapshots.test.ts | 44 + .../mindroom/threads/threadTagSnapshots.ts | 43 + src/app/mindroom/threads/threadTags.test.ts | 514 ++ src/app/mindroom/threads/threadTags.ts | 376 ++ src/app/mindroom/threads/threadUtils.test.ts | 338 ++ src/app/mindroom/threads/threadUtils.ts | 296 ++ src/app/mindroom/threads/timelineDebug.ts | 53 + .../threads/timelineDebugController.test.ts | 154 + .../threads/timelineDebugController.ts | 165 + .../threads/timelinePagination.test.ts | 63 + .../mindroom/threads/timelinePagination.ts | 263 + .../threads/timelinePaginationController.ts | 145 + .../threads/timelineReadReceiptController.ts | 221 + .../threads/timelineScrollUtils.test.ts | 324 ++ .../mindroom/threads/timelineScrollUtils.ts | 424 ++ src/app/mindroom/threads/types.ts | 132 + .../threads/useMindroomThreadIndex.test.ts | 121 + .../threads/useMindroomThreadIndex.ts | 632 +++ .../threads/useMutateThreadTags.test.ts | 409 ++ .../mindroom/threads/useMutateThreadTags.ts | 157 + src/app/mindroom/threads/usePageResume.ts | 50 + .../threads/useRecentThreadViewModel.test.ts | 241 + .../threads/useRoomEscapeReadReceipts.test.ts | 109 + .../threads/useRoomEscapeReadReceipts.ts | 34 + src/app/mindroom/threads/useRoomEvent.test.ts | 233 + src/app/mindroom/threads/useRoomEvent.ts | 108 + .../useRoomInputSendSessionController.test.ts | 297 ++ .../useRoomInputSendSessionController.ts | 381 ++ src/app/mindroom/threads/useRoomThreadList.ts | 117 + .../threads/useRoomThreadRouteRestore.ts | 83 + .../threads/useRoomThreadSummaryState.ts | 27 + .../threads/useRoomThreadTags.test.ts | 247 + src/app/mindroom/threads/useRoomThreadTags.ts | 152 + .../threads/useRoomViewThreadState.ts | 301 ++ src/app/mindroom/threads/useStateEvents.ts | 15 + .../threads/useThreadAwareTimelineRefresh.ts | 69 + .../mindroom/threads/useThreadEventRefresh.ts | 49 + .../threads/useThreadHeaderInfo.test.ts | 337 ++ .../mindroom/threads/useThreadHeaderInfo.ts | 38 + .../threads/useThreadLastActivityTs.test.ts | 504 ++ .../threads/useThreadLastActivityTs.ts | 155 + .../threads/useThreadRenderState.test.ts | 642 +++ .../mindroom/threads/useThreadRenderState.ts | 263 + .../threads/useThreadRootEvent.test.ts | 238 + .../mindroom/threads/useThreadRootEvent.ts | 86 + .../threads/useThreadScheduledStatus.ts | 41 + .../threads/useThreadScheduledTasks.test.ts | 196 + .../threads/useThreadScheduledTasks.ts | 5 + .../threads/useThreadStreamingState.test.ts | 574 ++ .../threads/useThreadStreamingState.ts | 184 + .../mindroom/threads/useThreadTags.test.ts | 248 + src/app/mindroom/threads/useThreadTags.ts | 112 + .../threads/useThreadTimelineState.ts | 61 + 310 files changed, 71654 insertions(+) create mode 100644 scripts/migrate-thread-tags.mjs create mode 100644 src/app/mindroom/bridges/bridgeDetection.test.ts create mode 100644 src/app/mindroom/bridges/bridgeDetection.ts create mode 100644 src/app/mindroom/cache/clientStorageAtoms.test.ts create mode 100644 src/app/mindroom/cache/clientStorageAtoms.ts create mode 100644 src/app/mindroom/cache/sessionCleanup.test.ts create mode 100644 src/app/mindroom/cache/sessionCleanup.ts create mode 100644 src/app/mindroom/cache/sessionStoreConfig.test.ts create mode 100644 src/app/mindroom/cache/sessionStoreConfig.ts create mode 100644 src/app/mindroom/client/MindroomClientNonUIFeatures.test.ts create mode 100644 src/app/mindroom/client/MindroomClientNonUIFeatures.tsx create mode 100644 src/app/mindroom/cross-room-threads/__tests__/crossRoomThreadFilterPipeline.test.ts create mode 100644 src/app/mindroom/cross-room-threads/__tests__/crossRoomThreadFilters.test.ts create mode 100644 src/app/mindroom/cross-room-threads/__tests__/crossRoomThreadIndex.test.ts create mode 100644 src/app/mindroom/cross-room-threads/__tests__/crossRoomThreads.architecture.test.ts create mode 100644 src/app/mindroom/cross-room-threads/__tests__/useCrossRoomThreadIndex.test.ts create mode 100644 src/app/mindroom/cross-room-threads/crossRoomThreadFilterPipeline.ts create mode 100644 src/app/mindroom/cross-room-threads/crossRoomThreadFilters.ts create mode 100644 src/app/mindroom/cross-room-threads/crossRoomThreadIndex.ts create mode 100644 src/app/mindroom/cross-room-threads/useCrossRoomThreadIndex.ts create mode 100644 src/app/mindroom/local-mindroom/LocalMindroom.tsx create mode 100644 src/app/mindroom/local-mindroom/api.test.ts create mode 100644 src/app/mindroom/local-mindroom/api.ts create mode 100644 src/app/mindroom/local-mindroom/mindroom.test.ts create mode 100644 src/app/mindroom/local-mindroom/mindroom.ts create mode 100644 src/app/mindroom/local-mindroom/settingsIntegration.test.ts create mode 100644 src/app/mindroom/local-mindroom/settingsMenu.test.ts create mode 100644 src/app/mindroom/local-mindroom/settingsMenu.ts create mode 100644 src/app/mindroom/local-mindroom/settingsPage.ts create mode 100644 src/app/mindroom/local-mindroom/settingsRenderer.tsx create mode 100644 src/app/mindroom/matrix/matrixClientFactory.test.ts create mode 100644 src/app/mindroom/matrix/matrixClientFactory.ts create mode 100644 src/app/mindroom/notifications/MindroomMarkRoomReadChip.test.ts create mode 100644 src/app/mindroom/notifications/MindroomMarkRoomReadChip.tsx create mode 100644 src/app/mindroom/notifications/MindroomMarkRoomReadMenuItem.test.ts create mode 100644 src/app/mindroom/notifications/MindroomMarkRoomReadMenuItem.tsx create mode 100644 src/app/mindroom/notifications/MindroomMarkRoomsReadMenuItem.test.ts create mode 100644 src/app/mindroom/notifications/MindroomMarkRoomsReadMenuItem.tsx create mode 100644 src/app/mindroom/notifications/SystemNotificationMindroomExtensions.test.ts create mode 100644 src/app/mindroom/notifications/SystemNotificationMindroomExtensions.tsx create mode 100644 src/app/mindroom/notifications/readReceipts.test.ts create mode 100644 src/app/mindroom/notifications/readReceipts.ts create mode 100644 src/app/mindroom/recent-threads/RecentThreadEntry.test.ts create mode 100644 src/app/mindroom/recent-threads/RecentThreadEntry.tsx create mode 100644 src/app/mindroom/recent-threads/RecentThreadsDivider.test.ts create mode 100644 src/app/mindroom/recent-threads/RecentThreadsDivider.tsx create mode 100644 src/app/mindroom/recent-threads/RecentThreadsPanel.test.ts create mode 100644 src/app/mindroom/recent-threads/RecentThreadsPanel.tsx create mode 100644 src/app/mindroom/recent-threads/recentThreadSummaryUtils.ts create mode 100644 src/app/mindroom/recent-threads/recentThreads.css.ts create mode 100644 src/app/mindroom/recent-threads/recentThreads.test.ts create mode 100644 src/app/mindroom/recent-threads/recentThreads.ts create mode 100644 src/app/mindroom/recent-threads/recentThreadsPanelHeight.ts create mode 100644 src/app/mindroom/recent-threads/recentThreadsPanelMobileExpanded.ts create mode 100644 src/app/mindroom/recent-threads/recentThreadsPanelUtils.ts create mode 100644 src/app/mindroom/recent-threads/useDebouncedViewportHeight.ts create mode 100644 src/app/mindroom/recent-threads/useResolvedRecentThreadsLayout.test.ts create mode 100644 src/app/mindroom/recent-threads/useResolvedRecentThreadsLayout.ts create mode 100644 src/app/mindroom/routing/__tests__/ClientLayout.test.ts create mode 100644 src/app/mindroom/routing/clientRouteRestore.test.ts create mode 100644 src/app/mindroom/routing/clientRouteRestore.ts create mode 100644 src/app/mindroom/settings/MindroomMessagePreloadLimitSetting.test.ts create mode 100644 src/app/mindroom/settings/MindroomMessagePreloadLimitSetting.tsx create mode 100644 src/app/mindroom/settings/mindroomSettings.test.ts create mode 100644 src/app/mindroom/settings/mindroomSettings.ts create mode 100644 src/app/mindroom/settings/settingsExtensions.tsx create mode 100644 src/app/mindroom/settings/settingsMenuExtensions.ts create mode 100644 src/app/mindroom/settings/voiceMessageSettings.test.ts create mode 100644 src/app/mindroom/settings/voiceMessageSettings.ts create mode 100644 src/app/mindroom/sidebar/MindroomTab.css.ts create mode 100644 src/app/mindroom/sidebar/MindroomTab.test.ts create mode 100644 src/app/mindroom/sidebar/MindroomTab.tsx create mode 100644 src/app/mindroom/threads/CollapsibleMessage.css.ts create mode 100644 src/app/mindroom/threads/CollapsibleMessage.test.ts create mode 100644 src/app/mindroom/threads/CollapsibleMessage.tsx create mode 100644 src/app/mindroom/threads/CompactRoomView.css.ts create mode 100644 src/app/mindroom/threads/CompactRoomView.test.ts create mode 100644 src/app/mindroom/threads/CompactRoomView.tsx create mode 100644 src/app/mindroom/threads/CompactThreadCard.tsx create mode 100644 src/app/mindroom/threads/MindroomCallChatView.tsx create mode 100644 src/app/mindroom/threads/MindroomRoom.tsx create mode 100644 src/app/mindroom/threads/MindroomRoomTimeline.tsx create mode 100644 src/app/mindroom/threads/MindroomRoomView.tsx create mode 100644 src/app/mindroom/threads/MindroomRoomViewHeader.tsx create mode 100644 src/app/mindroom/threads/RoomThreadOverview.css.ts create mode 100644 src/app/mindroom/threads/RoomThreadOverview.test.ts create mode 100644 src/app/mindroom/threads/RoomThreadOverview.tsx create mode 100644 src/app/mindroom/threads/ThreadBadgeRenderer.test.ts create mode 100644 src/app/mindroom/threads/ThreadBadgeRenderer.tsx create mode 100644 src/app/mindroom/threads/ThreadContextBanner.css.ts create mode 100644 src/app/mindroom/threads/ThreadContextBanner.test.ts create mode 100644 src/app/mindroom/threads/ThreadContextBanner.tsx create mode 100644 src/app/mindroom/threads/ThreadIndicator.css.ts create mode 100644 src/app/mindroom/threads/ThreadIndicator.test.ts create mode 100644 src/app/mindroom/threads/ThreadIndicator.tsx create mode 100644 src/app/mindroom/threads/ThreadTagPicker.test.ts create mode 100644 src/app/mindroom/threads/ThreadTagPicker.tsx create mode 100644 src/app/mindroom/threads/ThreadTagPill.tsx create mode 100644 src/app/mindroom/threads/__tests__/Room.test.ts create mode 100644 src/app/mindroom/threads/__tests__/RoomTimeline.approval.test.ts create mode 100644 src/app/mindroom/threads/__tests__/RoomTimeline.architecture.test.ts create mode 100644 src/app/mindroom/threads/__tests__/RoomTimeline.cache.test.ts create mode 100644 src/app/mindroom/threads/__tests__/RoomTimeline.filter-query.test.ts create mode 100644 src/app/mindroom/threads/__tests__/RoomTimeline.navigation.test.ts create mode 100644 src/app/mindroom/threads/__tests__/RoomTimeline.permalink-refresh.test.ts create mode 100644 src/app/mindroom/threads/__tests__/RoomTimelineCollapsible.test.ts create mode 100644 src/app/mindroom/threads/__tests__/RoomView.test.ts create mode 100644 src/app/mindroom/threads/__tests__/RoomView.threadSummary.test.ts create mode 100644 src/app/mindroom/threads/cacheDbMigrationUtils.test.ts create mode 100644 src/app/mindroom/threads/cacheDbMigrationUtils.ts create mode 100644 src/app/mindroom/threads/commandPaletteThreadItems.test.ts create mode 100644 src/app/mindroom/threads/commandPaletteThreadItems.ts create mode 100644 src/app/mindroom/threads/commandPaletteThreadViewModel.test.ts create mode 100644 src/app/mindroom/threads/commandPaletteThreadViewModel.ts create mode 100644 src/app/mindroom/threads/compactRootEditBackfillController.ts create mode 100644 src/app/mindroom/threads/compactThreadCardUtils.test.ts create mode 100644 src/app/mindroom/threads/compactThreadCardUtils.ts create mode 100644 src/app/mindroom/threads/compactThreadCardViewModel.test.ts create mode 100644 src/app/mindroom/threads/compactThreadCardViewModel.ts create mode 100644 src/app/mindroom/threads/compactThreadRootData.test.ts create mode 100644 src/app/mindroom/threads/compactThreadRootData.ts create mode 100644 src/app/mindroom/threads/composeMessageRelation.test.ts create mode 100644 src/app/mindroom/threads/composeMessageRelation.ts create mode 100644 src/app/mindroom/threads/eventCacheEditUtils.test.ts create mode 100644 src/app/mindroom/threads/eventCacheEditUtils.ts create mode 100644 src/app/mindroom/threads/eventCacheTokenUtils.test.ts create mode 100644 src/app/mindroom/threads/eventCacheTokenUtils.ts create mode 100644 src/app/mindroom/threads/eventRepository.test.ts create mode 100644 src/app/mindroom/threads/eventRepository.ts create mode 100644 src/app/mindroom/threads/lastExitedThread.test.ts create mode 100644 src/app/mindroom/threads/lastExitedThread.ts create mode 100644 src/app/mindroom/threads/lastOpenThread.test.ts create mode 100644 src/app/mindroom/threads/lastOpenThread.ts create mode 100644 src/app/mindroom/threads/migrateThreadTagsScript.test.ts create mode 100644 src/app/mindroom/threads/preloadController.ts create mode 100644 src/app/mindroom/threads/preloadSettings.test.ts create mode 100644 src/app/mindroom/threads/preloadSettings.ts create mode 100644 src/app/mindroom/threads/recentThreadViewModel.test.ts create mode 100644 src/app/mindroom/threads/recentThreadViewModel.ts create mode 100644 src/app/mindroom/threads/roomCacheHydrationController.ts create mode 100644 src/app/mindroom/threads/roomCacheLifecycleController.ts create mode 100644 src/app/mindroom/threads/roomDeepLink.ts create mode 100644 src/app/mindroom/threads/roomEventCache.test.ts create mode 100644 src/app/mindroom/threads/roomEventCache.ts create mode 100644 src/app/mindroom/threads/roomEventOpenController.ts create mode 100644 src/app/mindroom/threads/roomFocusScrollController.test.ts create mode 100644 src/app/mindroom/threads/roomFocusScrollController.ts create mode 100644 src/app/mindroom/threads/roomInputSendSession.test.ts create mode 100644 src/app/mindroom/threads/roomInputSendSession.ts create mode 100644 src/app/mindroom/threads/roomLiveEventArrive.ts create mode 100644 src/app/mindroom/threads/roomLiveEventController.ts create mode 100644 src/app/mindroom/threads/roomNavigateState.test.ts create mode 100644 src/app/mindroom/threads/roomNavigateState.ts create mode 100644 src/app/mindroom/threads/roomPaginationCommandController.test.ts create mode 100644 src/app/mindroom/threads/roomPaginationCommandController.ts create mode 100644 src/app/mindroom/threads/roomPreloadTarget.test.ts create mode 100644 src/app/mindroom/threads/roomPreloadTarget.ts create mode 100644 src/app/mindroom/threads/roomThreadFilterState.test.ts create mode 100644 src/app/mindroom/threads/roomThreadFilterState.ts create mode 100644 src/app/mindroom/threads/roomThreadList.test.ts create mode 100644 src/app/mindroom/threads/roomThreadList.ts create mode 100644 src/app/mindroom/threads/roomThreadOverviewModel.test.ts create mode 100644 src/app/mindroom/threads/roomThreadOverviewModel.ts create mode 100644 src/app/mindroom/threads/roomTimelineEvents.test.ts create mode 100644 src/app/mindroom/threads/roomTimelineEvents.ts create mode 100644 src/app/mindroom/threads/roomTimelineMessageExtensions.test.ts create mode 100644 src/app/mindroom/threads/roomTimelineMessageExtensions.tsx create mode 100644 src/app/mindroom/threads/roomTimelineNavigationController.ts create mode 100644 src/app/mindroom/threads/roomTimelineReplyDraft.test.ts create mode 100644 src/app/mindroom/threads/roomTimelineReplyDraft.ts create mode 100644 src/app/mindroom/threads/roomTimelineViewState.test.ts create mode 100644 src/app/mindroom/threads/roomTimelineViewState.ts create mode 100644 src/app/mindroom/threads/roomTimelineWindowController.ts create mode 100644 src/app/mindroom/threads/roomViewMode.test.ts create mode 100644 src/app/mindroom/threads/roomViewMode.ts create mode 100644 src/app/mindroom/threads/scheduledTaskContract.test.ts create mode 100644 src/app/mindroom/threads/scheduledTaskContract.ts create mode 100644 src/app/mindroom/threads/test-utils/RoomTimeline.test.shared.ts create mode 100644 src/app/mindroom/threads/threadBackPaginationController.test.ts create mode 100644 src/app/mindroom/threads/threadBackPaginationController.ts create mode 100644 src/app/mindroom/threads/threadBadgeViewModel.test.ts create mode 100644 src/app/mindroom/threads/threadBadgeViewModel.ts create mode 100644 src/app/mindroom/threads/threadBootstrap.test.ts create mode 100644 src/app/mindroom/threads/threadBootstrap.ts create mode 100644 src/app/mindroom/threads/threadCacheCoverage.test.ts create mode 100644 src/app/mindroom/threads/threadCacheCoverage.ts create mode 100644 src/app/mindroom/threads/threadCachePersistenceController.ts create mode 100644 src/app/mindroom/threads/threadCacheSnapshot.test.ts create mode 100644 src/app/mindroom/threads/threadCacheSnapshot.ts create mode 100644 src/app/mindroom/threads/threadCollapsibleMessages.ts create mode 100644 src/app/mindroom/threads/threadEditBackfill.test.ts create mode 100644 src/app/mindroom/threads/threadEditBackfill.ts create mode 100644 src/app/mindroom/threads/threadEditBackfillController.ts create mode 100644 src/app/mindroom/threads/threadEventCache.test.ts create mode 100644 src/app/mindroom/threads/threadEventCache.ts create mode 100644 src/app/mindroom/threads/threadFilterDsl.test.ts create mode 100644 src/app/mindroom/threads/threadFilterDsl.ts create mode 100644 src/app/mindroom/threads/threadHeaderViewModel.test.ts create mode 100644 src/app/mindroom/threads/threadHeaderViewModel.ts create mode 100644 src/app/mindroom/threads/threadIndexRecords.test.ts create mode 100644 src/app/mindroom/threads/threadIndexRecords.ts create mode 100644 src/app/mindroom/threads/threadIndicatorViewModel.ts create mode 100644 src/app/mindroom/threads/threadMessagePreview.ts create mode 100644 src/app/mindroom/threads/threadNavigation.test.ts create mode 100644 src/app/mindroom/threads/threadNavigation.ts create mode 100644 src/app/mindroom/threads/threadOpenCacheController.ts create mode 100644 src/app/mindroom/threads/threadOpenCacheFirst.test.ts create mode 100644 src/app/mindroom/threads/threadOpenCacheFirst.ts create mode 100644 src/app/mindroom/threads/threadOpenLifecycleController.ts create mode 100644 src/app/mindroom/threads/threadOpenPostBootstrapRefresh.test.ts create mode 100644 src/app/mindroom/threads/threadOpenPostBootstrapRefresh.ts create mode 100644 src/app/mindroom/threads/threadOpenSdkBootstrap.test.ts create mode 100644 src/app/mindroom/threads/threadOpenSdkBootstrap.ts create mode 100644 src/app/mindroom/threads/threadOpenSeedCache.ts create mode 100644 src/app/mindroom/threads/threadOpenSeedController.test.ts create mode 100644 src/app/mindroom/threads/threadOpenSeedController.ts create mode 100644 src/app/mindroom/threads/threadOpenTargetEvent.test.ts create mode 100644 src/app/mindroom/threads/threadOpenTargetEvent.ts create mode 100644 src/app/mindroom/threads/threadOverviewCacheHydration.test.ts create mode 100644 src/app/mindroom/threads/threadOverviewCacheHydration.ts create mode 100644 src/app/mindroom/threads/threadOverviewCacheMetadata.test.ts create mode 100644 src/app/mindroom/threads/threadOverviewCacheMetadata.ts create mode 100644 src/app/mindroom/threads/threadOverviewRefreshCounter.test.ts create mode 100644 src/app/mindroom/threads/threadOverviewRefreshCounter.ts create mode 100644 src/app/mindroom/threads/threadOverviewRefreshTargets.test.ts create mode 100644 src/app/mindroom/threads/threadOverviewRefreshTargets.ts create mode 100644 src/app/mindroom/threads/threadOverviewResumeController.ts create mode 100644 src/app/mindroom/threads/threadPaginationCommandController.ts create mode 100644 src/app/mindroom/threads/threadPaginationUtils.test.ts create mode 100644 src/app/mindroom/threads/threadPaginationUtils.ts create mode 100644 src/app/mindroom/threads/threadPresentation.test.ts create mode 100644 src/app/mindroom/threads/threadPresentation.ts create mode 100644 src/app/mindroom/threads/threadRecord.test.ts create mode 100644 src/app/mindroom/threads/threadRecord.ts create mode 100644 src/app/mindroom/threads/threadRecordOverview.test.ts create mode 100644 src/app/mindroom/threads/threadRecordOverview.ts create mode 100644 src/app/mindroom/threads/threadRenderUtils.test.ts create mode 100644 src/app/mindroom/threads/threadRenderUtils.ts create mode 100644 src/app/mindroom/threads/threadResolvableRoot.test.ts create mode 100644 src/app/mindroom/threads/threadResolvableRoot.ts create mode 100644 src/app/mindroom/threads/threadRoomFocus.ts create mode 100644 src/app/mindroom/threads/threadRouteUtils.test.ts create mode 100644 src/app/mindroom/threads/threadRouteUtils.ts create mode 100644 src/app/mindroom/threads/threadScheduledStatus.test.ts create mode 100644 src/app/mindroom/threads/threadScheduledStatus.ts create mode 100644 src/app/mindroom/threads/threadSeedPrewarmController.ts create mode 100644 src/app/mindroom/threads/threadSortFreezeController.test.ts create mode 100644 src/app/mindroom/threads/threadSortFreezeController.ts create mode 100644 src/app/mindroom/threads/threadSummaryCache.ts create mode 100644 src/app/mindroom/threads/threadSummaryPublishController.test.ts create mode 100644 src/app/mindroom/threads/threadSummaryPublishController.ts create mode 100644 src/app/mindroom/threads/threadSummarySelection.test.ts create mode 100644 src/app/mindroom/threads/threadSummarySelection.ts create mode 100644 src/app/mindroom/threads/threadSummaryState.ts create mode 100644 src/app/mindroom/threads/threadSummaryStore.ts create mode 100644 src/app/mindroom/threads/threadTagColor.ts create mode 100644 src/app/mindroom/threads/threadTagPending.ts create mode 100644 src/app/mindroom/threads/threadTagSnapshots.test.ts create mode 100644 src/app/mindroom/threads/threadTagSnapshots.ts create mode 100644 src/app/mindroom/threads/threadTags.test.ts create mode 100644 src/app/mindroom/threads/threadTags.ts create mode 100644 src/app/mindroom/threads/threadUtils.test.ts create mode 100644 src/app/mindroom/threads/threadUtils.ts create mode 100644 src/app/mindroom/threads/timelineDebug.ts create mode 100644 src/app/mindroom/threads/timelineDebugController.test.ts create mode 100644 src/app/mindroom/threads/timelineDebugController.ts create mode 100644 src/app/mindroom/threads/timelinePagination.test.ts create mode 100644 src/app/mindroom/threads/timelinePagination.ts create mode 100644 src/app/mindroom/threads/timelinePaginationController.ts create mode 100644 src/app/mindroom/threads/timelineReadReceiptController.ts create mode 100644 src/app/mindroom/threads/timelineScrollUtils.test.ts create mode 100644 src/app/mindroom/threads/timelineScrollUtils.ts create mode 100644 src/app/mindroom/threads/types.ts create mode 100644 src/app/mindroom/threads/useMindroomThreadIndex.test.ts create mode 100644 src/app/mindroom/threads/useMindroomThreadIndex.ts create mode 100644 src/app/mindroom/threads/useMutateThreadTags.test.ts create mode 100644 src/app/mindroom/threads/useMutateThreadTags.ts create mode 100644 src/app/mindroom/threads/usePageResume.ts create mode 100644 src/app/mindroom/threads/useRecentThreadViewModel.test.ts create mode 100644 src/app/mindroom/threads/useRoomEscapeReadReceipts.test.ts create mode 100644 src/app/mindroom/threads/useRoomEscapeReadReceipts.ts create mode 100644 src/app/mindroom/threads/useRoomEvent.test.ts create mode 100644 src/app/mindroom/threads/useRoomEvent.ts create mode 100644 src/app/mindroom/threads/useRoomInputSendSessionController.test.ts create mode 100644 src/app/mindroom/threads/useRoomInputSendSessionController.ts create mode 100644 src/app/mindroom/threads/useRoomThreadList.ts create mode 100644 src/app/mindroom/threads/useRoomThreadRouteRestore.ts create mode 100644 src/app/mindroom/threads/useRoomThreadSummaryState.ts create mode 100644 src/app/mindroom/threads/useRoomThreadTags.test.ts create mode 100644 src/app/mindroom/threads/useRoomThreadTags.ts create mode 100644 src/app/mindroom/threads/useRoomViewThreadState.ts create mode 100644 src/app/mindroom/threads/useStateEvents.ts create mode 100644 src/app/mindroom/threads/useThreadAwareTimelineRefresh.ts create mode 100644 src/app/mindroom/threads/useThreadEventRefresh.ts create mode 100644 src/app/mindroom/threads/useThreadHeaderInfo.test.ts create mode 100644 src/app/mindroom/threads/useThreadHeaderInfo.ts create mode 100644 src/app/mindroom/threads/useThreadLastActivityTs.test.ts create mode 100644 src/app/mindroom/threads/useThreadLastActivityTs.ts create mode 100644 src/app/mindroom/threads/useThreadRenderState.test.ts create mode 100644 src/app/mindroom/threads/useThreadRenderState.ts create mode 100644 src/app/mindroom/threads/useThreadRootEvent.test.ts create mode 100644 src/app/mindroom/threads/useThreadRootEvent.ts create mode 100644 src/app/mindroom/threads/useThreadScheduledStatus.ts create mode 100644 src/app/mindroom/threads/useThreadScheduledTasks.test.ts create mode 100644 src/app/mindroom/threads/useThreadScheduledTasks.ts create mode 100644 src/app/mindroom/threads/useThreadStreamingState.test.ts create mode 100644 src/app/mindroom/threads/useThreadStreamingState.ts create mode 100644 src/app/mindroom/threads/useThreadTags.test.ts create mode 100644 src/app/mindroom/threads/useThreadTags.ts create mode 100644 src/app/mindroom/threads/useThreadTimelineState.ts diff --git a/scripts/migrate-thread-tags.mjs b/scripts/migrate-thread-tags.mjs new file mode 100644 index 0000000000..ba296120df --- /dev/null +++ b/scripts/migrate-thread-tags.mjs @@ -0,0 +1,579 @@ +#!/usr/bin/env node +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const THREAD_TAGS_EVENT_TYPE = 'com.mindroom.thread.tags'; +const TAG_NAME_RE = /^[a-z0-9-]{1,50}$/; +const ISO_8601_PATTERN = + /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?)?$/; + +const parseArgs = (argv) => { + const args = { + write: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--write') { + args.write = true; + continue; + } + + if (!arg.startsWith('--')) { + throw new Error(`Unexpected argument: ${arg}`); + } + + const value = argv[index + 1]; + if (!value || value.startsWith('--')) { + throw new Error(`Missing value for ${arg}`); + } + + if (arg === '--homeserver') args.homeserver = value; + else if (arg === '--room-id') args.roomId = value; + else if (arg === '--access-token') args.accessToken = value; + else throw new Error(`Unknown argument: ${arg}`); + + index += 1; + } + + if (!args.homeserver || !args.roomId || !args.accessToken) { + throw new Error( + 'Usage: node scripts/migrate-thread-tags.mjs --homeserver --room-id --access-token [--write]' + ); + } + + return args; +}; + +const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value); + +const normalizeNonEmptyString = (value) => { + if (typeof value !== 'string') return null; + + const normalized = value.trim(); + return normalized.length > 0 ? normalized : null; +}; + +const normalizeTagName = (value) => { + const normalized = normalizeNonEmptyString(value)?.toLowerCase(); + if (!normalized || !TAG_NAME_RE.test(normalized)) { + return null; + } + return normalized; +}; + +const requireTagName = (value) => { + const normalized = normalizeTagName(value); + if (!normalized) { + throw new Error(`Invalid thread tag name: ${value}`); + } + return normalized; +}; + +const normalizeSetAt = (value) => { + if (typeof value === 'number' && Number.isFinite(value)) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return null; + + const normalized = date.toISOString(); + return ISO_8601_PATTERN.test(normalized) ? normalized : null; + } + + if ( + typeof value !== 'string' || + !ISO_8601_PATTERN.test(value) || + Number.isNaN(Date.parse(value)) + ) { + return null; + } + + return value; +}; + +const normalizeNote = (value) => { + if (value == null) return undefined; + if (typeof value !== 'string') return null; + + const normalized = value.trim(); + return normalized.length > 0 ? normalized : undefined; +}; + +const normalizeData = (value) => { + if (value == null) return undefined; + if (!isRecord(value)) return null; + + return { ...value }; +}; + +const cloneTags = (tags) => Object.fromEntries(Object.entries(tags).map(([tag, record]) => [tag, { ...record }])); + +const cloneTagMap = (source) => + new Map(Array.from(source.entries(), ([threadRootId, tags]) => [threadRootId, cloneTags(tags)])); + +const cloneTombstoneMap = (source) => + new Map(Array.from(source.entries(), ([threadRootId, tags]) => [threadRootId, new Set(tags)])); + +const sortObject = (value) => + Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right))); + +const recordToWireContent = (record) => ({ + set_by: record.set_by, + set_at: record.set_at, + ...(record.note !== undefined ? { note: record.note } : {}), + ...(record.data !== undefined ? { data: record.data } : {}), +}); + +const parseTagRecord = (tagName, value, warnings, sourceLabel) => { + const normalizedTagName = normalizeTagName(tagName); + if (!normalizedTagName) { + warnings.push(`Skipping invalid tag name ${JSON.stringify(tagName)} in ${sourceLabel}.`); + return null; + } + + if (!isRecord(value)) { + warnings.push(`Skipping malformed ${normalizedTagName} payload in ${sourceLabel}.`); + return null; + } + + const setBy = normalizeNonEmptyString(value.set_by); + const setAt = normalizeSetAt(value.set_at); + const note = normalizeNote(value.note); + const data = normalizeData(value.data); + if (!setBy || !setAt || note === null || data === null) { + warnings.push(`Skipping invalid ${normalizedTagName} payload in ${sourceLabel}.`); + return null; + } + + return { + tagName: normalizedTagName, + record: { + set_by: setBy, + set_at: setAt, + ...(note !== undefined ? { note } : {}), + ...(data !== undefined ? { data } : {}), + }, + }; +}; + +const parseLegacyThreadTagsContent = (threadRootId, content, warnings) => { + if (!isRecord(content) || !isRecord(content.tags)) { + return null; + } + + const parsedTags = {}; + Object.entries(content.tags).forEach(([rawTagName, rawValue]) => { + const parsedTag = parseTagRecord( + rawTagName, + rawValue, + warnings, + `legacy state ${JSON.stringify(threadRootId)}` + ); + if (!parsedTag) return; + + parsedTags[parsedTag.tagName] = parsedTag.record; + }); + + if (Object.keys(parsedTags).length === 0) { + return null; + } + + return { + threadRootId, + tags: parsedTags, + }; +}; + +const parsePerTagStateKey = (stateKey) => { + if (typeof stateKey !== 'string') return null; + + try { + const parsed = JSON.parse(stateKey); + if (!Array.isArray(parsed) || parsed.length !== 2) { + return null; + } + + const threadRootId = normalizeNonEmptyString(parsed[0]); + const tagName = normalizeTagName(parsed[1]); + if (!threadRootId || !threadRootId.startsWith('$') || !tagName) { + return null; + } + + return { threadRootId, tagName }; + } catch { + return null; + } +}; + +const buildPerTagStateKey = (threadRootId, tagName) => + JSON.stringify([threadRootId, requireTagName(tagName)]); + +const mergeThreadTagRoomState = ( + legacyTagsByThread, + perTagRecordsByThread, + perTagTombstonesByThread +) => { + const mergedThreadRootIds = new Set([ + ...legacyTagsByThread.keys(), + ...perTagRecordsByThread.keys(), + ...perTagTombstonesByThread.keys(), + ]); + + const merged = new Map(); + Array.from(mergedThreadRootIds) + .sort() + .forEach((threadRootId) => { + const mergedTags = cloneTags(legacyTagsByThread.get(threadRootId) ?? {}); + + perTagTombstonesByThread.get(threadRootId)?.forEach((tagName) => { + delete mergedTags[tagName]; + }); + + Object.assign(mergedTags, perTagRecordsByThread.get(threadRootId)); + + if (Object.keys(mergedTags).length > 0) { + merged.set(threadRootId, { tags: sortObject(mergedTags) }); + } + }); + + return merged; +}; + +export const collectRoomState = (stateEvents) => { + const warnings = []; + const legacyStateKeys = []; + const legacyStates = []; + const legacyStatesByThread = new Map(); + const legacyTagsByThread = new Map(); + const perTagRecordsByThread = new Map(); + const perTagTombstonesByThread = new Map(); + + stateEvents.forEach((event) => { + if (!isRecord(event) || event.type !== THREAD_TAGS_EVENT_TYPE) { + return; + } + + const parsedStateKey = parsePerTagStateKey(event.state_key); + if (parsedStateKey === null) { + if (typeof event.state_key !== 'string') { + return; + } + + legacyStateKeys.push(event.state_key); + const legacyState = parseLegacyThreadTagsContent(event.state_key, event.content, warnings); + if (!legacyState) { + return; + } + + legacyStates.push(legacyState); + legacyStatesByThread.set(legacyState.threadRootId, legacyState); + legacyTagsByThread.set(legacyState.threadRootId, legacyState.tags); + return; + } + + if (!isRecord(event.content)) { + return; + } + + const { threadRootId, tagName } = parsedStateKey; + if (Object.keys(event.content).length === 0) { + const tombstones = perTagTombstonesByThread.get(threadRootId) ?? new Set(); + tombstones.add(tagName); + perTagTombstonesByThread.set(threadRootId, tombstones); + return; + } + + const parsedTag = parseTagRecord( + tagName, + event.content, + warnings, + `per-tag state ${JSON.stringify(event.state_key)}` + ); + if (!parsedTag) { + return; + } + + const records = perTagRecordsByThread.get(threadRootId) ?? {}; + records[parsedTag.tagName] = parsedTag.record; + perTagRecordsByThread.set(threadRootId, records); + }); + + const merged = mergeThreadTagRoomState( + legacyTagsByThread, + perTagRecordsByThread, + perTagTombstonesByThread + ); + + return { + warnings, + legacyStateKeys, + legacyStates, + legacyStatesByThread, + legacyTagsByThread, + perTagRecordsByThread, + perTagTombstonesByThread, + merged, + }; +}; + +const normalizeMergedStateForCompare = (merged) => + Object.fromEntries( + Array.from(merged.entries(), ([threadRootId, content]) => [ + threadRootId, + { tags: sortObject(content.tags) }, + ]) + ); + +export const buildMigrationPlans = (collectedState) => { + const plans = collectedState.legacyStateKeys.map((threadRootId) => { + const legacyState = collectedState.legacyStatesByThread.get(threadRootId); + const existingPerTagRecords = collectedState.perTagRecordsByThread.get(threadRootId) ?? {}; + const existingPerTagTombstones = + collectedState.perTagTombstonesByThread.get(threadRootId) ?? new Set(); + + const writes = []; + const skipped = []; + + Object.entries(legacyState?.tags ?? {}).forEach(([tagName, record]) => { + if (existingPerTagRecords[tagName]) { + skipped.push({ + threadRootId, + tagName, + reason: 'per-tag record already exists', + }); + return; + } + + if (existingPerTagTombstones.has(tagName)) { + skipped.push({ + threadRootId, + tagName, + reason: 'per-tag tombstone already exists', + }); + return; + } + + writes.push({ + threadRootId, + tagName, + content: recordToWireContent(record), + }); + }); + + return { + threadRootId, + writes, + tombstoneLegacy: true, + skipped, + }; + }); + + const totalWrites = plans.reduce((sum, plan) => sum + plan.writes.length, 0); + const totalLegacyTombstones = plans.filter((plan) => plan.tombstoneLegacy).length; + const skipped = plans.flatMap((plan) => plan.skipped); + + return { + plans, + totalWrites, + totalLegacyTombstones, + skipped, + }; +}; + +export const predictMergedStateAfterMigration = (collectedState, migrationPlans) => { + const predictedLegacyTags = cloneTagMap(collectedState.legacyTagsByThread); + const predictedPerTagRecords = cloneTagMap(collectedState.perTagRecordsByThread); + const predictedPerTagTombstones = cloneTombstoneMap(collectedState.perTagTombstonesByThread); + + migrationPlans.plans.forEach((plan) => { + plan.writes.forEach((write) => { + const threadRecords = predictedPerTagRecords.get(write.threadRootId) ?? {}; + threadRecords[write.tagName] = { + set_by: write.content.set_by, + set_at: write.content.set_at, + ...(write.content.note !== undefined ? { note: write.content.note } : {}), + ...(write.content.data !== undefined ? { data: write.content.data } : {}), + }; + predictedPerTagRecords.set(write.threadRootId, threadRecords); + }); + + if (plan.tombstoneLegacy) { + predictedLegacyTags.delete(plan.threadRootId); + } + }); + + return mergeThreadTagRoomState( + predictedLegacyTags, + predictedPerTagRecords, + predictedPerTagTombstones + ); +}; + +const buildRequestUrl = (homeserver, path) => new URL(path, homeserver).toString(); + +const requestJson = async (homeserver, accessToken, path, init = {}) => { + const response = await fetch(buildRequestUrl(homeserver, path), { + ...init, + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + ...(init.headers ?? {}), + }, + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`${response.status} ${response.statusText}: ${body}`); + } + + if (response.status === 204) { + return null; + } + + return response.json(); +}; + +const fetchRoomState = (homeserver, accessToken, roomId) => + requestJson( + homeserver, + accessToken, + `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state` + ); + +const putRoomState = (homeserver, accessToken, roomId, stateKey, content) => + requestJson( + homeserver, + accessToken, + `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/${encodeURIComponent( + THREAD_TAGS_EVENT_TYPE + )}/${encodeURIComponent(stateKey)}`, + { + method: 'PUT', + body: JSON.stringify(content), + } + ); + +const printSummary = (modeLabel, collectedState, migrationPlans) => { + const legacyTagCount = collectedState.legacyStates.reduce( + (sum, state) => sum + Object.keys(state.tags).length, + 0 + ); + const perTagRecordCount = Array.from(collectedState.perTagRecordsByThread.values()).reduce( + (sum, tags) => sum + Object.keys(tags).length, + 0 + ); + const perTagTombstoneCount = Array.from(collectedState.perTagTombstonesByThread.values()).reduce( + (sum, tags) => sum + tags.size, + 0 + ); + + console.log(`${modeLabel} summary:`); + console.log(`- Legacy thread-tag events: ${collectedState.legacyStateKeys.length}`); + console.log(`- Legacy tags parsed: ${legacyTagCount}`); + console.log(`- Existing per-tag records: ${perTagRecordCount}`); + console.log(`- Existing per-tag tombstones: ${perTagTombstoneCount}`); + console.log(`- Planned per-tag writes: ${migrationPlans.totalWrites}`); + console.log(`- Planned legacy tombstones: ${migrationPlans.totalLegacyTombstones}`); + console.log(`- Skipped legacy tags: ${migrationPlans.skipped.length}`); + console.log(`- Parser warnings: ${collectedState.warnings.length}`); +}; + +const printWarnings = (warnings) => { + if (warnings.length === 0) return; + + console.warn('\nWarnings:'); + warnings.forEach((warning) => console.warn(`- ${warning}`)); +}; + +const printSkipped = (skipped) => { + if (skipped.length === 0) return; + + console.log('\nSkipped legacy tags:'); + skipped.forEach(({ threadRootId, tagName, reason }) => { + console.log(`- ${threadRootId} / ${tagName}: ${reason}`); + }); +}; + +const printPlannedWrites = (plans) => { + const writes = plans.flatMap((plan) => plan.writes); + if (writes.length === 0) return; + + console.log('\nPer-tag writes:'); + writes.forEach((write) => { + console.log(`- ${write.threadRootId} / ${write.tagName} -> ${buildPerTagStateKey(write.threadRootId, write.tagName)}`); + }); +}; + +const assertMergedStatesMatch = (expectedMerged, actualMerged, label) => { + const expected = JSON.stringify(normalizeMergedStateForCompare(expectedMerged)); + const actual = JSON.stringify(normalizeMergedStateForCompare(actualMerged)); + if (expected !== actual) { + throw new Error(`${label} verification failed: merged thread-tag state changed.`); + } +}; + +const main = async () => { + const args = parseArgs(process.argv.slice(2)); + const initialStateEvents = await fetchRoomState(args.homeserver, args.accessToken, args.roomId); + if (!Array.isArray(initialStateEvents)) { + throw new Error('Room state response was not an array.'); + } + + const collectedState = collectRoomState(initialStateEvents); + const migrationPlans = buildMigrationPlans(collectedState); + const predictedMergedState = predictMergedStateAfterMigration(collectedState, migrationPlans); + + printSummary(args.write ? 'Write' : 'Dry-run', collectedState, migrationPlans); + printWarnings(collectedState.warnings); + printSkipped(migrationPlans.skipped); + printPlannedWrites(migrationPlans.plans); + + assertMergedStatesMatch( + collectedState.merged, + predictedMergedState, + 'Dry-run prediction' + ); + + if (!args.write) { + console.log('\nDry-run complete. Re-run with --write to apply the migration.'); + return; + } + + for (const plan of migrationPlans.plans) { + for (const write of plan.writes) { + await putRoomState( + args.homeserver, + args.accessToken, + args.roomId, + buildPerTagStateKey(write.threadRootId, write.tagName), + write.content + ); + } + + if (plan.tombstoneLegacy) { + await putRoomState( + args.homeserver, + args.accessToken, + args.roomId, + plan.threadRootId, + {} + ); + } + } + + const verifiedStateEvents = await fetchRoomState(args.homeserver, args.accessToken, args.roomId); + if (!Array.isArray(verifiedStateEvents)) { + throw new Error('Verification room state response was not an array.'); + } + + const verifiedState = collectRoomState(verifiedStateEvents); + assertMergedStatesMatch(collectedState.merged, verifiedState.merged, 'Post-write'); + + console.log('\nWrite complete. Post-write merged state matches the pre-migration view.'); +}; + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + }); +} diff --git a/src/app/mindroom/bridges/bridgeDetection.test.ts b/src/app/mindroom/bridges/bridgeDetection.test.ts new file mode 100644 index 0000000000..8a5ba5c79d --- /dev/null +++ b/src/app/mindroom/bridges/bridgeDetection.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { isSignalBridgeRoom, isSignalBridgeUserId } from './bridgeDetection'; + +describe('bridgeDetection', () => { + it('detects signal bridge bot user ids', () => { + expect(isSignalBridgeUserId('@signalbot:example.org')).toBe(true); + expect(isSignalBridgeUserId('@signalbot_prod:example.org')).toBe(true); + expect(isSignalBridgeUserId('@telegrambot:example.org')).toBe(false); + expect(isSignalBridgeUserId('signalbot')).toBe(false); + }); + + it('detects signal-bridged rooms from active bot membership', () => { + expect( + isSignalBridgeRoom({ + getMembers: () => [ + { userId: '@alice:example.org', membership: 'join' }, + { userId: '@signalbot:example.org', membership: 'join' }, + ], + }) + ).toBe(true); + }); + + it('ignores left signal bots and non-signal bots', () => { + expect( + isSignalBridgeRoom({ + getMembers: () => [ + { userId: '@signalbot:example.org', membership: 'leave' }, + { userId: '@discordbot:example.org', membership: 'join' }, + ], + }) + ).toBe(false); + }); +}); diff --git a/src/app/mindroom/bridges/bridgeDetection.ts b/src/app/mindroom/bridges/bridgeDetection.ts new file mode 100644 index 0000000000..1d47a55f12 --- /dev/null +++ b/src/app/mindroom/bridges/bridgeDetection.ts @@ -0,0 +1,25 @@ +type RoomMemberLike = { + userId?: string | null; + membership?: string | null; +}; + +type RoomLike = { + getMembers: () => RoomMemberLike[]; +}; + +const ACTIVE_MEMBERSHIPS = new Set(['join', 'invite']); + +export const isSignalBridgeUserId = (userId: string): boolean => { + const match = /^@([^:]+):/.exec(userId); + if (!match) return false; + + const localpart = match[1].toLowerCase(); + return localpart === 'signalbot' || localpart.startsWith('signalbot_'); +}; + +export const isSignalBridgeRoom = (room: RoomLike): boolean => + room.getMembers().some((member) => { + if (!member.userId || !member.membership) return false; + if (!ACTIVE_MEMBERSHIPS.has(member.membership)) return false; + return isSignalBridgeUserId(member.userId); + }); diff --git a/src/app/mindroom/cache/clientStorageAtoms.test.ts b/src/app/mindroom/cache/clientStorageAtoms.test.ts new file mode 100644 index 0000000000..47cbd83c30 --- /dev/null +++ b/src/app/mindroom/cache/clientStorageAtoms.test.ts @@ -0,0 +1,96 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getDefaultStore } from 'jotai'; +import { + makeMindroomClientStorageAtoms, + registerMindroomClientStorageAtoms, +} from './clientStorageAtoms'; +import { + clearRecentThreadsPanelHeightStore, + setRecentThreadsPanelHeight, +} from '../recent-threads/recentThreadsPanelHeight'; +import { + clearRecentThreadsPanelMobileExpandedStore, + setRecentThreadsPanelMobileExpanded, +} from '../recent-threads/recentThreadsPanelMobileExpanded'; +import { bumpRecentThread, clearRecentThreadsStore } from '../recent-threads/recentThreads'; +import { + clearLastOpenThreadStore, + getLastOpenThread, + setLastOpenThread, +} from '../threads/lastOpenThread'; +import { + DEFAULT_CROSS_ROOM_THREAD_FILTERS, + clearCrossRoomThreadFiltersStore, +} from '../cross-room-threads/crossRoomThreadFilters'; + +const USER_ID = '@alice:example.org'; +const storage = new Map(); + +const clearMindroomStorage = () => { + clearLastOpenThreadStore(USER_ID); + clearRecentThreadsStore(USER_ID); + clearRecentThreadsPanelHeightStore(USER_ID); + clearRecentThreadsPanelMobileExpandedStore(USER_ID); + clearCrossRoomThreadFiltersStore(USER_ID); +}; + +beforeEach(() => { + storage.clear(); + vi.stubGlobal('localStorage', { + getItem: vi.fn((key: string) => storage.get(key) ?? null), + setItem: vi.fn((key: string, value: string) => { + storage.set(key, value); + }), + removeItem: vi.fn((key: string) => { + storage.delete(key); + }), + }); + clearMindroomStorage(); +}); + +afterEach(() => { + clearMindroomStorage(); + vi.unstubAllGlobals(); +}); + +describe('MindRoom client storage atom registration', () => { + it('registers all imperative client UI storage atoms for the active user', () => { + const atoms = makeMindroomClientStorageAtoms(USER_ID); + const unregister = registerMindroomClientStorageAtoms(atoms); + + setLastOpenThread('!room:example.org', '$thread-root'); + bumpRecentThread('!room:example.org', '$thread-root', 200, 'Preview text'); + setRecentThreadsPanelHeight(320); + setRecentThreadsPanelMobileExpanded(true); + getDefaultStore().set(atoms.crossRoomThreadFiltersAtom, { + ...DEFAULT_CROSS_ROOM_THREAD_FILTERS, + query: 'agent', + }); + + expect(getLastOpenThread('!room:example.org')).toBe('$thread-root'); + expect(storage.get(`lastOpenThread${USER_ID}`)).toBe('{"!room:example.org":"$thread-root"}'); + expect(storage.get(`recentThreads:${USER_ID}`)).toBe( + '{"v":1,"entries":[{"roomId":"!room:example.org","threadId":"$thread-root","openedAt":200,"summaryText":"Preview text"}]}' + ); + expect(storage.get(`recentThreadsPanelHeight:${USER_ID}`)).toBe('{"v":1,"height":320}'); + expect(storage.get(`recentThreadsPanelMobileExpanded:${USER_ID}`)).toBe( + '{"expanded":true,"v":1}' + ); + expect(getDefaultStore().get(atoms.crossRoomThreadFiltersAtom).query).toBe('agent'); + expect(storage.get(`crossRoomThreadFilters:${USER_ID}`)).not.toContain('"query"'); + + unregister(); + + setLastOpenThread('!room:example.org', '$after-unregister'); + bumpRecentThread('!room:example.org', '$after-unregister', 300); + setRecentThreadsPanelHeight(120); + setRecentThreadsPanelMobileExpanded(false); + + expect(getLastOpenThread('!room:example.org')).toBeUndefined(); + expect(storage.get(`lastOpenThread${USER_ID}`)).toBe('{"!room:example.org":"$thread-root"}'); + expect(storage.get(`recentThreadsPanelHeight:${USER_ID}`)).toBe('{"v":1,"height":320}'); + expect(storage.get(`recentThreadsPanelMobileExpanded:${USER_ID}`)).toBe( + '{"expanded":true,"v":1}' + ); + }); +}); diff --git a/src/app/mindroom/cache/clientStorageAtoms.ts b/src/app/mindroom/cache/clientStorageAtoms.ts new file mode 100644 index 0000000000..3e0c113c7a --- /dev/null +++ b/src/app/mindroom/cache/clientStorageAtoms.ts @@ -0,0 +1,64 @@ +import { useLayoutEffect, useMemo } from 'react'; +import { makeRecentThreadsAtom, registerRecentThreadsAtom } from '../recent-threads/recentThreads'; +import { + makeRecentThreadsPanelHeightAtom, + registerRecentThreadsPanelHeightAtom, +} from '../recent-threads/recentThreadsPanelHeight'; +import { + makeRecentThreadsPanelMobileExpandedAtom, + registerRecentThreadsPanelMobileExpandedAtom, +} from '../recent-threads/recentThreadsPanelMobileExpanded'; +import { + makeCrossRoomThreadFiltersAtom, + registerCrossRoomThreadFiltersAtom, +} from '../cross-room-threads/crossRoomThreadFilters'; +import { makeLastOpenThreadAtom, registerLastOpenThreadAtom } from '../threads/lastOpenThread'; + +export type MindroomClientStorageAtoms = { + lastOpenThreadAtom: ReturnType; + recentThreadsAtom: ReturnType; + recentThreadsPanelHeightAtom: ReturnType; + recentThreadsPanelMobileExpandedAtom: ReturnType; + crossRoomThreadFiltersAtom: ReturnType; +}; + +export const makeMindroomClientStorageAtoms = (userId: string): MindroomClientStorageAtoms => ({ + lastOpenThreadAtom: makeLastOpenThreadAtom(userId), + recentThreadsAtom: makeRecentThreadsAtom(userId), + recentThreadsPanelHeightAtom: makeRecentThreadsPanelHeightAtom(userId), + recentThreadsPanelMobileExpandedAtom: makeRecentThreadsPanelMobileExpandedAtom(userId), + crossRoomThreadFiltersAtom: makeCrossRoomThreadFiltersAtom(userId), +}); + +export const registerMindroomClientStorageAtoms = ({ + lastOpenThreadAtom, + recentThreadsAtom, + recentThreadsPanelHeightAtom, + recentThreadsPanelMobileExpandedAtom, + crossRoomThreadFiltersAtom, +}: MindroomClientStorageAtoms): (() => void) => { + const unregisterLastOpenThreadAtom = registerLastOpenThreadAtom(lastOpenThreadAtom); + const unregisterRecentThreadsAtom = registerRecentThreadsAtom(recentThreadsAtom); + const unregisterRecentThreadsPanelHeightAtom = registerRecentThreadsPanelHeightAtom( + recentThreadsPanelHeightAtom + ); + const unregisterRecentThreadsPanelMobileExpandedAtom = + registerRecentThreadsPanelMobileExpandedAtom(recentThreadsPanelMobileExpandedAtom); + const unregisterCrossRoomThreadFiltersAtom = registerCrossRoomThreadFiltersAtom( + crossRoomThreadFiltersAtom + ); + + return () => { + unregisterCrossRoomThreadFiltersAtom(); + unregisterRecentThreadsPanelMobileExpandedAtom(); + unregisterRecentThreadsPanelHeightAtom(); + unregisterRecentThreadsAtom(); + unregisterLastOpenThreadAtom(); + }; +}; + +export const useMindroomClientStorageAtoms = (userId: string): void => { + const storageAtoms = useMemo(() => makeMindroomClientStorageAtoms(userId), [userId]); + + useLayoutEffect(() => registerMindroomClientStorageAtoms(storageAtoms), [storageAtoms]); +}; diff --git a/src/app/mindroom/cache/sessionCleanup.test.ts b/src/app/mindroom/cache/sessionCleanup.test.ts new file mode 100644 index 0000000000..0c39b4f58b --- /dev/null +++ b/src/app/mindroom/cache/sessionCleanup.test.ts @@ -0,0 +1,135 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { MINDROOM_EDIT_DEBUG_STORAGE_KEY } from '../messages/editDebug'; +import { clearMindroomLongTextHydrationCache } from '../messages/longText'; +import { IOS_PUSH_LOCAL_STORAGE_KEY_PREFIX, clearIOSPushState } from '../native/iosPush'; +import { clearRecentThreadsPanelHeightStore } from '../recent-threads/recentThreadsPanelHeight'; +import { clearRecentThreadsPanelMobileExpandedStore } from '../recent-threads/recentThreadsPanelMobileExpanded'; +import { clearRecentThreadsStore } from '../recent-threads/recentThreads'; +import { clearCrossRoomThreadFiltersStore } from '../cross-room-threads/crossRoomThreadFilters'; +import { clearLastOpenThreadStore } from '../threads/lastOpenThread'; +import { clearRecentThreadViewModelSharedState } from '../threads/recentThreadViewModel'; +import { clearRoomThreadFiltersStore } from '../threads/roomThreadFilterState'; +import { deleteRoomEventCache, getRoomEventCacheDbName } from '../threads/roomEventCache'; +import { deleteThreadEventCache, getThreadEventCacheDbName } from '../threads/threadEventCache'; +import { + deleteThreadSummaryCache, + getThreadSummaryCacheDbName, +} from '../threads/threadSummaryStore'; +import { + MINDROOM_OWNED_LOCAL_STORAGE_KEYS, + MINDROOM_OWNED_LOCAL_STORAGE_PREFIXES, + MINDROOM_SINGLETON_INDEXED_DB_NAMES, + clearMindroomInMemoryCaches, + clearMindroomSessionNativeState, + clearMindroomSessionUiState, + deleteMindroomSessionCaches, + getMindroomSessionIndexedDbNames, +} from './sessionCleanup'; + +vi.mock('../messages/longText', () => ({ + clearMindroomLongTextHydrationCache: vi.fn(), +})); + +vi.mock('../native/iosPush', () => ({ + IOS_PUSH_LOCAL_STORAGE_KEY_PREFIX: 'mindroom_ios_push_', + clearIOSPushState: vi.fn(), +})); + +vi.mock('../recent-threads/recentThreads', () => ({ + clearRecentThreadsStore: vi.fn(), +})); + +vi.mock('../cross-room-threads/crossRoomThreadFilters', () => ({ + clearCrossRoomThreadFiltersStore: vi.fn(), +})); + +vi.mock('../threads/lastOpenThread', () => ({ + clearLastOpenThreadStore: vi.fn(), +})); + +vi.mock('../recent-threads/recentThreadsPanelHeight', () => ({ + clearRecentThreadsPanelHeightStore: vi.fn(), +})); + +vi.mock('../recent-threads/recentThreadsPanelMobileExpanded', () => ({ + clearRecentThreadsPanelMobileExpandedStore: vi.fn(), +})); + +vi.mock('../threads/recentThreadViewModel', () => ({ + clearRecentThreadViewModelSharedState: vi.fn(), +})); + +vi.mock('../threads/roomThreadFilterState', () => ({ + clearRoomThreadFiltersStore: vi.fn(), +})); + +vi.mock('../threads/roomEventCache', () => ({ + MINDROOM_ROOM_EVENT_CACHE_DB_NAME: 'mindroom-room-event-cache', + deleteRoomEventCache: vi.fn().mockResolvedValue(undefined), + getRoomEventCacheDbName: vi.fn((sessionId: string) => `room-cache::${sessionId}`), +})); + +vi.mock('../threads/threadEventCache', () => ({ + MINDROOM_THREAD_EVENT_CACHE_DB_NAME: 'mindroom-thread-event-cache', + deleteThreadEventCache: vi.fn().mockResolvedValue(undefined), + getThreadEventCacheDbName: vi.fn((sessionId: string) => `thread-cache::${sessionId}`), +})); + +vi.mock('../threads/threadSummaryStore', () => ({ + deleteThreadSummaryCache: vi.fn().mockResolvedValue(undefined), + getThreadSummaryCacheDbName: vi.fn((sessionId: string) => `summary-cache::${sessionId}`), +})); + +describe('MindRoom session cleanup', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('exposes MindRoom-owned app cleanup keys from one boundary', () => { + expect(MINDROOM_SINGLETON_INDEXED_DB_NAMES).toEqual([ + 'mindroom-room-event-cache', + 'mindroom-thread-event-cache', + ]); + expect(MINDROOM_OWNED_LOCAL_STORAGE_KEYS).toEqual([MINDROOM_EDIT_DEBUG_STORAGE_KEY]); + expect(MINDROOM_OWNED_LOCAL_STORAGE_PREFIXES).toEqual([IOS_PUSH_LOCAL_STORAGE_KEY_PREFIX]); + }); + + it('derives session-scoped MindRoom IndexedDB names', () => { + expect(getMindroomSessionIndexedDbNames('session-a')).toEqual([ + 'thread-cache::session-a', + 'room-cache::session-a', + 'summary-cache::session-a', + ]); + expect(vi.mocked(getThreadEventCacheDbName)).toHaveBeenCalledWith('session-a'); + expect(vi.mocked(getRoomEventCacheDbName)).toHaveBeenCalledWith('session-a'); + expect(vi.mocked(getThreadSummaryCacheDbName)).toHaveBeenCalledWith('session-a'); + }); + + it('deletes all MindRoom session caches together', async () => { + await deleteMindroomSessionCaches('session-a'); + + expect(vi.mocked(deleteThreadEventCache)).toHaveBeenCalledWith('session-a'); + expect(vi.mocked(deleteRoomEventCache)).toHaveBeenCalledWith('session-a'); + expect(vi.mocked(deleteThreadSummaryCache)).toHaveBeenCalledWith('session-a'); + }); + + it('clears MindRoom UI, native, and in-memory state', () => { + clearMindroomSessionUiState('@alice:example.com'); + clearMindroomSessionNativeState('session-a'); + clearMindroomInMemoryCaches(); + + expect(vi.mocked(clearLastOpenThreadStore)).toHaveBeenCalledWith('@alice:example.com'); + expect(vi.mocked(clearRoomThreadFiltersStore)).toHaveBeenCalledWith('@alice:example.com'); + expect(vi.mocked(clearCrossRoomThreadFiltersStore)).toHaveBeenCalledWith('@alice:example.com'); + expect(vi.mocked(clearRecentThreadsStore)).toHaveBeenCalledWith('@alice:example.com'); + expect(vi.mocked(clearRecentThreadsPanelHeightStore)).toHaveBeenCalledWith( + '@alice:example.com' + ); + expect(vi.mocked(clearRecentThreadsPanelMobileExpandedStore)).toHaveBeenCalledWith( + '@alice:example.com' + ); + expect(vi.mocked(clearRecentThreadViewModelSharedState)).toHaveBeenCalledTimes(1); + expect(vi.mocked(clearIOSPushState)).toHaveBeenCalledWith('session-a'); + expect(vi.mocked(clearMindroomLongTextHydrationCache)).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/mindroom/cache/sessionCleanup.ts b/src/app/mindroom/cache/sessionCleanup.ts new file mode 100644 index 0000000000..1d280fff76 --- /dev/null +++ b/src/app/mindroom/cache/sessionCleanup.ts @@ -0,0 +1,64 @@ +import { MINDROOM_EDIT_DEBUG_STORAGE_KEY } from '../messages/editDebug'; +import { clearMindroomLongTextHydrationCache } from '../messages/longText'; +import { IOS_PUSH_LOCAL_STORAGE_KEY_PREFIX, clearIOSPushState } from '../native/iosPush'; +import { clearRecentThreadsPanelHeightStore } from '../recent-threads/recentThreadsPanelHeight'; +import { clearRecentThreadsPanelMobileExpandedStore } from '../recent-threads/recentThreadsPanelMobileExpanded'; +import { clearRecentThreadsStore } from '../recent-threads/recentThreads'; +import { clearCrossRoomThreadFiltersStore } from '../cross-room-threads/crossRoomThreadFilters'; +import { clearLastOpenThreadStore } from '../threads/lastOpenThread'; +import { clearRecentThreadViewModelSharedState } from '../threads/recentThreadViewModel'; +import { clearRoomThreadFiltersStore } from '../threads/roomThreadFilterState'; +import { + MINDROOM_ROOM_EVENT_CACHE_DB_NAME, + deleteRoomEventCache, + getRoomEventCacheDbName, +} from '../threads/roomEventCache'; +import { + MINDROOM_THREAD_EVENT_CACHE_DB_NAME, + deleteThreadEventCache, + getThreadEventCacheDbName, +} from '../threads/threadEventCache'; +import { + deleteThreadSummaryCache, + getThreadSummaryCacheDbName, +} from '../threads/threadSummaryStore'; + +export const MINDROOM_SINGLETON_INDEXED_DB_NAMES = [ + MINDROOM_ROOM_EVENT_CACHE_DB_NAME, + MINDROOM_THREAD_EVENT_CACHE_DB_NAME, +] as const; + +export const MINDROOM_OWNED_LOCAL_STORAGE_KEYS = [MINDROOM_EDIT_DEBUG_STORAGE_KEY] as const; +export const MINDROOM_OWNED_LOCAL_STORAGE_PREFIXES = [IOS_PUSH_LOCAL_STORAGE_KEY_PREFIX] as const; + +export const getMindroomSessionIndexedDbNames = (sessionId: string): string[] => [ + getThreadEventCacheDbName(sessionId), + getRoomEventCacheDbName(sessionId), + getThreadSummaryCacheDbName(sessionId), +]; + +export const deleteMindroomSessionCaches = async (sessionId: string): Promise => { + await Promise.all([ + deleteThreadEventCache(sessionId), + deleteRoomEventCache(sessionId), + deleteThreadSummaryCache(sessionId), + ]); +}; + +export const clearMindroomSessionUiState = (userId: string): void => { + clearLastOpenThreadStore(userId); + clearRoomThreadFiltersStore(userId); + clearCrossRoomThreadFiltersStore(userId); + clearRecentThreadsStore(userId); + clearRecentThreadsPanelHeightStore(userId); + clearRecentThreadsPanelMobileExpandedStore(userId); + clearRecentThreadViewModelSharedState(); +}; + +export const clearMindroomSessionNativeState = (sessionId: string): void => { + clearIOSPushState(sessionId); +}; + +export const clearMindroomInMemoryCaches = (): void => { + clearMindroomLongTextHydrationCache(); +}; diff --git a/src/app/mindroom/cache/sessionStoreConfig.test.ts b/src/app/mindroom/cache/sessionStoreConfig.test.ts new file mode 100644 index 0000000000..4ce2ea702b --- /dev/null +++ b/src/app/mindroom/cache/sessionStoreConfig.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from 'vitest'; +import { MINDROOM_SESSION_STORE_EVENT, MINDROOM_SESSION_STORE_KEY } from './sessionStoreConfig'; + +describe('sessionStoreConfig', () => { + it('owns the persisted multi-account session store names', () => { + expect(MINDROOM_SESSION_STORE_KEY).toBe('mindroom_multi_account_store'); + expect(MINDROOM_SESSION_STORE_EVENT).toBe('mindroom-session-store-changed'); + }); +}); diff --git a/src/app/mindroom/cache/sessionStoreConfig.ts b/src/app/mindroom/cache/sessionStoreConfig.ts new file mode 100644 index 0000000000..4c7ae4c2c3 --- /dev/null +++ b/src/app/mindroom/cache/sessionStoreConfig.ts @@ -0,0 +1,2 @@ +export const MINDROOM_SESSION_STORE_KEY = 'mindroom_multi_account_store'; +export const MINDROOM_SESSION_STORE_EVENT = 'mindroom-session-store-changed'; diff --git a/src/app/mindroom/client/MindroomClientNonUIFeatures.test.ts b/src/app/mindroom/client/MindroomClientNonUIFeatures.test.ts new file mode 100644 index 0000000000..d9342ad5a6 --- /dev/null +++ b/src/app/mindroom/client/MindroomClientNonUIFeatures.test.ts @@ -0,0 +1,312 @@ +import React from 'react'; +import { Provider, createStore } from 'jotai'; +import type { Store } from 'jotai/vanilla'; +import type { MatrixClient } from 'matrix-js-sdk'; +import { MemoryRouter } from 'react-router-dom'; +import { act, create, ReactTestRenderer } from 'react-test-renderer'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { MatrixClientProvider } from '../../hooks/useMatrixClient'; +import { allInvitesAtom } from '../../state/room-list/inviteList'; +import { Membership } from '../../../types/matrix/room'; +import { MindroomInviteNotifications } from './MindroomClientNonUIFeatures'; + +type MockRoom = { + roomId: string; + getMyMembership: () => Membership; +}; + +type AudioMock = HTMLAudioElement & { + play: ReturnType; +}; + +const createInviteRoom = (roomId: string): MockRoom => ({ + roomId, + getMyMembership: () => Membership.Invite, +}); + +const createMatrixClient = (inviteRoomIds: string[]): MatrixClient => + ({ + getRooms: vi.fn(() => inviteRoomIds.map(createInviteRoom)), + getSyncState: vi.fn(() => 'SYNCING'), + } as unknown as MatrixClient); + +const setInvites = (store: Store, roomIds: string[]) => { + store.set(allInvitesAtom, { type: 'INITIALIZE', rooms: roomIds }); +}; + +const addInvite = (store: Store, roomId: string) => { + store.set(allInvitesAtom, { type: 'PUT', roomId }); +}; + +const removeInvite = (store: Store, roomId: string) => { + store.set(allInvitesAtom, { type: 'DELETE', roomId }); +}; + +const renderInviteNotifications = ( + store: Store, + mx: MatrixClient, + audioElement: AudioMock +): ReactTestRenderer => + create( + React.createElement( + Provider, + { store }, + React.createElement( + MatrixClientProvider, + { value: mx }, + React.createElement( + MemoryRouter, + { + future: { + v7_relativeSplatPath: true, + v7_startTransition: true, + }, + }, + React.createElement(MindroomInviteNotifications) + ) + ) + ), + { + createNodeMock: (element) => (element.type === 'audio' ? audioElement : null), + } + ); + +describe('MindroomInviteNotifications', () => { + let renderer: ReactTestRenderer | undefined; + let play: ReturnType; + let audioElement: AudioMock; + let notificationMock: ReturnType; + + beforeEach(() => { + play = vi.fn(() => Promise.resolve()); + audioElement = { play } as unknown as AudioMock; + notificationMock = vi.fn(() => ({ close: vi.fn() })); + Object.defineProperty(notificationMock, 'permission', { + configurable: true, + value: 'granted', + }); + vi.stubGlobal('window', { + Notification: notificationMock, + closed: false, + }); + }); + + afterEach(() => { + if (renderer) { + act(() => { + renderer?.unmount(); + }); + } + renderer = undefined; + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it('does not chime when stale atom invites are replaced by the current client hydration', async () => { + const store = createStore(); + setInvites(store, ['!stale:old']); + const mx = createMatrixClient(['!cached-a:new', '!cached-b:new']); + + await act(async () => { + renderer = renderInviteNotifications(store, mx, audioElement); + }); + expect(renderer?.root.findByType('audio').props.preload).toBe('none'); + + await act(async () => { + setInvites(store, ['!cached-a:new', '!cached-b:new']); + }); + + expect(play).not.toHaveBeenCalled(); + expect(notificationMock).not.toHaveBeenCalled(); + }); + + it('chimes when an empty cold-open receives a new live invite', async () => { + const store = createStore(); + const mx = createMatrixClient([]); + + await act(async () => { + renderer = renderInviteNotifications(store, mx, audioElement); + }); + + await act(async () => { + addInvite(store, '!new-live:server'); + }); + + expect(play).toHaveBeenCalledOnce(); + expect(notificationMock).toHaveBeenCalledOnce(); + expect(notificationMock).toHaveBeenCalledWith( + 'Invitation', + expect.objectContaining({ + body: 'You have 1 new invitation request.', + silent: true, + }) + ); + }); + + it('does not chime for startup hydration of a pre-existing invite', async () => { + const roomId = '!cached-invite:server'; + const store = createStore(); + const mx = createMatrixClient([roomId]); + + await act(async () => { + renderer = renderInviteNotifications(store, mx, audioElement); + }); + + await act(async () => { + setInvites(store, [roomId]); + }); + + expect(play).not.toHaveBeenCalled(); + expect(notificationMock).not.toHaveBeenCalled(); + }); + + it('chimes when the same room re-invites after disappearing from current invites', async () => { + const roomId = '!repeat-invite:server'; + const store = createStore(); + const mx = createMatrixClient([roomId]); + + await act(async () => { + renderer = renderInviteNotifications(store, mx, audioElement); + }); + await act(async () => { + addInvite(store, roomId); + }); + await act(async () => { + removeInvite(store, roomId); + }); + await act(async () => { + addInvite(store, roomId); + }); + + expect(play).toHaveBeenCalledOnce(); + expect(notificationMock).toHaveBeenCalledOnce(); + }); + + it('baselines the new Matrix client on account switch before invite hydration arrives', async () => { + const store = createStore(); + setInvites(store, ['!alice-stale:old']); + const aliceMx = createMatrixClient(['!alice-stale:old']); + const bobMx = createMatrixClient(['!bob-cached:new']); + + await act(async () => { + renderer = renderInviteNotifications(store, aliceMx, audioElement); + }); + await act(async () => { + renderer?.update( + React.createElement( + Provider, + { store }, + React.createElement( + MatrixClientProvider, + { value: bobMx }, + React.createElement( + MemoryRouter, + { + future: { + v7_relativeSplatPath: true, + v7_startTransition: true, + }, + }, + React.createElement(MindroomInviteNotifications) + ) + ) + ) + ); + }); + await act(async () => { + setInvites(store, ['!bob-cached:new']); + }); + + expect(play).not.toHaveBeenCalled(); + expect(notificationMock).not.toHaveBeenCalled(); + }); + + it('does not let stale account-switch atom IDs poison the new client baseline', async () => { + const sharedRoomId = '!shared:server'; + const store = createStore(); + setInvites(store, [sharedRoomId]); + const aliceMx = createMatrixClient([sharedRoomId]); + const bobMx = createMatrixClient([]); + + await act(async () => { + renderer = renderInviteNotifications(store, aliceMx, audioElement); + }); + await act(async () => { + renderer?.update( + React.createElement( + Provider, + { store }, + React.createElement( + MatrixClientProvider, + { value: bobMx }, + React.createElement( + MemoryRouter, + { + future: { + v7_relativeSplatPath: true, + v7_startTransition: true, + }, + }, + React.createElement(MindroomInviteNotifications) + ) + ) + ) + ); + }); + + expect(play).not.toHaveBeenCalled(); + expect(notificationMock).not.toHaveBeenCalled(); + + await act(async () => { + addInvite(store, sharedRoomId); + }); + + expect(play).toHaveBeenCalledOnce(); + expect(notificationMock).toHaveBeenCalledOnce(); + }); + + it('does not count unrelated stale account-switch atom IDs with a different live invite', async () => { + const store = createStore(); + setInvites(store, ['!stale-a:old', '!stale-b:old']); + const aliceMx = createMatrixClient(['!stale-a:old', '!stale-b:old']); + const bobMx = createMatrixClient([]); + + await act(async () => { + renderer = renderInviteNotifications(store, aliceMx, audioElement); + }); + await act(async () => { + renderer?.update( + React.createElement( + Provider, + { store }, + React.createElement( + MatrixClientProvider, + { value: bobMx }, + React.createElement( + MemoryRouter, + { + future: { + v7_relativeSplatPath: true, + v7_startTransition: true, + }, + }, + React.createElement(MindroomInviteNotifications) + ) + ) + ) + ); + }); + await act(async () => { + addInvite(store, '!new-live:server'); + }); + + expect(play).toHaveBeenCalledOnce(); + expect(notificationMock).toHaveBeenCalledOnce(); + expect(notificationMock).toHaveBeenCalledWith( + 'Invitation', + expect.objectContaining({ + body: 'You have 1 new invitation request.', + }) + ); + }); +}); diff --git a/src/app/mindroom/client/MindroomClientNonUIFeatures.tsx b/src/app/mindroom/client/MindroomClientNonUIFeatures.tsx new file mode 100644 index 0000000000..611ea5643c --- /dev/null +++ b/src/app/mindroom/client/MindroomClientNonUIFeatures.tsx @@ -0,0 +1,261 @@ +import { useAtomValue } from 'jotai'; +import React, { useCallback, useEffect, useRef } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { PluginListenerHandle } from '@capacitor/core'; +import { PushNotifications } from '@capacitor/push-notifications'; +import type { MatrixClient } from 'matrix-js-sdk'; +import InviteSound from '../../../../public/sound/invite.ogg'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useActiveSession } from '../../hooks/useSessionStore'; +import { useClientConfig } from '../../hooks/useClientConfig'; +import { getInboxInvitesPath } from '../../pages/pathUtils'; +import { allInvitesAtom } from '../../state/room-list/inviteList'; +import { roomToUnreadAtom } from '../../state/room/roomToUnread'; +import { useSetting } from '../../state/hooks/settings'; +import { settingsAtom } from '../../state/settings'; +import { notificationPermission, setFavicon } from '../../utils/dom'; +import { Membership } from '../../../types/matrix/room'; +import { MINDROOM_FAVICON_SRC } from '../branding/branding'; +import { + checkIOSPushPermission, + disableIOSPushPusher, + isNativeIOSPlatform, + registerIOSPush, + resolveIOSPushConfig, + setIOSPushEnabled, + unregisterIOSPush, + upsertIOSPushPusher, +} from '../native/iosPush'; +import { useIOSPushEnabled } from '../native/useIOSPushEnabled'; + +const LogoUnreadSVG = MINDROOM_FAVICON_SRC; +const LogoHighlightSVG = MINDROOM_FAVICON_SRC; + +function MindroomFaviconUpdater() { + const roomToUnread = useAtomValue(roomToUnreadAtom); + + useEffect(() => { + let notification = false; + let highlight = false; + roomToUnread.forEach((unread) => { + if (unread.total > 0) { + notification = true; + } + if (unread.highlight > 0) { + highlight = true; + } + }); + + if (notification) { + setFavicon(highlight ? LogoHighlightSVG : LogoUnreadSVG); + } else { + setFavicon(MINDROOM_FAVICON_SRC); + } + }, [roomToUnread]); + + return null; +} + +const createKnownInviteRoomIdSet = (mx: MatrixClient): Set => { + const roomIds = new Set(); + mx.getRooms().forEach((room) => { + if (room.getMyMembership() === Membership.Invite) { + roomIds.add(room.roomId); + } + }); + return roomIds; +}; + +type InviteObservationState = { + mx: MatrixClient; + knownRoomIds: Set; + currentRoomIds: Set; + suppressedRoomIds: Set; + lastInvites: string[]; +}; + +export function MindroomInviteNotifications() { + const audioRef = useRef(null); + const invites = useAtomValue(allInvitesAtom); + const mx = useMatrixClient(); + const inviteObservationStateRef = useRef(); + if (!inviteObservationStateRef.current || inviteObservationStateRef.current.mx !== mx) { + const knownRoomIds = createKnownInviteRoomIdSet(mx); + inviteObservationStateRef.current = { + mx, + knownRoomIds, + currentRoomIds: new Set(invites), + suppressedRoomIds: new Set(invites.filter((roomId) => !knownRoomIds.has(roomId))), + lastInvites: invites, + }; + } + + const navigate = useNavigate(); + const [showNotifications] = useSetting(settingsAtom, 'showNotifications'); + const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds'); + + const notify = useCallback( + (count: number) => { + const noti = new window.Notification('Invitation', { + icon: MINDROOM_FAVICON_SRC, + badge: MINDROOM_FAVICON_SRC, + body: `You have ${count} new invitation request.`, + silent: true, + }); + + noti.onclick = () => { + if (!window.closed) navigate(getInboxInvitesPath()); + noti.close(); + }; + }, + [navigate] + ); + + const playSound = useCallback(() => { + const audioElement = audioRef.current; + void audioElement?.play().catch(() => undefined); + }, []); + + useEffect(() => { + const observationState = inviteObservationStateRef.current; + if (!observationState) return; + + const currentRoomIds = new Set(invites); + const inviteAtomEmitted = observationState.lastInvites !== invites; + const enteredInviteRoomIds = invites.filter( + (roomId) => + !observationState.knownRoomIds.has(roomId) && !observationState.currentRoomIds.has(roomId) + ); + const reemittedSuppressedRoomId = + enteredInviteRoomIds.length === 0 && inviteAtomEmitted + ? [...invites] + .reverse() + .find( + (roomId) => + !observationState.knownRoomIds.has(roomId) && + observationState.currentRoomIds.has(roomId) && + observationState.suppressedRoomIds.has(roomId) + ) + : undefined; + const newInviteRoomIds = reemittedSuppressedRoomId + ? [reemittedSuppressedRoomId] + : enteredInviteRoomIds; + + observationState.currentRoomIds.forEach((roomId) => { + if (!currentRoomIds.has(roomId)) { + observationState.knownRoomIds.delete(roomId); + observationState.suppressedRoomIds.delete(roomId); + } + }); + newInviteRoomIds.forEach((roomId) => { + observationState.knownRoomIds.add(roomId); + observationState.suppressedRoomIds.delete(roomId); + }); + observationState.currentRoomIds = currentRoomIds; + observationState.lastInvites = invites; + + if (newInviteRoomIds.length > 0 && mx.getSyncState() === 'SYNCING') { + if (showNotifications && notificationPermission('granted')) { + notify(newInviteRoomIds.length); + } + + if (notificationSound) { + playSound(); + } + } + }, [mx, invites, showNotifications, notificationSound, notify, playSound]); + + return ( + // eslint-disable-next-line jsx-a11y/media-has-caption + + ); +} + +function MindroomNativeIOSPushFeature() { + const mx = useMatrixClient(); + const clientConfig = useClientConfig(); + const activeSession = useActiveSession(); + const sessionId = activeSession?.sessionId; + const nativePushNotifications = useIOSPushEnabled(sessionId); + + useEffect(() => { + const pushConfig = resolveIOSPushConfig(clientConfig, sessionId); + if (!isNativeIOSPlatform() || !pushConfig) return undefined; + + let disposed = false; + let registrationHandle: PluginListenerHandle | undefined; + let registrationErrorHandle: PluginListenerHandle | undefined; + + const setupNativePush = async () => { + const registrationListener = await PushNotifications.addListener('registration', (token) => { + if (disposed) return; + upsertIOSPushPusher(mx, pushConfig, token.value, sessionId).catch(async () => { + setIOSPushEnabled(false, sessionId); + await disableIOSPushPusher(mx, pushConfig, sessionId).catch(() => undefined); + await unregisterIOSPush().catch(() => undefined); + }); + }); + if (disposed) { + registrationListener.remove().catch(() => undefined); + return; + } + registrationHandle = registrationListener; + + const registrationErrorListener = await PushNotifications.addListener( + 'registrationError', + () => { + setIOSPushEnabled(false, sessionId); + disableIOSPushPusher(mx, pushConfig, sessionId).catch(() => undefined); + unregisterIOSPush().catch(() => undefined); + } + ); + if (disposed) { + registrationErrorListener.remove().catch(() => undefined); + return; + } + registrationErrorHandle = registrationErrorListener; + + if (!nativePushNotifications) { + await disableIOSPushPusher(mx, pushConfig, sessionId); + await unregisterIOSPush().catch(() => undefined); + return; + } + + const permission = await checkIOSPushPermission(); + if (permission !== 'granted') { + setIOSPushEnabled(false, sessionId); + await disableIOSPushPusher(mx, pushConfig, sessionId); + await unregisterIOSPush().catch(() => undefined); + return; + } + + await registerIOSPush(); + }; + + setupNativePush().catch(async () => { + setIOSPushEnabled(false, sessionId); + await disableIOSPushPusher(mx, pushConfig, sessionId).catch(() => undefined); + await unregisterIOSPush().catch(() => undefined); + }); + + return () => { + disposed = true; + registrationHandle?.remove().catch(() => undefined); + registrationErrorHandle?.remove().catch(() => undefined); + }; + }, [clientConfig, mx, nativePushNotifications, sessionId]); + + return null; +} + +export function MindroomClientNonUIFeatures() { + return ( + <> + + + + + ); +} diff --git a/src/app/mindroom/cross-room-threads/__tests__/crossRoomThreadFilterPipeline.test.ts b/src/app/mindroom/cross-room-threads/__tests__/crossRoomThreadFilterPipeline.test.ts new file mode 100644 index 0000000000..8ae1b470e6 --- /dev/null +++ b/src/app/mindroom/cross-room-threads/__tests__/crossRoomThreadFilterPipeline.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest'; +import type { CrossRoomThreadIndexEntry } from '../crossRoomThreadIndex'; +import { applyCrossRoomThreadFilters } from '../crossRoomThreadFilterPipeline'; +import { DEFAULT_CROSS_ROOM_THREAD_FILTERS } from '../crossRoomThreadFilters'; + +const now = Date.UTC(2026, 4, 6, 12, 0, 0); + +const makeEntry = ( + threadRootId: string, + overrides: Partial = {} +): CrossRoomThreadIndexEntry => + ({ + key: `!room:example.org\u0000${threadRootId}`, + roomId: '!room:example.org', + roomName: 'Room', + parentSpaceIds: [], + threadRootId, + lastActivityTs: now, + isUnread: false, + isResolved: false, + hasAttention: false, + isInvolved: true, + summaryText: 'summary', + rootPreviewText: 'root', + searchableText: 'root summary', + tags: [], + generation: 0, + ...overrides, + } as CrossRoomThreadIndexEntry); + +describe('crossRoomThreadFilterPipeline', () => { + it('applies each structured filter axis', () => { + const entries = [ + makeEntry('$a', { + roomId: '!a:example.org', + parentSpaceIds: ['!space:example.org'], + tags: ['urgent'], + isUnread: true, + hasAttention: true, + }), + makeEntry('$b', { + roomId: '!b:example.org', + parentSpaceIds: ['!other:example.org'], + tags: ['done'], + isUnread: false, + isResolved: true, + isInvolved: false, + }), + ]; + + const result = applyCrossRoomThreadFilters( + entries, + { + ...DEFAULT_CROSS_ROOM_THREAD_FILTERS, + scope: 'all', + roomIds: ['!a:example.org'], + spaceIds: ['!space:example.org'], + tag: { include: ['urgent'], exclude: ['done'] }, + unreadOnly: true, + resolved: 'unresolved', + hasAttention: true, + }, + { now } + ); + + expect(result.map((entry) => entry.threadRootId)).toEqual(['$a']); + }); + + it('defaults to involved threads and the 7d activity window', () => { + const old = now - 8 * 24 * 60 * 60 * 1000; + const entries = [ + makeEntry('$active-involved'), + makeEntry('$not-involved', { isInvolved: false }), + makeEntry('$old', { lastActivityTs: old }), + ]; + + expect( + applyCrossRoomThreadFilters(entries, DEFAULT_CROSS_ROOM_THREAD_FILTERS, { now }).map( + (entry) => entry.threadRootId + ) + ).toEqual(['$active-involved']); + }); + + it('searches only the precomputed root and summary haystack', () => { + const entries = [ + makeEntry('$root-match', { + searchableText: 'root-token summary', + }), + makeEntry('$reply-only', { + searchableText: 'root summary', + rootPreviewText: 'root', + summaryText: 'summary', + }), + ]; + + expect( + applyCrossRoomThreadFilters( + entries, + { ...DEFAULT_CROSS_ROOM_THREAD_FILTERS, query: 'root-token' }, + { now } + ).map((entry) => entry.threadRootId) + ).toEqual(['$root-match']); + + expect( + applyCrossRoomThreadFilters( + entries, + { ...DEFAULT_CROSS_ROOM_THREAD_FILTERS, query: 'reply-only-secret-token' }, + { now } + ) + ).toEqual([]); + }); + + it('sorts by activity first, then unread attention and stable ties', () => { + const entries = [ + makeEntry('$c', { roomName: 'Beta', lastActivityTs: now, isUnread: false }), + makeEntry('$a', { roomName: 'Alpha', lastActivityTs: now, isUnread: false }), + makeEntry('$old-unread', { + roomName: 'Alpha', + lastActivityTs: now - 1, + isUnread: true, + hasAttention: true, + }), + makeEntry('$unread', { roomName: 'Alpha', lastActivityTs: now, isUnread: true }), + ]; + + expect( + applyCrossRoomThreadFilters( + entries, + { ...DEFAULT_CROSS_ROOM_THREAD_FILTERS, activityWindow: 'all' }, + { now } + ).map((entry) => entry.threadRootId) + ).toEqual(['$unread', '$a', '$c', '$old-unread']); + }); +}); diff --git a/src/app/mindroom/cross-room-threads/__tests__/crossRoomThreadFilters.test.ts b/src/app/mindroom/cross-room-threads/__tests__/crossRoomThreadFilters.test.ts new file mode 100644 index 0000000000..a2aeee4d6c --- /dev/null +++ b/src/app/mindroom/cross-room-threads/__tests__/crossRoomThreadFilters.test.ts @@ -0,0 +1,112 @@ +// @vitest-environment jsdom + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { getDefaultStore } from 'jotai'; +import { + DEFAULT_CROSS_ROOM_THREAD_FILTERS, + clearCrossRoomThreadFiltersStore, + makeCrossRoomThreadFiltersAtom, + sanitizeCrossRoomThreadFilters, +} from '../crossRoomThreadFilters'; + +describe('crossRoomThreadFilters', () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it('defaults unknown storage shapes', () => { + expect(sanitizeCrossRoomThreadFilters(null)).toEqual(DEFAULT_CROSS_ROOM_THREAD_FILTERS); + expect(sanitizeCrossRoomThreadFilters({ v: 2, query: 'ignored' })).toEqual( + DEFAULT_CROSS_ROOM_THREAD_FILTERS + ); + }); + + it('sanitizes known fields and drops unknown values', () => { + expect( + sanitizeCrossRoomThreadFilters({ + v: 1, + query: ' hello ', + scope: 'bad', + roomIds: ['!a', '!a', 1, ''], + spaceIds: ['!s'], + tag: { include: ['urgent', 'urgent'], exclude: ['done'] }, + unreadOnly: true, + resolved: 'unresolved', + hasAttention: true, + activityWindow: 'bad', + ignored: true, + }) + ).toEqual({ + ...DEFAULT_CROSS_ROOM_THREAD_FILTERS, + query: 'hello', + roomIds: ['!a'], + spaceIds: ['!s'], + tag: { include: ['urgent'], exclude: ['done'] }, + unreadOnly: true, + resolved: 'unresolved', + hasAttention: true, + }); + }); + + it('persists structured filters under the bare per-user localStorage key and clears them', () => { + const atom = makeCrossRoomThreadFiltersAtom('@me:example.org'); + const store = getDefaultStore(); + + store.set(atom, { + ...DEFAULT_CROSS_ROOM_THREAD_FILTERS, + query: 'agent', + scope: 'all', + }); + + const stored = localStorage.getItem('crossRoomThreadFilters:@me:example.org'); + expect(stored).toContain('"scope":"all"'); + expect(stored).not.toContain('agent'); + expect(stored).not.toContain('"query"'); + + clearCrossRoomThreadFiltersStore('@me:example.org'); + expect(localStorage.getItem('crossRoomThreadFilters:@me:example.org')).toBeNull(); + }); + + it('keeps the in-memory query when a cross-tab storage event updates persisted filters', async () => { + const userId = '@storage-query:example.org'; + const atom = makeCrossRoomThreadFiltersAtom(userId); + const store = getDefaultStore(); + const unsubscribe = store.sub(atom, () => {}); + const storageKey = 'crossRoomThreadFilters:@storage-query:example.org'; + + store.set(atom, { + ...DEFAULT_CROSS_ROOM_THREAD_FILTERS, + query: 'foo', + scope: 'all', + }); + + localStorage.setItem( + storageKey, + JSON.stringify({ + ...DEFAULT_CROSS_ROOM_THREAD_FILTERS, + query: 'bar', + scope: 'involved', + unreadOnly: true, + }) + ); + window.dispatchEvent(new StorageEvent('storage', { key: storageKey })); + await Promise.resolve(); + + expect(store.get(atom).query).toBe('foo'); + expect(store.get(atom).unreadOnly).toBe(true); + + unsubscribe(); + clearCrossRoomThreadFiltersStore(userId); + }); + + it('returns fresh ephemeral atoms while the user id is empty', () => { + const firstAtom = makeCrossRoomThreadFiltersAtom(''); + const secondAtom = makeCrossRoomThreadFiltersAtom(''); + + expect(firstAtom).not.toBe(secondAtom); + }); +}); diff --git a/src/app/mindroom/cross-room-threads/__tests__/crossRoomThreadIndex.test.ts b/src/app/mindroom/cross-room-threads/__tests__/crossRoomThreadIndex.test.ts new file mode 100644 index 0000000000..fd53898bc0 --- /dev/null +++ b/src/app/mindroom/cross-room-threads/__tests__/crossRoomThreadIndex.test.ts @@ -0,0 +1,421 @@ +import { describe, expect, it, vi } from 'vitest'; +import { RelationType } from 'matrix-js-sdk/lib/@types/event'; +import type { MatrixEvent } from 'matrix-js-sdk'; +import type { Room } from 'matrix-js-sdk/lib/models/room'; +import type { Thread } from 'matrix-js-sdk/lib/models/thread'; +import { + buildCrossRoomThreadIndexEntry, + createCrossRoomThreadDirtyCoalescer, + CROSS_ROOM_INDEX_EVICTION_SLACK, + emptyCrossRoomThreadIndexSnapshot, + getCrossRoomThreadIndexKey, + getCrossRoomThreadRootsForEvent, + isUserInvolvedInThread, + MAX_CROSS_ROOM_INDEX_ENTRIES, + removeCrossRoomThreadIndexEntry, + removeRoomCrossRoomThreadIndexEntries, + upsertCrossRoomThreadIndexEntry, +} from '../crossRoomThreadIndex'; +import type { ThreadTagSnapshot } from '../../threads/threadTagSnapshots'; + +type EventOptions = { + id: string; + sender?: string; + body?: string; + ts?: number; + threadRootId?: string; + content?: Record; + replacingEvent?: () => MatrixEvent | undefined; +}; + +const makeEvent = ({ + id, + sender = '@alice:example.org', + body, + ts = 1, + threadRootId, + content, + replacingEvent, +}: EventOptions): MatrixEvent => + ({ + threadRootId, + getId: () => id, + getSender: () => sender, + getTs: () => ts, + getType: () => 'm.room.message', + getContent: () => content ?? { msgtype: 'm.text', body }, + getUnsigned: () => ({}), + getRelation: () => (threadRootId ? { rel_type: RelationType.Thread } : undefined), + isRelation: (relationType: string) => relationType === RelationType.Thread && !!threadRootId, + isRedacted: () => false, + isRedaction: () => false, + replacingEvent: () => replacingEvent?.(), + } as MatrixEvent); + +const makeTimelineSet = (events: MatrixEvent[]) => ({ + relations: { + getChildEventsForEvent: () => undefined, + }, + getLiveTimeline: () => ({ + getEvents: () => events, + getNeighbouringTimeline: () => undefined, + }), +}); + +const makeThread = (root: MatrixEvent, replies: MatrixEvent[] = []): Thread => + ({ + rootEvent: root, + events: replies, + timeline: replies, + length: replies.length, + lastReply: () => replies.at(-1) ?? null, + getUnfilteredTimelineSet: () => makeTimelineSet([...replies, root]), + } as Thread); + +const tagSnapshot: ThreadTagSnapshot = { + content: { tags: { urgent: { set_by: '@alice:example.org', set_at: '2026-05-06T00:00:00Z' } } }, + isResolved: false, + displayTags: ['urgent'], +}; + +const makeRoom = ({ + roomId = '!room:example.org', + name = 'Room', + root, + replies = [], +}: { + roomId?: string; + name?: string; + root: MatrixEvent; + replies?: MatrixEvent[]; +}): Room => { + const thread = makeThread(root, replies); + + return { + roomId, + name, + getThread: (threadRootId: string) => (threadRootId === root.getId() ? thread : null), + findEventById: (eventId: string) => (eventId === root.getId() ? root : undefined), + getMember: () => undefined, + getEventReadUpTo: () => undefined, + getUnfilteredTimelineSet: () => makeTimelineSet([...replies, root]), + } as unknown as Room; +}; + +describe('crossRoomThreadIndex', () => { + it('builds entries from root preview and summary text only', () => { + const root = makeEvent({ id: '$root', body: 'Root preview', ts: 100 }); + const reply = makeEvent({ + id: '$reply', + body: 'reply-only-secret-token', + ts: 150, + threadRootId: '$root', + }); + const room = makeRoom({ root, replies: [reply] }); + + const entry = buildCrossRoomThreadIndexEntry({ + room, + threadRootId: '$root', + currentUserId: '@bob:example.org', + summaryInfo: { summaryText: 'Summary text', generatedTs: 120, messageCount: 1 }, + tagSnapshot, + }); + + expect(entry).toBeDefined(); + expect(entry?.searchableText).toContain('root preview'); + expect(entry?.searchableText).toContain('summary text'); + expect(entry?.searchableText).not.toContain('reply-only-secret-token'); + }); + + it('computes involvement from root sender, visible reply sender, and direct mentions', () => { + const ownRoot = makeEvent({ id: '$own-root', sender: '@me:example.org', body: 'Root' }); + expect( + isUserInvolvedInThread({ + rootEvent: ownRoot, + thread: makeThread(ownRoot), + userId: '@me:example.org', + }) + ).toBe(true); + + const root = makeEvent({ id: '$root', body: 'Root' }); + const ownReply = makeEvent({ + id: '$own-reply', + sender: '@me:example.org', + body: 'Reply', + threadRootId: '$root', + }); + expect( + isUserInvolvedInThread({ + rootEvent: root, + thread: makeThread(root, [ownReply]), + userId: '@me:example.org', + }) + ).toBe(true); + + const mentionedReply = makeEvent({ + id: '$mention', + body: 'No body scan needed', + threadRootId: '$root', + content: { 'm.mentions': { user_ids: ['@me:example.org'] } }, + }); + expect( + isUserInvolvedInThread({ + rootEvent: root, + thread: makeThread(root, [mentionedReply]), + userId: '@me:example.org', + }) + ).toBe(true); + + const roomMention = makeEvent({ + id: '$room-mention', + body: 'Room mention', + threadRootId: '$root', + content: { 'm.mentions': { room: true, user_ids: ['@me:example.org'] } }, + }); + expect( + isUserInvolvedInThread({ + rootEvent: root, + thread: makeThread(root, [roomMention]), + userId: '@me:example.org', + }) + ).toBe(true); + }); + + it('computes direct mention involvement from effective replacement content', () => { + const root = makeEvent({ id: '$root', body: 'Root' }); + let latestEdit: MatrixEvent | undefined; + const reply = makeEvent({ + id: '$reply', + body: 'Reply', + threadRootId: '$root', + replacingEvent: () => latestEdit, + }); + + expect( + isUserInvolvedInThread({ + rootEvent: root, + thread: makeThread(root, [reply]), + userId: '@me:example.org', + }) + ).toBe(false); + + latestEdit = makeEvent({ + id: '$edit-add', + content: { + msgtype: 'm.text', + body: '* Reply', + 'm.relates_to': { rel_type: RelationType.Replace, event_id: '$reply' }, + 'm.mentions': { user_ids: [] }, + 'm.new_content': { + msgtype: 'm.text', + body: 'Reply @me', + 'm.mentions': { user_ids: ['@me:example.org'] }, + }, + }, + }); + + expect( + isUserInvolvedInThread({ + rootEvent: root, + thread: makeThread(root, [reply]), + userId: '@me:example.org', + }) + ).toBe(true); + + latestEdit = makeEvent({ + id: '$edit-remove', + content: { + msgtype: 'm.text', + body: '* Reply @me', + 'm.relates_to': { rel_type: RelationType.Replace, event_id: '$reply' }, + 'm.mentions': { user_ids: ['@me:example.org'] }, + 'm.new_content': { + msgtype: 'm.text', + body: 'Reply without mention', + }, + }, + }); + + expect( + isUserInvolvedInThread({ + rootEvent: root, + thread: makeThread(root, [reply]), + userId: '@me:example.org', + }) + ).toBe(false); + }); + + it('reads direct mentions from replacement wrapper new_content when the wrapper is enumerated', () => { + const root = makeEvent({ id: '$root', body: 'Root' }); + const replyEdit = makeEvent({ + id: '$reply-edit', + body: '* Reply', + threadRootId: '$root', + content: { + msgtype: 'm.text', + body: '* Reply', + 'm.relates_to': { rel_type: RelationType.Replace, event_id: '$reply' }, + 'm.mentions': { user_ids: [] }, + 'm.new_content': { + msgtype: 'm.text', + body: 'Reply @me', + 'm.mentions': { user_ids: ['@me:example.org'] }, + }, + }, + }); + + expect( + isUserInvolvedInThread({ + rootEvent: root, + thread: makeThread(root, [replyEdit]), + userId: '@me:example.org', + }) + ).toBe(true); + }); + + it('upserts, removes, removes rooms, and bumps versions only when state changes', () => { + const root = makeEvent({ id: '$root', body: 'Root' }); + const entry = buildCrossRoomThreadIndexEntry({ + room: makeRoom({ root }), + threadRootId: '$root', + tagSnapshot, + }); + expect(entry).toBeDefined(); + + const inserted = upsertCrossRoomThreadIndexEntry(emptyCrossRoomThreadIndexSnapshot(), entry!); + expect(inserted.version).toBe(1); + expect( + inserted.entries.get(getCrossRoomThreadIndexKey('!room:example.org', '$root')) + ).toBeDefined(); + + const removed = removeCrossRoomThreadIndexEntry(inserted, '!room:example.org', '$root'); + expect(removed.version).toBe(2); + expect(removed.entries.size).toBe(0); + + expect(removeCrossRoomThreadIndexEntry(removed, '!room:example.org', '$root')).toBe(removed); + + const roomA = upsertCrossRoomThreadIndexEntry(inserted, { + ...entry!, + key: getCrossRoomThreadIndexKey('!other:example.org', '$other'), + roomId: '!other:example.org', + threadRootId: '$other', + }); + const roomRemoved = removeRoomCrossRoomThreadIndexEntries(roomA, '!room:example.org'); + expect(roomRemoved.entries.size).toBe(1); + expect(roomRemoved.entries.values().next().value.roomId).toBe('!other:example.org'); + }); + + it('indexes thread roots by root and visible reply event ids', () => { + const root = makeEvent({ id: '$root', body: 'Root' }); + const reply = makeEvent({ + id: '$reply', + body: 'Reply', + threadRootId: '$root', + }); + const room = makeRoom({ root, replies: [reply] }); + const entry = buildCrossRoomThreadIndexEntry({ + room, + threadRootId: '$root', + tagSnapshot, + }); + expect(entry).toBeDefined(); + + const inserted = upsertCrossRoomThreadIndexEntry(emptyCrossRoomThreadIndexSnapshot(), entry!); + expect(getCrossRoomThreadRootsForEvent(inserted, room.roomId, '$root')).toEqual(['$root']); + expect(getCrossRoomThreadRootsForEvent(inserted, room.roomId, '$reply')).toEqual(['$root']); + + const removed = removeCrossRoomThreadIndexEntry(inserted, room.roomId, '$root'); + expect(getCrossRoomThreadRootsForEvent(removed, room.roomId, '$root')).toEqual([]); + expect(getCrossRoomThreadRootsForEvent(removed, room.roomId, '$reply')).toEqual([]); + }); + + it('batch-evicts oldest entries only after the eviction slack is exceeded', () => { + let snapshot = emptyCrossRoomThreadIndexSnapshot(); + const root = makeEvent({ id: '$root', body: 'Root' }); + const entry = buildCrossRoomThreadIndexEntry({ + room: makeRoom({ root }), + threadRootId: '$root', + tagSnapshot, + }); + expect(entry).toBeDefined(); + + for (let index = 0; index < MAX_CROSS_ROOM_INDEX_ENTRIES; index += 1) { + snapshot = upsertCrossRoomThreadIndexEntry(snapshot, { + ...entry!, + key: getCrossRoomThreadIndexKey(`!room${index}:example.org`, `$root${index}`), + roomId: `!room${index}:example.org`, + threadRootId: `$root${index}`, + lastActivityTs: index, + }); + } + + snapshot = upsertCrossRoomThreadIndexEntry(snapshot, { + ...entry!, + key: getCrossRoomThreadIndexKey( + `!room${MAX_CROSS_ROOM_INDEX_ENTRIES}:example.org`, + `$root${MAX_CROSS_ROOM_INDEX_ENTRIES}` + ), + roomId: `!room${MAX_CROSS_ROOM_INDEX_ENTRIES}:example.org`, + threadRootId: `$root${MAX_CROSS_ROOM_INDEX_ENTRIES}`, + lastActivityTs: MAX_CROSS_ROOM_INDEX_ENTRIES, + }); + + expect(snapshot.entries.size).toBe(MAX_CROSS_ROOM_INDEX_ENTRIES + 1); + expect(snapshot.entries.has(getCrossRoomThreadIndexKey('!room0:example.org', '$root0'))).toBe( + true + ); + + for ( + let index = MAX_CROSS_ROOM_INDEX_ENTRIES + 1; + index <= MAX_CROSS_ROOM_INDEX_ENTRIES + CROSS_ROOM_INDEX_EVICTION_SLACK; + index += 1 + ) { + snapshot = upsertCrossRoomThreadIndexEntry(snapshot, { + ...entry!, + key: getCrossRoomThreadIndexKey(`!room${index}:example.org`, `$root${index}`), + roomId: `!room${index}:example.org`, + threadRootId: `$root${index}`, + lastActivityTs: index, + }); + } + + expect(snapshot.entries.size).toBe(MAX_CROSS_ROOM_INDEX_ENTRIES); + expect(snapshot.entries.has(getCrossRoomThreadIndexKey('!room0:example.org', '$root0'))).toBe( + false + ); + expect( + snapshot.entries.has( + getCrossRoomThreadIndexKey( + `!room${CROSS_ROOM_INDEX_EVICTION_SLACK}:example.org`, + `$root${CROSS_ROOM_INDEX_EVICTION_SLACK}` + ) + ) + ).toBe(false); + expect( + snapshot.entries.has( + getCrossRoomThreadIndexKey( + `!room${CROSS_ROOM_INDEX_EVICTION_SLACK + 1}:example.org`, + `$root${CROSS_ROOM_INDEX_EVICTION_SLACK + 1}` + ) + ) + ).toBe(true); + }); + + it('coalesces dirty keys into one scheduled flush', () => { + const callbacks: Array<() => void> = []; + const flush = vi.fn(); + const coalescer = createCrossRoomThreadDirtyCoalescer(flush, (callback) => + callbacks.push(callback) + ); + + coalescer.enqueueDirty('a'); + coalescer.enqueueDirty('b'); + coalescer.enqueueDirty('a'); + + expect(callbacks).toHaveLength(1); + callbacks[0](); + + expect(flush).toHaveBeenCalledTimes(1); + expect(flush).toHaveBeenCalledWith(['a', 'b']); + }); +}); diff --git a/src/app/mindroom/cross-room-threads/__tests__/crossRoomThreads.architecture.test.ts b/src/app/mindroom/cross-room-threads/__tests__/crossRoomThreads.architecture.test.ts new file mode 100644 index 0000000000..d6ad267187 --- /dev/null +++ b/src/app/mindroom/cross-room-threads/__tests__/crossRoomThreads.architecture.test.ts @@ -0,0 +1,73 @@ +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join, relative, resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const sourceRoots = [ + 'src/app/mindroom/cross-room-threads', + 'src/app/pages/client/threads', + 'src/app/pages/client/sidebar/ThreadsTab.tsx', + 'src/app/hooks/router/useThreadsSelected.ts', + 'src/app/pages/MobileFriendly.tsx', +]; + +const readSources = (): Map => { + const sources = new Map(); + + const visit = (path: string) => { + if (path.includes('/__tests__/')) return; + const absolutePath = resolve(process.cwd(), path); + const stat = statSync(absolutePath); + if (stat.isDirectory()) { + readdirSync(absolutePath).forEach((child) => visit(join(path, child))); + return; + } + if (!/\.(ts|tsx)$/.test(path)) return; + sources.set(relative(process.cwd(), absolutePath), readFileSync(absolutePath, 'utf8')); + }; + + sourceRoots.forEach(visit); + return sources; +}; + +describe('cross-room Threads architecture', () => { + it('does not introduce PWA reload or browser lifecycle hazards', () => { + const combined = Array.from(readSources().values()).join('\n'); + + expect(combined).not.toContain('window.location.reload'); + expect(combined).not.toContain('pagehide'); + expect(combined).not.toContain('beforeunload'); + expect(combined).not.toContain('unload'); + expect(combined).not.toContain('serviceWorker'); + expect(combined).not.toContain('service-worker'); + expect(combined).not.toContain('type="file"'); + expect(combined).not.toContain("type='file'"); + expect(combined).not.toContain('MindroomBackRouteHandler'); + expect(combined).not.toContain('blockStandaloneWebApp'); + }); + + it('keeps row rendering on the pure compact-card view model path', () => { + const rowSource = readFileSync( + resolve(process.cwd(), 'src/app/pages/client/threads/ThreadsViewRow.tsx'), + 'utf8' + ); + + expect(rowSource).toContain('buildCompactThreadCardViewModelFromRecord'); + expect(rowSource).not.toContain('useCompactThreadCardViewModels'); + expect(rowSource).toContain('navigateRoomThread(entry.roomId, entry.threadRootId)'); + expect(rowSource).not.toContain('navigateRoom('); + }); + + it('keeps free-text filtering scoped to the precomputed entry haystack', () => { + const pipelineSource = readFileSync( + resolve( + process.cwd(), + 'src/app/mindroom/cross-room-threads/crossRoomThreadFilterPipeline.ts' + ), + 'utf8' + ); + + expect(pipelineSource).toContain('entry.searchableText.includes'); + expect(pipelineSource).not.toContain('reply'); + expect(pipelineSource).not.toContain('body'); + }); +}); diff --git a/src/app/mindroom/cross-room-threads/__tests__/useCrossRoomThreadIndex.test.ts b/src/app/mindroom/cross-room-threads/__tests__/useCrossRoomThreadIndex.test.ts new file mode 100644 index 0000000000..891f259bd3 --- /dev/null +++ b/src/app/mindroom/cross-room-threads/__tests__/useCrossRoomThreadIndex.test.ts @@ -0,0 +1,1132 @@ +// @vitest-environment jsdom + +import React from 'react'; +import { act, create } from 'react-test-renderer'; +import { Provider, createStore } from 'jotai'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { RelationType } from 'matrix-js-sdk/lib/@types/event'; +import { MatrixEventEvent, type MatrixEvent } from 'matrix-js-sdk/lib/models/event'; +import { RoomEvent, RoomStateEvent } from 'matrix-js-sdk'; +import { ThreadEvent, type Thread } from 'matrix-js-sdk/lib/models/thread'; +import type { Room } from 'matrix-js-sdk/lib/models/room'; +import { allRoomsAtom } from '../../../state/room-list/roomList'; +import { roomToParentsAtom } from '../../../state/room/roomToParents'; +import { makeRecentThreadsAtom } from '../../recent-threads/recentThreads'; +import { + clearThreadSummarySharedState, + storeThreadSummaryInState, +} from '../../threads/threadSummaryState'; +import { MINDROOM_THREAD_TAGS_EVENT } from '../../threads/threadTags'; +import { useCrossRoomThreadIndex } from '../useCrossRoomThreadIndex'; +import { crossRoomThreadIndexAtom, getCrossRoomThreadIndexKey } from '../crossRoomThreadIndex'; + +const { matrixClientMock, activeSessionMock } = vi.hoisted(() => ({ + matrixClientMock: vi.fn(), + activeSessionMock: vi.fn(), +})); + +vi.mock('../../../hooks/useMatrixClient', () => ({ + useMatrixClient: matrixClientMock, +})); + +vi.mock('../../../hooks/useSessionStore', () => ({ + useActiveSession: activeSessionMock, +})); + +type ListenerMap = Map void>>; + +const addListener = (listeners: ListenerMap, event: unknown, handler: (...args: any[]) => void) => { + const eventListeners = listeners.get(event) ?? new Set(); + eventListeners.add(handler); + listeners.set(event, eventListeners); +}; + +const removeListener = ( + listeners: ListenerMap, + event: unknown, + handler: (...args: any[]) => void +) => { + listeners.get(event)?.delete(handler); +}; + +const emit = (listeners: ListenerMap, event: unknown, ...args: unknown[]) => { + listeners.get(event)?.forEach((handler) => handler(...args)); +}; + +const makeEvent = ({ + id, + body = 'Root', + content, + eventType = 'm.room.message', + sender = '@alice:example.org', + threadRootId, + relation, + roomId, + replacingEvent, + status = null, +}: { + id: string; + body?: string; + content?: Record; + eventType?: string; + sender?: string; + threadRootId?: string; + relation?: { rel_type: string; event_id: string }; + roomId?: string; + replacingEvent?: () => MatrixEvent | undefined; + status?: unknown; +}): MatrixEvent => { + const eventContent = content ?? { msgtype: 'm.text', body }; + const eventRelation = + relation ?? + (threadRootId ? { rel_type: RelationType.Thread, event_id: threadRootId } : undefined); + const event = { + status, + threadRootId, + getId: () => id, + getRoomId: () => roomId, + getSender: () => sender, + getTs: () => 100, + getType: () => eventType, + getContent: () => eventContent, + getUnsigned: () => ({}), + getRelation: () => eventRelation, + isRelation: (relationType: string) => eventRelation?.rel_type === relationType, + isSending: () => event.status !== undefined && event.status !== null, + isRedacted: () => false, + isRedaction: () => false, + replacingEvent: () => replacingEvent?.(), + } as MatrixEvent & { status: unknown }; + return event; +}; + +const makeTimelineSet = (events: MatrixEvent[]) => ({ + relations: { + getChildEventsForEvent: () => undefined, + }, + getLiveTimeline: () => ({ + getEvents: () => events, + getNeighbouringTimeline: () => undefined, + }), +}); + +const makeThread = (root: MatrixEvent, replies: MatrixEvent[] = []): Thread => + ({ + rootEvent: root, + events: replies, + timeline: replies, + length: replies.length, + lastReply: () => null, + getUnfilteredTimelineSet: () => makeTimelineSet([root, ...replies]), + } as Thread); + +const makeRoom = ( + roomId = '!room:example.org', + { + rootId = '$root', + rootBody = 'Root', + rootContent, + rootReplacingEvent, + replies = [], + }: { + rootId?: string; + rootBody?: string; + rootContent?: Record; + rootReplacingEvent?: () => MatrixEvent | undefined; + replies?: MatrixEvent[]; + } = {} +) => { + const root = makeEvent({ + id: rootId, + body: rootBody, + content: rootContent, + roomId, + replacingEvent: rootReplacingEvent, + }); + const thread = makeThread(root, replies); + const eventMap = new Map([ + [rootId, root], + ...replies.map((reply): [string, MatrixEvent] => [reply.getId() ?? '', reply]), + ]); + const listeners: ListenerMap = new Map(); + const room = { + roomId, + name: 'Room', + getMyMembership: vi.fn(() => 'join'), + isSpaceRoom: vi.fn(() => false), + getLastActiveTimestamp: vi.fn(() => 100), + getThreads: vi.fn(() => [thread]), + getThread: vi.fn((threadRootId: string) => (threadRootId === rootId ? thread : null)), + findEventById: vi.fn((eventId: string) => eventMap.get(eventId)), + getMember: vi.fn(() => undefined), + getEventReadUpTo: vi.fn(() => undefined), + getLiveTimeline: vi.fn(() => ({ + getState: () => ({ + getStateEvents: () => [], + }), + })), + getUnfilteredTimelineSet: vi.fn(() => makeTimelineSet([root, ...replies])), + on: vi.fn((event: unknown, handler: (...args: any[]) => void) => + addListener(listeners, event, handler) + ), + removeListener: vi.fn((event: unknown, handler: (...args: any[]) => void) => + removeListener(listeners, event, handler) + ), + emit: (event: unknown, ...args: unknown[]) => emit(listeners, event, ...args), + } as unknown as Room & { emit: (event: unknown, ...args: unknown[]) => void }; + + return { room, root, thread }; +}; + +const makeRoomWithThreads = (roomId: string, threadRoots: Array<{ id: string; body: string }>) => { + const roots = threadRoots.map(({ id, body }) => makeEvent({ id, body, roomId })); + const threads = roots.map((root) => makeThread(root)); + const eventMap = new Map( + roots.map((root): [string, MatrixEvent] => [root.getId() ?? '', root]) + ); + const listeners: ListenerMap = new Map(); + const room = { + roomId, + name: 'Room', + getMyMembership: vi.fn(() => 'join'), + isSpaceRoom: vi.fn(() => false), + getLastActiveTimestamp: vi.fn(() => 100), + getThreads: vi.fn(() => threads), + getThread: vi.fn((threadRootId: string) => { + const index = roots.findIndex((root) => root.getId() === threadRootId); + return index >= 0 ? threads[index] : null; + }), + findEventById: vi.fn((eventId: string) => eventMap.get(eventId)), + getMember: vi.fn(() => undefined), + getEventReadUpTo: vi.fn(() => undefined), + getLiveTimeline: vi.fn(() => ({ + getState: () => ({ + getStateEvents: () => [], + }), + })), + getUnfilteredTimelineSet: vi.fn(() => makeTimelineSet(roots)), + on: vi.fn((event: unknown, handler: (...args: any[]) => void) => + addListener(listeners, event, handler) + ), + removeListener: vi.fn((event: unknown, handler: (...args: any[]) => void) => + removeListener(listeners, event, handler) + ), + emit: (event: unknown, ...args: unknown[]) => emit(listeners, event, ...args), + } as unknown as Room & { emit: (event: unknown, ...args: unknown[]) => void }; + + return { room, roots, threads }; +}; + +const makeRoomWithThreadReplies = (roomId: string, threadCount: number) => { + const roots = Array.from({ length: threadCount }, (_, index) => + makeEvent({ id: `$root-${index}`, body: `Root ${index}`, roomId }) + ); + const replies = roots.map((root, index) => + makeEvent({ + id: `$reply-${index}`, + body: `Reply ${index}`, + threadRootId: root.getId(), + roomId, + }) + ); + const threads = roots.map((root, index) => makeThread(root, [replies[index]])); + const threadMap = new Map( + roots.map((root, index): [string, Thread] => [root.getId() ?? '', threads[index]]) + ); + const eventMap = new Map([ + ...roots.map((root): [string, MatrixEvent] => [root.getId() ?? '', root]), + ...replies.map((reply): [string, MatrixEvent] => [reply.getId() ?? '', reply]), + ]); + const listeners: ListenerMap = new Map(); + const room = { + roomId, + name: 'Room', + getMyMembership: vi.fn(() => 'join'), + isSpaceRoom: vi.fn(() => false), + getLastActiveTimestamp: vi.fn(() => 100), + getThreads: vi.fn(() => threads), + getThread: vi.fn((threadRootId: string) => threadMap.get(threadRootId) ?? null), + findEventById: vi.fn((eventId: string) => eventMap.get(eventId)), + getMember: vi.fn(() => undefined), + getEventReadUpTo: vi.fn(() => undefined), + getLiveTimeline: vi.fn(() => ({ + getState: () => ({ + getStateEvents: () => [], + }), + })), + getUnfilteredTimelineSet: vi.fn(() => makeTimelineSet([...roots, ...replies])), + on: vi.fn((event: unknown, handler: (...args: any[]) => void) => + addListener(listeners, event, handler) + ), + removeListener: vi.fn((event: unknown, handler: (...args: any[]) => void) => + removeListener(listeners, event, handler) + ), + emit: (event: unknown, ...args: unknown[]) => emit(listeners, event, ...args), + } as unknown as Room & { emit: (event: unknown, ...args: unknown[]) => void }; + + return { room, roots, replies, threads }; +}; + +const makeClient = (rooms: Room | Room[]) => { + const roomList = Array.isArray(rooms) ? rooms : [rooms]; + const roomMap = new Map(roomList.map((room): [string, Room] => [room.roomId, room])); + const listeners: ListenerMap = new Map(); + return { + getUserId: vi.fn(() => '@me:example.org'), + getRoom: vi.fn((roomId: string) => roomMap.get(roomId) ?? null), + on: vi.fn((event: unknown, handler: (...args: any[]) => void) => + addListener(listeners, event, handler) + ), + removeListener: vi.fn((event: unknown, handler: (...args: any[]) => void) => + removeListener(listeners, event, handler) + ), + emit: (event: unknown, ...args: unknown[]) => emit(listeners, event, ...args), + }; +}; + +function HookProbe() { + useCrossRoomThreadIndex(); + return null; +} + +const flushScheduledWork = async () => { + await act(async () => { + vi.runOnlyPendingTimers(); + await Promise.resolve(); + await Promise.resolve(); + }); +}; + +describe('useCrossRoomThreadIndex', () => { + beforeEach(() => { + vi.useFakeTimers(); + localStorage.clear(); + clearThreadSummarySharedState(); + activeSessionMock.mockReturnValue({ + sessionId: 'session', + userId: '@me:example.org', + baseUrl: 'https://example.org', + deviceId: 'DEVICE', + accessToken: 'token', + lastUsedAt: 1, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + localStorage.clear(); + clearThreadSummarySharedState(); + }); + + it('bootstraps loaded joined room threads lazily and registers listeners while mounted', async () => { + const { room } = makeRoom(); + const mx = makeClient(room); + matrixClientMock.mockReturnValue(mx); + const store = createStore(); + store.set(allRoomsAtom, { type: 'INITIALIZE', rooms: [room.roomId] }); + store.set(roomToParentsAtom, { + type: 'INITIALIZE', + roomToParents: new Map([[room.roomId, new Set(['!space:example.org'])]]), + }); + + let renderer: ReturnType | undefined; + await act(async () => { + renderer = create(React.createElement(Provider, { store }, React.createElement(HookProbe))); + }); + + await flushScheduledWork(); + + const snapshot = store.get(crossRoomThreadIndexAtom); + expect(snapshot.bootstrapped).toBe(true); + expect(Array.from(snapshot.entries.values())[0]?.parentSpaceIds).toEqual([ + '!space:example.org', + ]); + expect(room.on).toHaveBeenCalledWith(RoomEvent.Timeline, expect.any(Function)); + expect(room.on).toHaveBeenCalledWith(ThreadEvent.NewReply, expect.any(Function)); + expect(mx.on).toHaveBeenCalledWith(RoomEvent.MyMembership, expect.any(Function)); + expect(mx.on).toHaveBeenCalledWith(MatrixEventEvent.Decrypted, expect.any(Function)); + + await act(async () => { + renderer?.unmount(); + }); + expect(room.removeListener).toHaveBeenCalledWith(RoomEvent.Timeline, expect.any(Function)); + expect(mx.removeListener).toHaveBeenCalledWith(RoomStateEvent.Events, expect.any(Function)); + }); + + it('keeps bootstrapped false for dirty thread flushes before lazy bootstrap drains', async () => { + const { room, thread } = makeRoom(); + const mx = makeClient(room); + matrixClientMock.mockReturnValue(mx); + const store = createStore(); + store.set(allRoomsAtom, { type: 'INITIALIZE', rooms: [room.roomId] }); + + await act(async () => { + create(React.createElement(Provider, { store }, React.createElement(HookProbe))); + }); + + await act(async () => { + room.emit(ThreadEvent.Update, thread); + await Promise.resolve(); + }); + + const dirtySnapshot = store.get(crossRoomThreadIndexAtom); + expect(dirtySnapshot.entries.size).toBe(1); + expect(dirtySnapshot.bootstrapped).toBe(false); + + await flushScheduledWork(); + + expect(store.get(crossRoomThreadIndexAtom).bootstrapped).toBe(true); + }); + + it('does not index plain live room messages as cross-room threads', async () => { + const { room } = makeRoom(); + const mx = makeClient(room); + matrixClientMock.mockReturnValue(mx); + const store = createStore(); + store.set(allRoomsAtom, { type: 'INITIALIZE', rooms: [room.roomId] }); + + await act(async () => { + create(React.createElement(Provider, { store }, React.createElement(HookProbe))); + }); + await flushScheduledWork(); + + await act(async () => { + room.emit(RoomEvent.Timeline, makeEvent({ id: '$plain', body: 'Plain room message' }), room); + await Promise.resolve(); + }); + + expect( + store + .get(crossRoomThreadIndexAtom) + .entries.has(getCrossRoomThreadIndexKey(room.roomId, '$plain')) + ).toBe(false); + }); + + it('refreshes a tracked encrypted thread root when it decrypts', async () => { + const rootContent = { msgtype: 'm.text', body: '[encrypted]' }; + const { room, root } = makeRoom('!room:example.org', { rootContent }); + const mx = makeClient(room); + matrixClientMock.mockReturnValue(mx); + const store = createStore(); + store.set(allRoomsAtom, { type: 'INITIALIZE', rooms: [room.roomId] }); + + await act(async () => { + create(React.createElement(Provider, { store }, React.createElement(HookProbe))); + }); + await flushScheduledWork(); + + const key = getCrossRoomThreadIndexKey(room.roomId, '$root'); + expect(store.get(crossRoomThreadIndexAtom).entries.get(key)?.rootPreviewText).toBe( + '[encrypted]' + ); + + rootContent.body = 'Decrypted needle root'; + await act(async () => { + mx.emit(MatrixEventEvent.Decrypted, root); + await Promise.resolve(); + }); + await flushScheduledWork(); + + const entry = store.get(crossRoomThreadIndexAtom).entries.get(key); + expect(entry?.rootPreviewText).toBe('Decrypted needle root'); + expect(entry?.summaryText).toBe('Decrypted needle root'); + expect(entry?.searchableText).toContain('decrypted needle'); + }); + + it('refreshes a tracked entry when one of its visible replies decrypts', async () => { + const replyContent = { + msgtype: 'm.text', + body: 'encrypted reply', + 'm.mentions': { user_ids: [] }, + }; + const reply = makeEvent({ + id: '$reply', + content: replyContent, + sender: '@alice:example.org', + threadRootId: '$root', + roomId: '!room:example.org', + }); + const { room } = makeRoom('!room:example.org', { replies: [reply] }); + const mx = makeClient(room); + matrixClientMock.mockReturnValue(mx); + const store = createStore(); + store.set(allRoomsAtom, { type: 'INITIALIZE', rooms: [room.roomId] }); + + await act(async () => { + create(React.createElement(Provider, { store }, React.createElement(HookProbe))); + }); + await flushScheduledWork(); + + const key = getCrossRoomThreadIndexKey(room.roomId, '$root'); + expect(store.get(crossRoomThreadIndexAtom).entries.get(key)?.isInvolved).toBe(false); + + replyContent['m.mentions'] = { user_ids: ['@me:example.org'] }; + await act(async () => { + mx.emit(MatrixEventEvent.Decrypted, reply); + await Promise.resolve(); + }); + await flushScheduledWork(); + + expect(store.get(crossRoomThreadIndexAtom).entries.get(key)?.isInvolved).toBe(true); + }); + + it('uses the reverse event index when decrypted visible replies refresh tracked entries', async () => { + const { room, replies } = makeRoomWithThreadReplies('!room:example.org', 1000); + const mx = makeClient(room); + matrixClientMock.mockReturnValue(mx); + const store = createStore(); + store.set(allRoomsAtom, { type: 'INITIALIZE', rooms: [room.roomId] }); + + await act(async () => { + create(React.createElement(Provider, { store }, React.createElement(HookProbe))); + }); + await flushScheduledWork(); + expect(store.get(crossRoomThreadIndexAtom).entries.size).toBe(1000); + + const targetReply = replies[500]!; + room.getThread.mockClear(); + + await act(async () => { + mx.emit(MatrixEventEvent.Decrypted, targetReply); + await Promise.resolve(); + }); + await flushScheduledWork(); + + expect(room.getThread.mock.calls.length).toBeLessThanOrEqual(20); + }); + + it('refreshes an edited thread root from a filtered timeline replace event', async () => { + const rootContent = { msgtype: 'm.text', body: 'Original root' }; + let latestEdit: MatrixEvent | undefined; + const { room } = makeRoom('!room:example.org', { + rootContent, + rootReplacingEvent: () => latestEdit, + }); + const mx = makeClient(room); + matrixClientMock.mockReturnValue(mx); + const store = createStore(); + store.set(allRoomsAtom, { type: 'INITIALIZE', rooms: [room.roomId] }); + + await act(async () => { + create(React.createElement(Provider, { store }, React.createElement(HookProbe))); + }); + await flushScheduledWork(); + + const key = getCrossRoomThreadIndexKey(room.roomId, '$root'); + expect(store.get(crossRoomThreadIndexAtom).entries.get(key)?.rootPreviewText).toBe( + 'Original root' + ); + + latestEdit = makeEvent({ + id: '$edit', + content: { + msgtype: 'm.text', + body: '* Original root', + 'm.new_content': { msgtype: 'm.text', body: 'Edited root needle' }, + }, + relation: { rel_type: RelationType.Replace, event_id: '$root' }, + roomId: room.roomId, + }); + await act(async () => { + room.emit(RoomEvent.Timeline, latestEdit, room); + await Promise.resolve(); + }); + await flushScheduledWork(); + + expect(store.get(crossRoomThreadIndexAtom).entries.get(key)?.rootPreviewText).toBe( + 'Edited root needle' + ); + }); + + it('refreshes an edited thread root from a decrypted encrypted replace event', async () => { + let latestEdit: MatrixEvent | undefined; + const { room } = makeRoom('!room:example.org', { + rootBody: 'Original encrypted-edit root', + rootReplacingEvent: () => latestEdit, + }); + const mx = makeClient(room); + matrixClientMock.mockReturnValue(mx); + const store = createStore(); + store.set(allRoomsAtom, { type: 'INITIALIZE', rooms: [room.roomId] }); + + await act(async () => { + create(React.createElement(Provider, { store }, React.createElement(HookProbe))); + }); + await flushScheduledWork(); + + const key = getCrossRoomThreadIndexKey(room.roomId, '$root'); + expect(store.get(crossRoomThreadIndexAtom).entries.get(key)?.rootPreviewText).toBe( + 'Original encrypted-edit root' + ); + + latestEdit = makeEvent({ + id: '$encrypted-edit', + eventType: 'm.room.encrypted', + content: { + msgtype: 'm.text', + body: '* Original encrypted-edit root', + 'm.new_content': { msgtype: 'm.text', body: 'edited encrypted needle' }, + 'm.relates_to': { rel_type: RelationType.Replace, event_id: '$root' }, + }, + roomId: room.roomId, + }); + await act(async () => { + mx.emit(MatrixEventEvent.Decrypted, latestEdit); + await Promise.resolve(); + }); + await flushScheduledWork(); + + const entry = store.get(crossRoomThreadIndexAtom).entries.get(key); + expect(entry?.rootPreviewText).toBe('edited encrypted needle'); + expect(entry?.searchableText).toContain('edited encrypted needle'); + }); + + it('refreshes a visible reply when its encrypted replace event decrypts', async () => { + const replyContent: Record = { + msgtype: 'm.notice', + body: 'Original reply summary', + 'io.mindroom.thread_summary': true, + 'm.mentions': { user_ids: [] }, + }; + const reply = makeEvent({ + id: '$reply', + content: replyContent, + sender: '@alice:example.org', + threadRootId: '$root', + roomId: '!room:example.org', + }); + const { room } = makeRoom('!room:example.org', { replies: [reply] }); + const mx = makeClient(room); + matrixClientMock.mockReturnValue(mx); + const store = createStore(); + store.set(allRoomsAtom, { type: 'INITIALIZE', rooms: [room.roomId] }); + + await act(async () => { + create(React.createElement(Provider, { store }, React.createElement(HookProbe))); + }); + await flushScheduledWork(); + + const key = getCrossRoomThreadIndexKey(room.roomId, '$root'); + expect(store.get(crossRoomThreadIndexAtom).entries.get(key)?.isInvolved).toBe(false); + expect(store.get(crossRoomThreadIndexAtom).entries.get(key)?.searchableText).toContain( + 'original reply summary' + ); + + const editedReplyContent = { + msgtype: 'm.notice', + body: 'Edited encrypted reply needle', + 'io.mindroom.thread_summary': true, + 'm.mentions': { user_ids: ['@me:example.org'] }, + }; + const encryptedReplyEdit = makeEvent({ + id: '$encrypted-reply-edit', + eventType: 'm.room.encrypted', + content: { + msgtype: 'm.notice', + body: '* Original reply summary', + 'm.new_content': editedReplyContent, + 'm.relates_to': { rel_type: RelationType.Replace, event_id: '$reply' }, + }, + roomId: room.roomId, + }); + Object.assign(replyContent, editedReplyContent); + + await act(async () => { + mx.emit(MatrixEventEvent.Decrypted, encryptedReplyEdit); + await Promise.resolve(); + }); + await flushScheduledWork(); + + const entry = store.get(crossRoomThreadIndexAtom).entries.get(key); + expect(entry?.isInvolved).toBe(true); + expect(entry?.searchableText).toContain('edited encrypted reply needle'); + }); + + it('refreshes involvement when a visible reply edit adds and removes a direct mention', async () => { + let latestEdit: MatrixEvent | undefined; + const reply = makeEvent({ + id: '$reply', + content: { + msgtype: 'm.text', + body: 'Original reply', + 'm.mentions': { user_ids: [] }, + }, + sender: '@alice:example.org', + threadRootId: '$root', + roomId: '!room:example.org', + replacingEvent: () => latestEdit, + }); + const { room } = makeRoom('!room:example.org', { replies: [reply] }); + const mx = makeClient(room); + matrixClientMock.mockReturnValue(mx); + const store = createStore(); + store.set(allRoomsAtom, { type: 'INITIALIZE', rooms: [room.roomId] }); + + await act(async () => { + create(React.createElement(Provider, { store }, React.createElement(HookProbe))); + }); + await flushScheduledWork(); + + const key = getCrossRoomThreadIndexKey(room.roomId, '$root'); + expect(store.get(crossRoomThreadIndexAtom).entries.get(key)?.isInvolved).toBe(false); + + latestEdit = makeEvent({ + id: '$edit-add-mention', + content: { + msgtype: 'm.text', + body: '* Original reply', + 'm.mentions': { user_ids: [] }, + 'm.new_content': { + msgtype: 'm.text', + body: 'Edited reply @me', + 'm.mentions': { user_ids: ['@me:example.org'] }, + }, + }, + relation: { rel_type: RelationType.Replace, event_id: '$reply' }, + roomId: room.roomId, + }); + await act(async () => { + room.emit(RoomEvent.Timeline, latestEdit, room); + await Promise.resolve(); + }); + await flushScheduledWork(); + + expect(store.get(crossRoomThreadIndexAtom).entries.get(key)?.isInvolved).toBe(true); + + latestEdit = makeEvent({ + id: '$edit-remove-mention', + content: { + msgtype: 'm.text', + body: '* Edited reply @me', + 'm.new_content': { + msgtype: 'm.text', + body: 'Edited reply without mention', + }, + }, + relation: { rel_type: RelationType.Replace, event_id: '$reply' }, + roomId: room.roomId, + }); + await act(async () => { + room.emit(RoomEvent.Timeline, latestEdit, room); + await Promise.resolve(); + }); + await flushScheduledWork(); + + expect(store.get(crossRoomThreadIndexAtom).entries.get(key)?.isInvolved).toBe(false); + }); + + it('refreshes indexed entries when thread summary state changes after bootstrap', async () => { + const { room } = makeRoom(); + const mx = makeClient(room); + matrixClientMock.mockReturnValue(mx); + const store = createStore(); + store.set(allRoomsAtom, { type: 'INITIALIZE', rooms: [room.roomId] }); + + await act(async () => { + create(React.createElement(Provider, { store }, React.createElement(HookProbe))); + }); + await flushScheduledWork(); + + const key = getCrossRoomThreadIndexKey(room.roomId, '$root'); + expect(store.get(crossRoomThreadIndexAtom).entries.get(key)?.searchableText).not.toContain( + 'cached-only needle' + ); + + await act(async () => { + storeThreadSummaryInState('session', room.roomId, '$root', { + summaryText: 'Cached-only needle summary', + generatedTs: 200, + messageCount: 2, + }); + await Promise.resolve(); + }); + await flushScheduledWork(); + + const entry = store.get(crossRoomThreadIndexAtom).entries.get(key); + expect(entry?.summaryText).toBe('Cached-only needle summary'); + expect(entry?.searchableText).toContain('cached-only needle'); + }); + + it('removes deleted threads from the index instead of re-upserting stale rows', async () => { + const { room, thread } = makeRoom(); + const mx = makeClient(room); + matrixClientMock.mockReturnValue(mx); + const store = createStore(); + store.set(allRoomsAtom, { type: 'INITIALIZE', rooms: [room.roomId] }); + + await act(async () => { + create(React.createElement(Provider, { store }, React.createElement(HookProbe))); + }); + await flushScheduledWork(); + expect(store.get(crossRoomThreadIndexAtom).entries.size).toBe(1); + + await act(async () => { + room.emit(ThreadEvent.Delete, thread); + await Promise.resolve(); + }); + + expect(store.get(crossRoomThreadIndexAtom).entries.size).toBe(0); + }); + + it('removes an existing entry when a dirty thread can no longer be rebuilt', async () => { + const { room, thread } = makeRoom(); + const mx = makeClient(room); + matrixClientMock.mockReturnValue(mx); + const store = createStore(); + store.set(allRoomsAtom, { type: 'INITIALIZE', rooms: [room.roomId] }); + + await act(async () => { + create(React.createElement(Provider, { store }, React.createElement(HookProbe))); + }); + await flushScheduledWork(); + expect(store.get(crossRoomThreadIndexAtom).entries.size).toBe(1); + + room.getThread.mockReturnValue(null); + room.findEventById.mockReturnValue(undefined); + + await act(async () => { + room.emit(ThreadEvent.Update, thread); + await Promise.resolve(); + }); + await flushScheduledWork(); + + expect(store.get(crossRoomThreadIndexAtom).entries.size).toBe(0); + }); + + it('drops queued dirty writes after unmount', async () => { + const { room, thread } = makeRoom(); + const mx = makeClient(room); + matrixClientMock.mockReturnValue(mx); + const store = createStore(); + store.set(allRoomsAtom, { type: 'INITIALIZE', rooms: [room.roomId] }); + + let renderer: ReturnType | undefined; + await act(async () => { + renderer = create(React.createElement(Provider, { store }, React.createElement(HookProbe))); + }); + await flushScheduledWork(); + + const before = store.get(crossRoomThreadIndexAtom); + await act(async () => { + room.emit(ThreadEvent.Update, thread); + renderer?.unmount(); + await Promise.resolve(); + }); + + expect(store.get(crossRoomThreadIndexAtom)).toBe(before); + }); + + it('drops queued dirty writes when the Matrix client user changes before the microtask flush', async () => { + const { room, thread } = makeRoom(); + const mx = makeClient(room); + matrixClientMock.mockReturnValue(mx); + const store = createStore(); + store.set(allRoomsAtom, { type: 'INITIALIZE', rooms: [room.roomId] }); + + await act(async () => { + create(React.createElement(Provider, { store }, React.createElement(HookProbe))); + }); + await flushScheduledWork(); + + const before = store.get(crossRoomThreadIndexAtom); + await act(async () => { + room.emit(ThreadEvent.Update, thread); + mx.getUserId.mockReturnValue('@other:example.org'); + await Promise.resolve(); + }); + + expect(store.get(crossRoomThreadIndexAtom)).toBe(before); + }); + + it('clears the previous account index before the next account bootstrap runs', async () => { + const { room: roomA } = makeRoom('!room-a:example.org'); + const mxA = makeClient(roomA); + matrixClientMock.mockReturnValue(mxA); + const store = createStore(); + store.set(allRoomsAtom, { type: 'INITIALIZE', rooms: [roomA.roomId] }); + + let renderer: ReturnType | undefined; + await act(async () => { + renderer = create(React.createElement(Provider, { store }, React.createElement(HookProbe))); + }); + await flushScheduledWork(); + expect(store.get(crossRoomThreadIndexAtom).entries.size).toBe(1); + + await act(async () => { + renderer?.unmount(); + }); + + const { room: roomB } = makeRoom('!room-b:example.org'); + const mxB = makeClient(roomB); + mxB.getUserId.mockReturnValue('@other:example.org'); + matrixClientMock.mockReturnValue(mxB); + activeSessionMock.mockReturnValue({ + sessionId: 'session-b', + userId: '@other:example.org', + baseUrl: 'https://example.org', + deviceId: 'DEVICE-B', + accessToken: 'token-b', + lastUsedAt: 2, + }); + store.set(allRoomsAtom, { type: 'INITIALIZE', rooms: [roomB.roomId] }); + + await act(async () => { + renderer = create(React.createElement(Provider, { store }, React.createElement(HookProbe))); + await Promise.resolve(); + }); + + const beforeBootstrap = store.get(crossRoomThreadIndexAtom); + expect(beforeBootstrap.entries.size).toBe(0); + expect(beforeBootstrap.bootstrapped).toBe(false); + }); + + it('does not re-register room listeners when recent threads change after bootstrap', async () => { + const { room } = makeRoom(); + const mx = makeClient(room); + matrixClientMock.mockReturnValue(mx); + const store = createStore(); + store.set(allRoomsAtom, { type: 'INITIALIZE', rooms: [room.roomId] }); + + await act(async () => { + create(React.createElement(Provider, { store }, React.createElement(HookProbe))); + }); + await flushScheduledWork(); + room.on.mockClear(); + room.removeListener.mockClear(); + + await act(async () => { + store.set(makeRecentThreadsAtom('@me:example.org'), { + type: 'BUMP', + roomId: room.roomId, + threadId: '$root', + openedAt: 200, + }); + await Promise.resolve(); + }); + + expect(room.removeListener).not.toHaveBeenCalled(); + expect(room.on).not.toHaveBeenCalled(); + }); + + it('only attaches listeners for added rooms when the joined room set grows', async () => { + const roomFixtures = [ + makeRoom('!room-1:example.org'), + makeRoom('!room-2:example.org'), + makeRoom('!room-3:example.org'), + makeRoom('!room-4:example.org'), + ]; + const [room1, room2, room3, room4] = roomFixtures.map((fixture) => fixture.room); + const mx = makeClient([room1, room2, room3, room4]); + matrixClientMock.mockReturnValue(mx); + const store = createStore(); + store.set(allRoomsAtom, { + type: 'INITIALIZE', + rooms: [room1.roomId, room2.roomId, room3.roomId], + }); + + await act(async () => { + create(React.createElement(Provider, { store }, React.createElement(HookProbe))); + }); + await flushScheduledWork(); + [room1, room2, room3, room4].forEach((room) => { + room.on.mockClear(); + room.removeListener.mockClear(); + }); + + await act(async () => { + store.set(allRoomsAtom, { type: 'PUT', roomId: room4.roomId }); + await Promise.resolve(); + }); + + expect(room1.removeListener).not.toHaveBeenCalled(); + expect(room2.removeListener).not.toHaveBeenCalled(); + expect(room3.removeListener).not.toHaveBeenCalled(); + expect(room1.on).not.toHaveBeenCalled(); + expect(room2.on).not.toHaveBeenCalled(); + expect(room3.on).not.toHaveBeenCalled(); + expect(room4.on).toHaveBeenCalledWith(RoomEvent.Timeline, expect.any(Function)); + }); + + it('indexes existing threads in a newly joined room without waiting for a thread event', async () => { + const { room: roomA } = makeRoom('!room-a:example.org'); + const { room: roomB } = makeRoom('!room-b:example.org'); + const { room: roomC } = makeRoomWithThreads('!room-c:example.org', [ + { id: '$c-root-1', body: 'C root one' }, + { id: '$c-root-2', body: 'C root two' }, + ]); + const mx = makeClient([roomA, roomB, roomC]); + matrixClientMock.mockReturnValue(mx); + const store = createStore(); + store.set(allRoomsAtom, { + type: 'INITIALIZE', + rooms: [roomA.roomId, roomB.roomId], + }); + + await act(async () => { + create(React.createElement(Provider, { store }, React.createElement(HookProbe))); + }); + await flushScheduledWork(); + expect(store.get(crossRoomThreadIndexAtom).entries.size).toBe(2); + + await act(async () => { + store.set(allRoomsAtom, { type: 'PUT', roomId: roomC.roomId }); + await Promise.resolve(); + }); + await flushScheduledWork(); + + const entries = store.get(crossRoomThreadIndexAtom).entries; + expect(entries.has(getCrossRoomThreadIndexKey(roomC.roomId, '$c-root-1'))).toBe(true); + expect(entries.has(getCrossRoomThreadIndexKey(roomC.roomId, '$c-root-2'))).toBe(true); + }); + + it('skips pending local-echo thread roots and indexes the later server event id', async () => { + const roomId = '!room:example.org'; + const pendingRoot = makeEvent({ + id: `~${roomId}:txn-1`, + body: 'Pending local root', + roomId, + status: 'sending', + }); + const pendingThread = makeThread(pendingRoot); + const { room } = makeRoom(roomId, { rootId: pendingRoot.getId() ?? '' }); + room.getThreads.mockReturnValue([pendingThread]); + room.getThread.mockImplementation((threadRootId: string) => + threadRootId === pendingRoot.getId() ? pendingThread : null + ); + room.findEventById.mockImplementation((eventId: string) => + eventId === pendingRoot.getId() ? pendingRoot : undefined + ); + + const mx = makeClient(room); + matrixClientMock.mockReturnValue(mx); + const store = createStore(); + store.set(allRoomsAtom, { type: 'INITIALIZE', rooms: [room.roomId] }); + + await act(async () => { + create(React.createElement(Provider, { store }, React.createElement(HookProbe))); + }); + await flushScheduledWork(); + + expect(store.get(crossRoomThreadIndexAtom).entries.size).toBe(0); + + await act(async () => { + room.emit(ThreadEvent.New, pendingThread); + await Promise.resolve(); + }); + await flushScheduledWork(); + + expect(store.get(crossRoomThreadIndexAtom).entries.size).toBe(0); + + const serverRoot = makeEvent({ + id: '$server-root', + body: 'Server root', + roomId, + status: null, + }); + const serverThread = makeThread(serverRoot); + room.getThreads.mockReturnValue([serverThread]); + room.getThread.mockImplementation((threadRootId: string) => + threadRootId === '$server-root' ? serverThread : null + ); + room.findEventById.mockImplementation((eventId: string) => + eventId === '$server-root' ? serverRoot : undefined + ); + + await act(async () => { + room.emit(ThreadEvent.New, serverThread); + await Promise.resolve(); + }); + await flushScheduledWork(); + + const entries = store.get(crossRoomThreadIndexAtom).entries; + expect(entries.has(getCrossRoomThreadIndexKey(room.roomId, pendingRoot.getId() ?? ''))).toBe( + false + ); + expect(entries.has(getCrossRoomThreadIndexKey(room.roomId, '$server-root'))).toBe(true); + }); + + it('removes a room from the index when membership leaves', async () => { + const { room } = makeRoom(); + const mx = makeClient(room); + matrixClientMock.mockReturnValue(mx); + const store = createStore(); + store.set(allRoomsAtom, { type: 'INITIALIZE', rooms: [room.roomId] }); + + await act(async () => { + create(React.createElement(Provider, { store }, React.createElement(HookProbe))); + }); + await flushScheduledWork(); + expect(store.get(crossRoomThreadIndexAtom).entries.size).toBe(1); + + room.getMyMembership.mockReturnValue('leave'); + await act(async () => { + mx.emit(RoomEvent.MyMembership, room); + await Promise.resolve(); + }); + + expect(store.get(crossRoomThreadIndexAtom).entries.size).toBe(0); + }); + + it('removes stale entries when a room leaves the joined room set', async () => { + const { room } = makeRoom(); + const mx = makeClient(room); + matrixClientMock.mockReturnValue(mx); + const store = createStore(); + store.set(allRoomsAtom, { type: 'INITIALIZE', rooms: [room.roomId] }); + + await act(async () => { + create(React.createElement(Provider, { store }, React.createElement(HookProbe))); + }); + await flushScheduledWork(); + expect(store.get(crossRoomThreadIndexAtom).entries.size).toBe(1); + + await act(async () => { + store.set(allRoomsAtom, { type: 'DELETE', roomId: room.roomId }); + await Promise.resolve(); + }); + + expect(store.get(crossRoomThreadIndexAtom).entries.size).toBe(0); + expect(room.removeListener).toHaveBeenCalledWith(RoomEvent.Timeline, expect.any(Function)); + }); + + it('does not resurrect a removed room from queued dirty or global state events', async () => { + const { room, thread } = makeRoom(); + const mx = makeClient(room); + matrixClientMock.mockReturnValue(mx); + const store = createStore(); + store.set(allRoomsAtom, { type: 'INITIALIZE', rooms: [room.roomId] }); + + await act(async () => { + create(React.createElement(Provider, { store }, React.createElement(HookProbe))); + }); + await flushScheduledWork(); + expect(store.get(crossRoomThreadIndexAtom).entries.size).toBe(1); + + await act(async () => { + room.emit(ThreadEvent.Update, thread); + store.set(allRoomsAtom, { type: 'DELETE', roomId: room.roomId }); + await Promise.resolve(); + }); + await flushScheduledWork(); + + expect(store.get(crossRoomThreadIndexAtom).entries.size).toBe(0); + + await act(async () => { + mx.emit( + RoomStateEvent.Events, + makeEvent({ + id: '$tags', + content: {}, + eventType: MINDROOM_THREAD_TAGS_EVENT, + roomId: room.roomId, + }), + { roomId: room.roomId } + ); + await Promise.resolve(); + }); + await flushScheduledWork(); + + expect(store.get(crossRoomThreadIndexAtom).entries.size).toBe(0); + }); +}); diff --git a/src/app/mindroom/cross-room-threads/crossRoomThreadFilterPipeline.ts b/src/app/mindroom/cross-room-threads/crossRoomThreadFilterPipeline.ts new file mode 100644 index 0000000000..2b0f845dd0 --- /dev/null +++ b/src/app/mindroom/cross-room-threads/crossRoomThreadFilterPipeline.ts @@ -0,0 +1,91 @@ +import type { CrossRoomThreadIndexEntry } from './crossRoomThreadIndex'; +import { normalizeThreadSearchText } from './crossRoomThreadIndex'; +import type { + CrossRoomThreadActivityWindow, + CrossRoomThreadFilters, +} from './crossRoomThreadFilters'; + +export type ApplyCrossRoomThreadFiltersOptions = { + now?: number; +}; + +const DAY_MS = 24 * 60 * 60 * 1000; + +const getActivityCutoff = ( + activityWindow: CrossRoomThreadActivityWindow, + now: number +): number | undefined => { + if (activityWindow === 'all') return undefined; + if (activityWindow === 'today') { + const today = new Date(now); + today.setHours(0, 0, 0, 0); + return today.getTime(); + } + if (activityWindow === '7d') return now - 7 * DAY_MS; + return now - 30 * DAY_MS; +}; + +const hasAny = (left: string[], right: string[]): boolean => + left.some((item) => right.includes(item)); + +const getEntryTags = (entry: CrossRoomThreadIndexEntry): string[] => + entry.tagSnapshot?.displayTags ?? entry.tags; + +const compareBooleanDesc = (left: boolean, right: boolean): number => + left === right ? 0 : left ? -1 : 1; + +export const compareCrossRoomThreadEntries = ( + left: CrossRoomThreadIndexEntry, + right: CrossRoomThreadIndexEntry +): number => { + const activityDiff = right.lastActivityTs - left.lastActivityTs; + if (activityDiff !== 0) return activityDiff; + + const unreadDiff = compareBooleanDesc(left.isUnread, right.isUnread); + if (unreadDiff !== 0) return unreadDiff; + + const attentionDiff = compareBooleanDesc(left.hasAttention, right.hasAttention); + if (attentionDiff !== 0) return attentionDiff; + + const resolvedDiff = compareBooleanDesc(!left.isResolved, !right.isResolved); + if (resolvedDiff !== 0) return resolvedDiff; + + const roomDiff = left.roomName.localeCompare(right.roomName); + if (roomDiff !== 0) return roomDiff; + + return left.threadRootId.localeCompare(right.threadRootId); +}; + +export const applyCrossRoomThreadFilters = ( + entries: Iterable, + filters: CrossRoomThreadFilters, + { now = Date.now() }: ApplyCrossRoomThreadFiltersOptions = {} +): CrossRoomThreadIndexEntry[] => { + const activityCutoff = getActivityCutoff(filters.activityWindow, now); + const normalizedQuery = normalizeThreadSearchText(filters.query); + const includeTags = filters.tag.include; + const excludeTags = filters.tag.exclude; + + return Array.from(entries) + .filter((entry) => filters.scope === 'all' || entry.isInvolved) + .filter((entry) => activityCutoff === undefined || entry.lastActivityTs >= activityCutoff) + .filter((entry) => filters.roomIds.length === 0 || filters.roomIds.includes(entry.roomId)) + .filter( + (entry) => filters.spaceIds.length === 0 || hasAny(entry.parentSpaceIds, filters.spaceIds) + ) + .filter((entry) => { + const entryTags = getEntryTags(entry); + return ( + includeTags.every((tag) => entryTags.includes(tag)) && + excludeTags.every((tag) => !entryTags.includes(tag)) + ); + }) + .filter((entry) => !filters.unreadOnly || entry.isUnread) + .filter((entry) => { + if (filters.resolved === 'all') return true; + return filters.resolved === 'resolved' ? entry.isResolved : !entry.isResolved; + }) + .filter((entry) => !filters.hasAttention || entry.hasAttention) + .filter((entry) => !normalizedQuery || entry.searchableText.includes(normalizedQuery)) + .sort(compareCrossRoomThreadEntries); +}; diff --git a/src/app/mindroom/cross-room-threads/crossRoomThreadFilters.ts b/src/app/mindroom/cross-room-threads/crossRoomThreadFilters.ts new file mode 100644 index 0000000000..e10b93a071 --- /dev/null +++ b/src/app/mindroom/cross-room-threads/crossRoomThreadFilters.ts @@ -0,0 +1,224 @@ +import { type WritableAtom, atom } from 'jotai'; +import { + atomWithLocalStorage, + getLocalStorageItem, + setLocalStorageItem, +} from '../../state/utils/atomWithLocalStorage'; +import { isRecord } from '../../utils/isRecord'; + +const CROSS_ROOM_THREAD_FILTERS = 'crossRoomThreadFilters'; +const CROSS_ROOM_THREAD_FILTERS_VERSION = 1; + +export type CrossRoomThreadScope = 'involved' | 'all'; +export type CrossRoomThreadResolvedFilter = 'all' | 'resolved' | 'unresolved'; +export type CrossRoomThreadActivityWindow = 'today' | '7d' | '30d' | 'all'; + +export type CrossRoomThreadFilters = { + v: typeof CROSS_ROOM_THREAD_FILTERS_VERSION; + query: string; + scope: CrossRoomThreadScope; + roomIds: string[]; + spaceIds: string[]; + tag: { + include: string[]; + exclude: string[]; + }; + unreadOnly: boolean; + resolved: CrossRoomThreadResolvedFilter; + hasAttention: boolean; + activityWindow: CrossRoomThreadActivityWindow; +}; + +export type CrossRoomThreadFiltersUpdate = + | CrossRoomThreadFilters + | ((current: CrossRoomThreadFilters) => CrossRoomThreadFilters); + +type PersistedCrossRoomThreadFilters = Omit; + +type CrossRoomThreadFiltersAtom = WritableAtom< + CrossRoomThreadFilters, + [CrossRoomThreadFiltersUpdate], + undefined +>; + +export const DEFAULT_CROSS_ROOM_THREAD_FILTERS: CrossRoomThreadFilters = { + v: CROSS_ROOM_THREAD_FILTERS_VERSION, + query: '', + scope: 'involved', + roomIds: [], + spaceIds: [], + tag: { + include: [], + exclude: [], + }, + unreadOnly: false, + resolved: 'all', + hasAttention: false, + activityWindow: '7d', +}; + +const DEFAULT_PERSISTED_CROSS_ROOM_THREAD_FILTERS: PersistedCrossRoomThreadFilters = { + v: CROSS_ROOM_THREAD_FILTERS_VERSION, + scope: DEFAULT_CROSS_ROOM_THREAD_FILTERS.scope, + roomIds: DEFAULT_CROSS_ROOM_THREAD_FILTERS.roomIds, + spaceIds: DEFAULT_CROSS_ROOM_THREAD_FILTERS.spaceIds, + tag: DEFAULT_CROSS_ROOM_THREAD_FILTERS.tag, + unreadOnly: DEFAULT_CROSS_ROOM_THREAD_FILTERS.unreadOnly, + resolved: DEFAULT_CROSS_ROOM_THREAD_FILTERS.resolved, + hasAttention: DEFAULT_CROSS_ROOM_THREAD_FILTERS.hasAttention, + activityWindow: DEFAULT_CROSS_ROOM_THREAD_FILTERS.activityWindow, +}; + +const resolveCrossRoomThreadFiltersUpdate = ( + current: CrossRoomThreadFilters, + update: CrossRoomThreadFiltersUpdate +): CrossRoomThreadFilters => (typeof update === 'function' ? update(current) : update); + +const validScopes = new Set(['involved', 'all']); +const validResolved = new Set(['all', 'resolved', 'unresolved']); +const validActivityWindows = new Set(['today', '7d', '30d', 'all']); + +const getStoreKey = (userId: string): string => `${CROSS_ROOM_THREAD_FILTERS}:${userId}`; + +const sanitizeStringArray = (value: unknown): string[] => { + if (!Array.isArray(value)) return []; + + return Array.from( + new Set( + value + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim()) + .filter(Boolean) + ) + ); +}; + +const sanitizeQuery = (value: unknown): string => + typeof value === 'string' ? value.trim().slice(0, 200) : ''; + +export const sanitizeCrossRoomThreadFilters = (value: unknown): CrossRoomThreadFilters => { + if (!isRecord(value) || value.v !== CROSS_ROOM_THREAD_FILTERS_VERSION) { + return DEFAULT_CROSS_ROOM_THREAD_FILTERS; + } + + const tag = isRecord(value.tag) ? value.tag : {}; + const scope = validScopes.has(value.scope as CrossRoomThreadScope) + ? (value.scope as CrossRoomThreadScope) + : DEFAULT_CROSS_ROOM_THREAD_FILTERS.scope; + const resolved = validResolved.has(value.resolved as CrossRoomThreadResolvedFilter) + ? (value.resolved as CrossRoomThreadResolvedFilter) + : DEFAULT_CROSS_ROOM_THREAD_FILTERS.resolved; + const activityWindow = validActivityWindows.has( + value.activityWindow as CrossRoomThreadActivityWindow + ) + ? (value.activityWindow as CrossRoomThreadActivityWindow) + : DEFAULT_CROSS_ROOM_THREAD_FILTERS.activityWindow; + + return { + v: CROSS_ROOM_THREAD_FILTERS_VERSION, + query: sanitizeQuery(value.query), + scope, + roomIds: sanitizeStringArray(value.roomIds), + spaceIds: sanitizeStringArray(value.spaceIds), + tag: { + include: sanitizeStringArray(tag.include), + exclude: sanitizeStringArray(tag.exclude), + }, + unreadOnly: value.unreadOnly === true, + resolved, + hasAttention: value.hasAttention === true, + activityWindow, + }; +}; + +const sanitizePersistedCrossRoomThreadFilters = ( + value: unknown +): PersistedCrossRoomThreadFilters => { + const sanitized = sanitizeCrossRoomThreadFilters(value); + return { + v: sanitized.v, + scope: sanitized.scope, + roomIds: sanitized.roomIds, + spaceIds: sanitized.spaceIds, + tag: sanitized.tag, + unreadOnly: sanitized.unreadOnly, + resolved: sanitized.resolved, + hasAttention: sanitized.hasAttention, + activityWindow: sanitized.activityWindow, + }; +}; + +let activeCrossRoomThreadFiltersAtom: CrossRoomThreadFiltersAtom | undefined; +const crossRoomThreadFiltersAtoms = new Map(); + +const makeEphemeralCrossRoomThreadFiltersAtom = (): CrossRoomThreadFiltersAtom => { + const baseAtom = atom(DEFAULT_CROSS_ROOM_THREAD_FILTERS); + return atom( + (get) => get(baseAtom), + (get, set, update) => { + set( + baseAtom, + sanitizeCrossRoomThreadFilters(resolveCrossRoomThreadFiltersUpdate(get(baseAtom), update)) + ); + return undefined; + } + ); +}; + +export const makeCrossRoomThreadFiltersAtom = (userId: string): CrossRoomThreadFiltersAtom => { + if (!userId) return makeEphemeralCrossRoomThreadFiltersAtom(); + + const existingAtom = crossRoomThreadFiltersAtoms.get(userId); + if (existingAtom) return existingAtom; + + const storeKey = getStoreKey(userId); + const persistedAtom = atomWithLocalStorage( + storeKey, + (key) => + sanitizePersistedCrossRoomThreadFilters(getLocalStorageItem(key, null)), + (key, value) => setLocalStorageItem(key, sanitizePersistedCrossRoomThreadFilters(value)) + ); + const queryAtom = atom(DEFAULT_CROSS_ROOM_THREAD_FILTERS.query); + const filtersAtom = atom( + (get) => ({ + ...DEFAULT_PERSISTED_CROSS_ROOM_THREAD_FILTERS, + ...get(persistedAtom), + query: get(queryAtom), + }), + (get, set, update) => { + const current = { + ...DEFAULT_PERSISTED_CROSS_ROOM_THREAD_FILTERS, + ...get(persistedAtom), + query: get(queryAtom), + }; + const sanitized = sanitizeCrossRoomThreadFilters( + resolveCrossRoomThreadFiltersUpdate(current, update) + ); + set(queryAtom, sanitized.query); + set(persistedAtom, sanitizePersistedCrossRoomThreadFilters(sanitized)); + } + ); + + crossRoomThreadFiltersAtoms.set(userId, filtersAtom); + return filtersAtom; +}; + +export const registerCrossRoomThreadFiltersAtom = (filtersAtom: CrossRoomThreadFiltersAtom) => { + activeCrossRoomThreadFiltersAtom = filtersAtom; + + return () => { + if (activeCrossRoomThreadFiltersAtom === filtersAtom) { + activeCrossRoomThreadFiltersAtom = undefined; + } + }; +}; + +export const clearCrossRoomThreadFiltersStore = (userId: string) => { + const filtersAtom = crossRoomThreadFiltersAtoms.get(userId); + if (activeCrossRoomThreadFiltersAtom === filtersAtom) { + activeCrossRoomThreadFiltersAtom = undefined; + } + + crossRoomThreadFiltersAtoms.delete(userId); + localStorage.removeItem(getStoreKey(userId)); +}; diff --git a/src/app/mindroom/cross-room-threads/crossRoomThreadIndex.ts b/src/app/mindroom/cross-room-threads/crossRoomThreadIndex.ts new file mode 100644 index 0000000000..7f1807939d --- /dev/null +++ b/src/app/mindroom/cross-room-threads/crossRoomThreadIndex.ts @@ -0,0 +1,495 @@ +import { atom } from 'jotai'; +import type { MatrixEvent } from 'matrix-js-sdk'; +import { RelationType } from 'matrix-js-sdk/lib/@types/event'; +import type { Room } from 'matrix-js-sdk/lib/models/room'; +import type { Thread } from 'matrix-js-sdk/lib/models/thread'; +import type { MindroomThreadSummaryInfo } from '../messages/threadSummary'; +import { buildThreadRecord } from '../threads/threadRecord'; +import { getRoomThreadTagSnapshotMap, type ThreadTagSnapshot } from '../threads/threadTagSnapshots'; +import { getPreferredVisibleThreadReplyEvents } from '../threads/threadUtils'; +import type { ThreadRecord } from '../threads/types'; + +export const MAX_CROSS_ROOM_INDEX_ENTRIES = 5000; +export const CROSS_ROOM_INDEX_EVICTION_SLACK = 250; +const KEY_SEPARATOR = '\u0000'; + +export type CrossRoomThreadIndexEntry = { + key: string; + roomId: string; + roomName: string; + parentSpaceIds: string[]; + threadRootId: string; + indexedEventIds: string[]; + threadRecord: ThreadRecord; + rootSenderId?: string; + lastActivityTs: number; + isUnread: boolean; + isResolved: boolean; + hasAttention: boolean; + isInvolved: boolean; + summaryText: string; + rootPreviewText: string; + searchableText: string; + tagSnapshot?: ThreadTagSnapshot; + tags: string[]; + generation: number; +}; + +export type CrossRoomThreadEventReverseIndex = Map>>; + +export type CrossRoomThreadIndexSnapshot = { + entries: Map; + eventIdToThreadRoots: CrossRoomThreadEventReverseIndex; + version: number; + bootstrapped: boolean; +}; + +export const emptyCrossRoomThreadIndexSnapshot = (): CrossRoomThreadIndexSnapshot => ({ + entries: new Map(), + eventIdToThreadRoots: new Map(), + version: 0, + bootstrapped: false, +}); + +export const crossRoomThreadIndexAtom = atom( + emptyCrossRoomThreadIndexSnapshot() +); + +export const getCrossRoomThreadIndexKey = (roomId: string, threadRootId: string): string => + `${roomId}${KEY_SEPARATOR}${threadRootId}`; + +export const parseCrossRoomThreadIndexKey = ( + key: string +): { roomId: string; threadRootId: string } | undefined => { + const separatorIndex = key.indexOf(KEY_SEPARATOR); + if (separatorIndex <= 0 || separatorIndex >= key.length - 1) return undefined; + + return { + roomId: key.slice(0, separatorIndex), + threadRootId: key.slice(separatorIndex + KEY_SEPARATOR.length), + }; +}; + +const getEntryIndexedEventIds = (entry: CrossRoomThreadIndexEntry): string[] => { + const indexedEventIds = (entry as { indexedEventIds?: string[] }).indexedEventIds; + const eventIds = new Set(indexedEventIds ?? []); + eventIds.add(entry.threadRootId); + + const rootEventId = entry.threadRecord.rootEventId; + if (rootEventId) eventIds.add(rootEventId); + + return Array.from(eventIds); +}; + +const addEntryToEventReverseIndex = ( + reverseIndex: CrossRoomThreadEventReverseIndex, + entry: CrossRoomThreadIndexEntry +): CrossRoomThreadEventReverseIndex => { + const eventIds = getEntryIndexedEventIds(entry); + if (eventIds.length === 0) return reverseIndex; + + const nextReverseIndex = new Map(reverseIndex); + const nextRoomIndex = new Map(nextReverseIndex.get(entry.roomId) ?? new Map()); + + eventIds.forEach((eventId) => { + const nextThreadRoots = new Set(nextRoomIndex.get(eventId) ?? new Set()); + nextThreadRoots.add(entry.threadRootId); + nextRoomIndex.set(eventId, nextThreadRoots); + }); + + nextReverseIndex.set(entry.roomId, nextRoomIndex); + return nextReverseIndex; +}; + +const removeEntryFromEventReverseIndex = ( + reverseIndex: CrossRoomThreadEventReverseIndex, + entry: CrossRoomThreadIndexEntry +): CrossRoomThreadEventReverseIndex => { + const roomIndex = reverseIndex.get(entry.roomId); + if (!roomIndex) return reverseIndex; + + let nextRoomIndex: Map> | undefined; + + getEntryIndexedEventIds(entry).forEach((eventId) => { + const threadRoots = (nextRoomIndex ?? roomIndex).get(eventId); + if (!threadRoots?.has(entry.threadRootId)) return; + + nextRoomIndex ??= new Map(roomIndex); + const nextThreadRoots = new Set(threadRoots); + nextThreadRoots.delete(entry.threadRootId); + + if (nextThreadRoots.size > 0) { + nextRoomIndex.set(eventId, nextThreadRoots); + return; + } + + nextRoomIndex.delete(eventId); + }); + + if (!nextRoomIndex) return reverseIndex; + + const nextReverseIndex = new Map(reverseIndex); + if (nextRoomIndex.size > 0) { + nextReverseIndex.set(entry.roomId, nextRoomIndex); + } else { + nextReverseIndex.delete(entry.roomId); + } + + return nextReverseIndex; +}; + +const removeRoomFromEventReverseIndex = ( + reverseIndex: CrossRoomThreadEventReverseIndex, + roomId: string +): CrossRoomThreadEventReverseIndex => { + if (!reverseIndex.has(roomId)) return reverseIndex; + + const nextReverseIndex = new Map(reverseIndex); + nextReverseIndex.delete(roomId); + return nextReverseIndex; +}; + +export const getCrossRoomThreadRootsForEvent = ( + snapshot: CrossRoomThreadIndexSnapshot, + roomId: string, + eventId: string | undefined +): string[] => { + if (!eventId) return []; + + return Array.from(snapshot.eventIdToThreadRoots.get(roomId)?.get(eventId) ?? []); +}; + +export const normalizeThreadSearchText = (value: string): string => + value.toLowerCase().replace(/\s+/g, ' ').trim(); + +const getEventContent = (event: MatrixEvent | undefined): Record | undefined => { + const content = event?.getContent?.(); + return content && typeof content === 'object' && !Array.isArray(content) + ? (content as Record) + : undefined; +}; + +const getEffectiveEventContent = ( + event: MatrixEvent | undefined +): Record | undefined => + getEventContent(event?.replacingEvent?.() ?? undefined) ?? getEventContent(event); + +const getEditedContent = ( + content: Record | undefined +): Record | undefined => { + const edited = content?.['m.new_content']; + return edited && typeof edited === 'object' && !Array.isArray(edited) + ? (edited as Record) + : undefined; +}; + +const getContentText = (content: Record | undefined): string | undefined => { + const edited = getEditedContent(content); + const body = edited?.body ?? content?.body; + const topic = edited?.topic ?? content?.topic; + const name = edited?.name ?? content?.name; + const text = body ?? topic ?? name; + return typeof text === 'string' ? text : undefined; +}; + +const hasDirectUserMention = ( + content: Record | undefined, + userId: string +): boolean => { + const mentions = content?.['m.mentions']; + if (!mentions || typeof mentions !== 'object' || Array.isArray(mentions)) return false; + + const mentionRecord = mentions as Record; + const userIds = mentionRecord.user_ids; + return Array.isArray(userIds) && userIds.includes(userId); +}; + +const getRelationTypeFromContent = ( + content: Record | undefined +): string | undefined => { + const relation = content?.['m.relates_to']; + if (!relation || typeof relation !== 'object' || Array.isArray(relation)) return undefined; + + const relationRecord = relation as Record; + return typeof relationRecord.rel_type === 'string' ? relationRecord.rel_type : undefined; +}; + +const isReplacementEvent = ( + event: MatrixEvent | undefined, + content: Record | undefined +): boolean => + event?.getRelation?.()?.rel_type === RelationType.Replace || + getRelationTypeFromContent(content) === RelationType.Replace; + +const getMentionSourceContent = ( + event: MatrixEvent | undefined, + content: Record | undefined +): Record | undefined => { + const editedContent = getEditedContent(content); + if (editedContent && isReplacementEvent(event, content)) return editedContent; + + return editedContent ?? content; +}; + +const eventDirectlyMentionsUser = (event: MatrixEvent | undefined, userId: string): boolean => { + const replacementEvent = event?.replacingEvent?.() ?? undefined; + const content = getEventContent(replacementEvent) ?? getEventContent(event); + + return hasDirectUserMention(getMentionSourceContent(replacementEvent ?? event, content), userId); +}; + +export const isUserInvolvedInThread = ({ + rootEvent, + thread, + userId, +}: { + rootEvent?: MatrixEvent; + thread: Thread | null | undefined; + userId: string | undefined; +}): boolean => { + if (!userId) return false; + if (rootEvent?.getSender?.() === userId) return true; + if (eventDirectlyMentionsUser(rootEvent, userId)) return true; + + const visibleReplies = getPreferredVisibleThreadReplyEvents(thread); + return visibleReplies.some( + (event) => event.getSender?.() === userId || eventDirectlyMentionsUser(event, userId) + ); +}; + +export const buildCrossRoomThreadIndexEntry = ({ + room, + threadRootId, + threadRootEvent, + summaryInfo, + currentUserId, + parentSpaceIds = [], + tagSnapshot, + generation = 0, +}: { + room: Room; + threadRootId: string; + threadRootEvent?: MatrixEvent; + summaryInfo?: MindroomThreadSummaryInfo; + currentUserId?: string; + parentSpaceIds?: string[]; + tagSnapshot?: ThreadTagSnapshot; + generation?: number; +}): CrossRoomThreadIndexEntry | undefined => { + const thread = room.getThread(threadRootId); + const resolvedRootEvent = + threadRootEvent ?? thread?.rootEvent ?? room.findEventById(threadRootId); + if (!resolvedRootEvent && !thread) return undefined; + + const resolvedTagSnapshot = tagSnapshot ?? getRoomThreadTagSnapshotMap(room).get(threadRootId); + const rootPreviewText = + getContentText(getEffectiveEventContent(resolvedRootEvent)) ?? summaryInfo?.summaryText ?? ''; + const threadRecord = buildThreadRecord({ + room, + threadRootId, + threadRootEvent: resolvedRootEvent, + summaryInfo, + rootPreviewText, + threadResolution: resolvedTagSnapshot + ? { + isResolved: resolvedTagSnapshot.isResolved, + tags: resolvedTagSnapshot.content.tags, + } + : undefined, + currentUserId, + }); + const summaryText = + threadRecord.presentation.primarySummaryText ?? + threadRecord.presentation.summaryText ?? + threadRecord.presentation.recentThreadSummaryText ?? + ''; + const lastActivityTs = + threadRecord.status.lastActivityTs ?? + resolvedRootEvent?.getTs?.() ?? + summaryInfo?.generatedTs ?? + 0; + const isResolved = threadRecord.status.isResolved; + const hasAttention = + !isResolved && + (threadRecord.status.isUnread || + threadRecord.status.isStreaming || + threadRecord.status.scheduledTaskCount > 0); + const indexedEventIds = new Set(); + indexedEventIds.add(threadRootId); + const rootEventId = resolvedRootEvent?.getId?.() ?? threadRecord.rootEventId; + if (rootEventId) indexedEventIds.add(rootEventId); + getPreferredVisibleThreadReplyEvents(thread).forEach((event) => { + const eventId = event.getId?.(); + if (eventId) indexedEventIds.add(eventId); + }); + + return { + key: getCrossRoomThreadIndexKey(room.roomId, threadRootId), + roomId: room.roomId, + roomName: room.name || room.roomId, + parentSpaceIds, + threadRootId, + indexedEventIds: Array.from(indexedEventIds), + threadRecord, + rootSenderId: resolvedRootEvent?.getSender?.(), + lastActivityTs, + isUnread: threadRecord.status.isUnread, + isResolved, + hasAttention, + isInvolved: isUserInvolvedInThread({ + rootEvent: resolvedRootEvent, + thread, + userId: currentUserId, + }), + summaryText, + rootPreviewText, + searchableText: normalizeThreadSearchText(`${rootPreviewText} ${summaryText}`), + tagSnapshot: resolvedTagSnapshot, + tags: threadRecord.status.tags, + generation, + }; +}; + +const evictOverflowEntries = ( + entries: Map +): { + entries: Map; + evictedEntries: CrossRoomThreadIndexEntry[]; +} => { + if (entries.size <= MAX_CROSS_ROOM_INDEX_ENTRIES + CROSS_ROOM_INDEX_EVICTION_SLACK) { + return { entries, evictedEntries: [] }; + } + + const keepKeys = new Set( + Array.from(entries.values()) + .sort((left, right) => right.lastActivityTs - left.lastActivityTs) + .slice(0, MAX_CROSS_ROOM_INDEX_ENTRIES) + .map((entry) => entry.key) + ); + + const nextEntries = new Map(); + const evictedEntries: CrossRoomThreadIndexEntry[] = []; + + entries.forEach((entry, key) => { + if (keepKeys.has(key)) { + nextEntries.set(key, entry); + return; + } + + evictedEntries.push(entry); + }); + + return { entries: nextEntries, evictedEntries }; +}; + +export const upsertCrossRoomThreadIndexEntry = ( + snapshot: CrossRoomThreadIndexSnapshot, + entry: CrossRoomThreadIndexEntry +): CrossRoomThreadIndexSnapshot => { + const current = snapshot.entries.get(entry.key); + const entries = new Map(snapshot.entries); + const nextEntry = { + ...entry, + generation: current ? current.generation + 1 : entry.generation, + }; + entries.set(entry.key, nextEntry); + + let eventIdToThreadRoots = current + ? removeEntryFromEventReverseIndex(snapshot.eventIdToThreadRoots, current) + : snapshot.eventIdToThreadRoots; + eventIdToThreadRoots = addEntryToEventReverseIndex(eventIdToThreadRoots, nextEntry); + + const evicted = evictOverflowEntries(entries); + evicted.evictedEntries.forEach((evictedEntry) => { + eventIdToThreadRoots = removeEntryFromEventReverseIndex(eventIdToThreadRoots, evictedEntry); + }); + + return { + ...snapshot, + entries: evicted.entries, + eventIdToThreadRoots, + version: snapshot.version + 1, + }; +}; + +export const removeCrossRoomThreadIndexEntry = ( + snapshot: CrossRoomThreadIndexSnapshot, + roomId: string, + threadRootId: string +): CrossRoomThreadIndexSnapshot => { + const key = getCrossRoomThreadIndexKey(roomId, threadRootId); + if (!snapshot.entries.has(key)) return snapshot; + + const entries = new Map(snapshot.entries); + const entry = entries.get(key); + entries.delete(key); + return { + ...snapshot, + entries, + eventIdToThreadRoots: entry + ? removeEntryFromEventReverseIndex(snapshot.eventIdToThreadRoots, entry) + : snapshot.eventIdToThreadRoots, + version: snapshot.version + 1, + }; +}; + +export const removeRoomCrossRoomThreadIndexEntries = ( + snapshot: CrossRoomThreadIndexSnapshot, + roomId: string +): CrossRoomThreadIndexSnapshot => { + const entries = new Map(snapshot.entries); + let removed = false; + entries.forEach((entry, key) => { + if (entry.roomId !== roomId) return; + entries.delete(key); + removed = true; + }); + + if (!removed) return snapshot; + return { + ...snapshot, + entries, + eventIdToThreadRoots: removeRoomFromEventReverseIndex(snapshot.eventIdToThreadRoots, roomId), + version: snapshot.version + 1, + }; +}; + +export type CrossRoomThreadDirtyCoalescer = { + enqueueDirty: (key: string) => void; + flushNow: () => void; + clear: () => void; +}; + +export const createCrossRoomThreadDirtyCoalescer = ( + flushDirtyKeys: (keys: string[]) => void, + schedule: (callback: () => void) => void = queueMicrotask +): CrossRoomThreadDirtyCoalescer => { + const dirtyKeys = new Set(); + let scheduled = false; + + const flushNow = () => { + if (dirtyKeys.size === 0) { + scheduled = false; + return; + } + + const keys = Array.from(dirtyKeys); + dirtyKeys.clear(); + scheduled = false; + flushDirtyKeys(keys); + }; + + return { + enqueueDirty: (key: string) => { + dirtyKeys.add(key); + if (scheduled) return; + scheduled = true; + schedule(flushNow); + }, + flushNow, + clear: () => { + dirtyKeys.clear(); + scheduled = false; + }, + }; +}; diff --git a/src/app/mindroom/cross-room-threads/useCrossRoomThreadIndex.ts b/src/app/mindroom/cross-room-threads/useCrossRoomThreadIndex.ts new file mode 100644 index 0000000000..2b7980dd44 --- /dev/null +++ b/src/app/mindroom/cross-room-threads/useCrossRoomThreadIndex.ts @@ -0,0 +1,492 @@ +import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'; +import { useAtomValue, useSetAtom } from 'jotai'; +import { RelationType } from 'matrix-js-sdk/lib/@types/event'; +import { MatrixEventEvent, type MatrixEvent } from 'matrix-js-sdk/lib/models/event'; +import { RoomEvent, RoomStateEvent } from 'matrix-js-sdk'; +import type { Room } from 'matrix-js-sdk/lib/models/room'; +import { ThreadEvent, type Thread } from 'matrix-js-sdk/lib/models/thread'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useActiveSession } from '../../hooks/useSessionStore'; +import { allRoomsAtom } from '../../state/room-list/roomList'; +import { roomToParentsAtom } from '../../state/room/roomToParents'; +import { makeRecentThreadsAtom } from '../recent-threads/recentThreads'; +import { + ensureThreadSummaryStateLoaded, + getThreadSummaryStateSnapshot, + subscribeToThreadSummaryState, +} from '../threads/threadSummaryState'; +import { MINDROOM_THREAD_TAGS_EVENT } from '../threads/threadTags'; +import { + buildCrossRoomThreadIndexEntry, + createCrossRoomThreadDirtyCoalescer, + crossRoomThreadIndexAtom, + emptyCrossRoomThreadIndexSnapshot, + getCrossRoomThreadRootsForEvent, + getCrossRoomThreadIndexKey, + parseCrossRoomThreadIndexKey, + removeCrossRoomThreadIndexEntry, + removeRoomCrossRoomThreadIndexEntries, + upsertCrossRoomThreadIndexEntry, +} from './crossRoomThreadIndex'; + +const BOOTSTRAP_ROOM_CHUNK_SIZE = 5; + +type RoomListenerDisposer = () => void; + +type CrossRoomThreadIndexController = { + syncJoinedRooms: (roomIds: string[]) => void; +}; + +const requestIdle = (callback: () => void): number => { + if (typeof window !== 'undefined' && typeof window.requestIdleCallback === 'function') { + return window.requestIdleCallback(callback, { timeout: 1500 }); + } + + return globalThis.setTimeout(callback, 0) as unknown as number; +}; + +const cancelIdle = (handle: number) => { + if (typeof window !== 'undefined' && typeof window.cancelIdleCallback === 'function') { + window.cancelIdleCallback(handle); + return; + } + + globalThis.clearTimeout(handle); +}; + +const getRoomThreads = (room: Room): Thread[] => { + const threads = (room as unknown as { getThreads?: () => unknown }).getThreads?.(); + if (!threads) return []; + if (Array.isArray(threads)) return threads as Thread[]; + if (threads instanceof Map) return Array.from(threads.values()) as Thread[]; + return Array.from(threads as Iterable); +}; + +const getThreadRootId = (thread: Thread): string | undefined => + thread.rootEvent?.getId?.() ?? (thread as unknown as { id?: string }).id; + +const getThreadRelationRootId = (event: MatrixEvent | undefined): string | undefined => { + if (!event) return undefined; + const eventId = event.getId?.(); + const threadRootId = event.threadRootId; + if (threadRootId && threadRootId !== eventId) return threadRootId; + + const relation = event.getRelation?.(); + const relatedRootId = + relation?.rel_type === RelationType.Thread && typeof relation.event_id === 'string' + ? relation.event_id + : undefined; + return relatedRootId && relatedRootId !== eventId ? relatedRootId : undefined; +}; + +const getReplaceRelationTargetId = (event: MatrixEvent | undefined): string | undefined => { + if (!event) return undefined; + + const relation = event.getRelation?.(); + if (relation?.rel_type === RelationType.Replace && typeof relation.event_id === 'string') { + return relation.event_id; + } + + const content = event.getContent?.(); + if (!content || typeof content !== 'object' || Array.isArray(content)) return undefined; + + const contentRelation = (content as Record)['m.relates_to']; + if (!contentRelation || typeof contentRelation !== 'object' || Array.isArray(contentRelation)) { + return undefined; + } + + const relationRecord = contentRelation as Record; + return relationRecord.rel_type === RelationType.Replace && + typeof relationRecord.event_id === 'string' + ? relationRecord.event_id + : undefined; +}; + +const isPendingThreadRootEvent = (event: MatrixEvent | undefined): boolean => { + if (!event) return false; + + const eventId = event.getId?.(); + if (typeof eventId === 'string' && eventId.startsWith('~')) return true; + if (event.isSending?.() === true) return true; + + const status = (event as unknown as { status?: unknown }).status; + return status !== undefined && status !== null; +}; + +const isJoinedTimelineRoom = (room: Room | null | undefined): room is Room => + !!room && room.getMyMembership?.() === 'join' && !room.isSpaceRoom?.(); + +export const useCrossRoomThreadIndex = () => { + const mx = useMatrixClient(); + const activeSession = useActiveSession(); + const userId = mx.getUserId() ?? activeSession?.userId; + const joinedRoomIds = useAtomValue(allRoomsAtom); + const roomToParents = useAtomValue(roomToParentsAtom); + const recentThreads = useAtomValue(useMemo(() => makeRecentThreadsAtom(userId ?? ''), [userId])); + const snapshot = useAtomValue(crossRoomThreadIndexAtom); + const setSnapshot = useSetAtom(crossRoomThreadIndexAtom); + const parentMapRef = useRef(roomToParents); + const recentThreadsRef = useRef(recentThreads); + const sessionIdRef = useRef(activeSession?.sessionId); + const userIdRef = useRef(userId); + const joinedRoomIdsRef = useRef(joinedRoomIds); + const snapshotRef = useRef(snapshot); + const roomDisposersRef = useRef(new Map()); + const controllerRef = useRef(); + + parentMapRef.current = roomToParents; + recentThreadsRef.current = recentThreads; + sessionIdRef.current = activeSession?.sessionId; + userIdRef.current = userId; + joinedRoomIdsRef.current = joinedRoomIds; + snapshotRef.current = snapshot; + + useLayoutEffect(() => { + const emptySnapshot = emptyCrossRoomThreadIndexSnapshot(); + snapshotRef.current = emptySnapshot; + setSnapshot(emptySnapshot); + }, [activeSession?.sessionId, mx, setSnapshot, userId]); + + useEffect(() => { + if (!userId) return undefined; + + let disposed = false; + let idleHandle: number | undefined; + const effectSessionId = activeSession?.sessionId; + const effectUserId = userId; + const isEffectCurrent = () => + !disposed && + userIdRef.current === effectUserId && + sessionIdRef.current === effectSessionId && + (mx.getUserId() ?? effectUserId) === effectUserId; + const roomDisposers = roomDisposersRef.current; + const recentRoomOrder = new Map(); + recentThreadsRef.current.forEach((entry, index) => { + if (!recentRoomOrder.has(entry.roomId)) recentRoomOrder.set(entry.roomId, index); + }); + + const buildEntry = (roomId: string, threadRootId: string) => { + if (!roomDisposers.has(roomId)) return undefined; + + const room = mx.getRoom(roomId); + if (!isJoinedTimelineRoom(room)) return undefined; + + const thread = room.getThread(threadRootId); + if (isPendingThreadRootEvent(thread?.rootEvent ?? room.findEventById(threadRootId))) { + return undefined; + } + + const sessionId = sessionIdRef.current; + const summaryMap = getThreadSummaryStateSnapshot(sessionId, roomId); + const parents = Array.from(parentMapRef.current.get(roomId) ?? []); + + return buildCrossRoomThreadIndexEntry({ + room, + threadRootId, + summaryInfo: summaryMap.get(threadRootId), + currentUserId: userIdRef.current, + parentSpaceIds: parents, + }); + }; + + const flushDirtyKeys = (keys: string[]) => { + if (!isEffectCurrent()) return; + + setSnapshot((current) => { + if (!isEffectCurrent()) return current; + + let next = current; + + keys.forEach((key) => { + const parsed = parseCrossRoomThreadIndexKey(key); + if (!parsed) return; + + const entry = buildEntry(parsed.roomId, parsed.threadRootId); + if (!entry) { + next = removeCrossRoomThreadIndexEntry(next, parsed.roomId, parsed.threadRootId); + return; + } + + next = upsertCrossRoomThreadIndexEntry(next, entry); + }); + + return next; + }); + }; + const coalescer = createCrossRoomThreadDirtyCoalescer(flushDirtyKeys); + + const enqueueThread = (roomId: string, threadRootId: string | undefined) => { + if (!isEffectCurrent()) return; + if (!threadRootId) return; + if (!roomDisposers.has(roomId)) return; + + const room = mx.getRoom(roomId); + if (!isJoinedTimelineRoom(room)) return; + + const thread = room.getThread(threadRootId); + if (isPendingThreadRootEvent(thread?.rootEvent ?? room.findEventById(threadRootId))) { + return; + } + + coalescer.enqueueDirty(getCrossRoomThreadIndexKey(roomId, threadRootId)); + }; + + const enqueueRoomThreads = (room: Room) => { + getRoomThreads(room).forEach((thread) => enqueueThread(room.roomId, getThreadRootId(thread))); + }; + + const enqueueTrackedEntriesContainingEventId = (room: Room, eventId: string | undefined) => { + getCrossRoomThreadRootsForEvent(snapshotRef.current, room.roomId, eventId).forEach( + (threadRootId) => enqueueThread(room.roomId, threadRootId) + ); + }; + + const enqueueTrackedEntriesContainingEvent = (room: Room, event: MatrixEvent) => { + enqueueTrackedEntriesContainingEventId(room, event.getId?.()); + }; + + const sortRoomsForBootstrap = (rooms: Room[]) => + rooms.sort((left, right) => { + const leftRecent = recentRoomOrder.get(left.roomId) ?? Number.MAX_SAFE_INTEGER; + const rightRecent = recentRoomOrder.get(right.roomId) ?? Number.MAX_SAFE_INTEGER; + if (leftRecent !== rightRecent) return leftRecent - rightRecent; + return (right.getLastActiveTimestamp?.() ?? 0) - (left.getLastActiveTimestamp?.() ?? 0); + }); + + const bootstrapRooms: Room[] = []; + const bootstrapQueuedRoomIds = new Set(); + + const scanRoom = (room: Room) => { + const sessionId = sessionIdRef.current; + if (sessionId) { + ensureThreadSummaryStateLoaded(sessionId, room.roomId).catch(() => {}); + } + + enqueueRoomThreads(room); + }; + + const drainBootstrapQueue = () => { + if (disposed) return; + + const chunk = bootstrapRooms.splice(0, BOOTSTRAP_ROOM_CHUNK_SIZE); + chunk.forEach((room) => bootstrapQueuedRoomIds.delete(room.roomId)); + chunk.forEach(scanRoom); + + if (bootstrapRooms.length > 0) { + idleHandle = requestIdle(drainBootstrapQueue); + return; + } + + idleHandle = undefined; + if (!isEffectCurrent()) return; + setSnapshot((current) => ({ ...current, bootstrapped: true })); + }; + + const scheduleRoomBootstrap = (rooms: Room[]) => { + const newRooms = rooms.filter((room) => { + if (bootstrapQueuedRoomIds.has(room.roomId)) return false; + bootstrapQueuedRoomIds.add(room.roomId); + return true; + }); + if (newRooms.length === 0) return; + + bootstrapRooms.push(...sortRoomsForBootstrap(newRooms)); + if (idleHandle === undefined) { + idleHandle = requestIdle(drainBootstrapQueue); + } + }; + + const addRoomListeners = (room: Room) => { + if (roomDisposers.has(room.roomId)) return; + + const handleThread = (thread: Thread, ...args: unknown[]) => { + const event = args.find( + (candidate): candidate is MatrixEvent => + !!candidate && typeof candidate === 'object' && 'getId' in candidate + ); + enqueueThread(room.roomId, getThreadRootId(thread) ?? getThreadRelationRootId(event)); + }; + const handleThreadDelete = (thread: Thread, ...args: unknown[]) => { + const event = args.find( + (candidate): candidate is MatrixEvent => + !!candidate && typeof candidate === 'object' && 'getId' in candidate + ); + const threadRootId = getThreadRootId(thread) ?? getThreadRelationRootId(event); + if (!threadRootId || !isEffectCurrent()) return; + + setSnapshot((current) => { + if (!isEffectCurrent()) return current; + return removeCrossRoomThreadIndexEntry(current, room.roomId, threadRootId); + }); + }; + const handleReceipt = () => { + enqueueRoomThreads(room); + }; + const handleTimeline = (event: MatrixEvent) => { + const threadRootId = getThreadRelationRootId(event); + if (threadRootId) { + enqueueThread(room.roomId, threadRootId); + return; + } + + const relation = event.getRelation?.(); + const editedThreadRootId = + relation?.rel_type === RelationType.Replace && typeof relation.event_id === 'string' + ? relation.event_id + : undefined; + if (editedThreadRootId) { + if ( + snapshotRef.current.entries.has( + getCrossRoomThreadIndexKey(room.roomId, editedThreadRootId) + ) + ) { + enqueueThread(room.roomId, editedThreadRootId); + return; + } + + enqueueTrackedEntriesContainingEventId(room, editedThreadRootId); + } + }; + + room.on(RoomEvent.Receipt, handleReceipt); + room.on(RoomEvent.Timeline, handleTimeline); + room.on(ThreadEvent.New, handleThread); + room.on(ThreadEvent.Update, handleThread); + room.on(ThreadEvent.NewReply, handleThread); + room.on(ThreadEvent.Delete, handleThreadDelete); + + const sessionId = sessionIdRef.current; + const unsubscribeSummaryState = sessionId + ? subscribeToThreadSummaryState(sessionId, room.roomId, () => enqueueRoomThreads(room)) + : undefined; + + roomDisposers.set(room.roomId, () => { + room.removeListener(RoomEvent.Receipt, handleReceipt); + room.removeListener(RoomEvent.Timeline, handleTimeline); + room.removeListener(ThreadEvent.New, handleThread); + room.removeListener(ThreadEvent.Update, handleThread); + room.removeListener(ThreadEvent.NewReply, handleThread); + room.removeListener(ThreadEvent.Delete, handleThreadDelete); + unsubscribeSummaryState?.(); + }); + }; + + const removeRoomFromIndex = (roomId: string) => { + setSnapshot((current) => removeRoomCrossRoomThreadIndexEntries(current, roomId)); + }; + + const removeRoomListeners = (roomId: string) => { + const dispose = roomDisposers.get(roomId); + if (!dispose) return; + roomDisposers.delete(roomId); + dispose(); + }; + + const syncJoinedRooms = (roomIds: string[]) => { + if (!isEffectCurrent()) return; + + const nextRoomIds = new Set( + roomIds + .map((roomId) => mx.getRoom(roomId)) + .filter(isJoinedTimelineRoom) + .map((room) => room.roomId) + ); + + Array.from(roomDisposers.keys()).forEach((roomId) => { + if (nextRoomIds.has(roomId)) return; + removeRoomListeners(roomId); + bootstrapQueuedRoomIds.delete(roomId); + for (let index = bootstrapRooms.length - 1; index >= 0; index -= 1) { + if (bootstrapRooms[index].roomId === roomId) bootstrapRooms.splice(index, 1); + } + removeRoomFromIndex(roomId); + }); + + const addedRooms = roomIds + .map((roomId) => mx.getRoom(roomId)) + .filter(isJoinedTimelineRoom) + .filter((room) => !roomDisposers.has(room.roomId)); + addedRooms.forEach(addRoomListeners); + const shouldScanAddedRoomsImmediately = snapshotRef.current.bootstrapped; + if (shouldScanAddedRoomsImmediately) { + addedRooms.forEach(scanRoom); + } else { + scheduleRoomBootstrap(addedRooms); + } + if (nextRoomIds.size === 0 && bootstrapRooms.length === 0 && idleHandle === undefined) { + setSnapshot((current) => + current.bootstrapped ? current : { ...current, bootstrapped: true } + ); + } + }; + + controllerRef.current = { syncJoinedRooms }; + syncJoinedRooms(joinedRoomIdsRef.current); + + const handleMembership = (room: Room) => { + if (room.getMyMembership?.() === 'join') { + syncJoinedRooms(joinedRoomIdsRef.current); + enqueueRoomThreads(room); + return; + } + + removeRoomListeners(room.roomId); + removeRoomFromIndex(room.roomId); + }; + + const handleStateEvent = (event: MatrixEvent, roomState: { roomId?: string }) => { + if (event.getType?.() !== MINDROOM_THREAD_TAGS_EVENT) return; + const roomId = + roomState?.roomId ?? (event as unknown as { getRoomId?: () => string }).getRoomId?.(); + if (!roomId) return; + + const room = mx.getRoom(roomId); + if (!room) return; + if (!roomDisposers.has(room.roomId)) return; + + enqueueRoomThreads(room); + }; + + const handleDecrypted = (event: MatrixEvent) => { + const roomId = (event as unknown as { getRoomId?: () => string }).getRoomId?.(); + const room = roomId ? mx.getRoom(roomId) : undefined; + if (!room) return; + if (!roomDisposers.has(room.roomId)) return; + + enqueueThread(room.roomId, getThreadRelationRootId(event)); + const editedThreadRootId = getReplaceRelationTargetId(event); + if ( + editedThreadRootId && + snapshotRef.current.entries.has(getCrossRoomThreadIndexKey(room.roomId, editedThreadRootId)) + ) { + enqueueThread(room.roomId, editedThreadRootId); + } else if (editedThreadRootId) { + enqueueTrackedEntriesContainingEventId(room, editedThreadRootId); + } + enqueueTrackedEntriesContainingEvent(room, event); + }; + + mx.on(RoomEvent.MyMembership, handleMembership); + mx.on(RoomStateEvent.Events, handleStateEvent); + mx.on(MatrixEventEvent.Decrypted, handleDecrypted); + + return () => { + disposed = true; + if (idleHandle !== undefined) cancelIdle(idleHandle); + coalescer.clear(); + if (controllerRef.current?.syncJoinedRooms === syncJoinedRooms) { + controllerRef.current = undefined; + } + Array.from(roomDisposers.keys()).forEach(removeRoomListeners); + mx.removeListener(RoomEvent.MyMembership, handleMembership); + mx.removeListener(RoomStateEvent.Events, handleStateEvent); + mx.removeListener(MatrixEventEvent.Decrypted, handleDecrypted); + }; + }, [activeSession?.sessionId, mx, setSnapshot, userId]); + + useEffect(() => { + controllerRef.current?.syncJoinedRooms(joinedRoomIds); + }, [joinedRoomIds]); + + return snapshot; +}; diff --git a/src/app/mindroom/local-mindroom/LocalMindroom.tsx b/src/app/mindroom/local-mindroom/LocalMindroom.tsx new file mode 100644 index 0000000000..23027a104b --- /dev/null +++ b/src/app/mindroom/local-mindroom/LocalMindroom.tsx @@ -0,0 +1,471 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { Box, Button, Icon, IconButton, Icons, Scroll, Spinner, Text, color } from 'folds'; +import { Page, PageContent, PageHeader } from '../../components/page'; +import { SequenceCard } from '../../components/sequence-card'; +import { SettingTile } from '../../components/setting-tile'; +import { copyToClipboard } from '../../utils/dom'; +import { useClientConfig } from '../../hooks/useClientConfig'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { SequenceCardStyle } from '../../features/settings/styles.css'; +import { + LocalMindroomConnection, + getLocalMindroomConnections, + getLocalMindroomErrorMessage, + getLocalMindroomPairStatus, + issueLocalMindroomPairCode, + revokeLocalMindroomConnection, +} from './api'; +import { + formatLocalTimestamp, + getConnectionCreatedAt, + getConnectionId, + getConnectionLastSeenAt, + getConnectionName, + getMindroomDocsUrl, + getMindroomPairingCommand, + getPairingSecondsRemaining, + isConnectionRevoked, + resolveMindroomProvisioningRequest, +} from './mindroom'; + +type LocalMindroomProps = { + requestClose: () => void; +}; + +type PairStatus = 'pending' | 'connected' | 'expired'; + +type PairSession = { + pairCode: string; + expiresAt: string; + pollIntervalSeconds: number; +}; + +export function LocalMindroom({ requestClose }: LocalMindroomProps) { + const mx = useMatrixClient(); + const { sidebar } = useClientConfig(); + const docsUrl = getMindroomDocsUrl(sidebar?.mindRoomUrl); + const sessionHomeserverUrl = mx.getHomeserverUrl(); + const sessionAccessToken = mx.getAccessToken() ?? undefined; + const provisioningRequest = resolveMindroomProvisioningRequest({ + sessionHomeserverUrl, + provisioningOverrideUrl: sidebar?.mindRoomProvisioningUrl, + accessToken: sessionAccessToken, + }); + const provisioningUrl = provisioningRequest.provisioningBaseUrl; + const browserAccessToken = provisioningRequest.accessToken; + + const [connections, setConnections] = useState(); + const [loadingConnections, setLoadingConnections] = useState(false); + const [connectionsError, setConnectionsError] = useState(); + + const [pairSession, setPairSession] = useState(); + const [pairStatus, setPairStatus] = useState(); + const [pairConnection, setPairConnection] = useState(); + const [pairError, setPairError] = useState(); + const [startingPair, setStartingPair] = useState(false); + const [nowMs, setNowMs] = useState(Date.now()); + const [copiedCommand, setCopiedCommand] = useState(false); + + const [confirmRevokeId, setConfirmRevokeId] = useState(); + const [revokingId, setRevokingId] = useState(); + const [revokeError, setRevokeError] = useState(); + + const loadConnections = useCallback(async () => { + setLoadingConnections(true); + setConnectionsError(undefined); + try { + const result = await getLocalMindroomConnections(browserAccessToken, provisioningUrl); + setConnections(result.connections.filter((connection) => !isConnectionRevoked(connection))); + } catch (error) { + setConnectionsError(getLocalMindroomErrorMessage(error)); + } finally { + setLoadingConnections(false); + } + }, [browserAccessToken, provisioningUrl]); + + useEffect(() => { + loadConnections(); + }, [loadConnections]); + + useEffect(() => { + if (!pairSession) return undefined; + const intervalId = window.setInterval(() => setNowMs(Date.now()), 1000); + return () => window.clearInterval(intervalId); + }, [pairSession]); + + const secondsRemaining = useMemo(() => { + if (!pairSession) return 0; + return getPairingSecondsRemaining(pairSession.expiresAt, nowMs); + }, [pairSession, nowMs]); + + useEffect(() => { + if (!pairSession || pairStatus !== 'pending') return; + if (secondsRemaining <= 0) { + setPairStatus('expired'); + } + }, [pairSession, pairStatus, secondsRemaining]); + + useEffect(() => { + if (!pairSession || pairStatus !== 'pending') return undefined; + + let cancelled = false; + const pollIntervalMs = Math.max(1, pairSession.pollIntervalSeconds) * 1000; + + const poll = async () => { + try { + const result = await getLocalMindroomPairStatus( + pairSession.pairCode, + browserAccessToken, + provisioningUrl + ); + if (cancelled) return; + + if (result.status === 'pending') { + setPairStatus('pending'); + return; + } + + if (result.status === 'expired') { + setPairStatus('expired'); + return; + } + + setPairStatus('connected'); + setPairConnection(result.connection); + loadConnections().catch(() => undefined); + } catch (error) { + if (cancelled) return; + setPairError(getLocalMindroomErrorMessage(error)); + } + }; + + poll(); + const intervalId = window.setInterval(() => { + poll(); + }, pollIntervalMs); + + return () => { + cancelled = true; + window.clearInterval(intervalId); + }; + }, [pairSession, pairStatus, loadConnections, browserAccessToken, provisioningUrl]); + + const handleStartPair = useCallback(async () => { + setStartingPair(true); + setPairError(undefined); + setRevokeError(undefined); + setConfirmRevokeId(undefined); + setPairConnection(undefined); + + try { + const result = await issueLocalMindroomPairCode(browserAccessToken, provisioningUrl); + setPairSession({ + pairCode: result.pair_code, + expiresAt: result.expires_at, + pollIntervalSeconds: result.poll_interval_seconds, + }); + setPairStatus('pending'); + setNowMs(Date.now()); + setCopiedCommand(false); + } catch (error) { + setPairError(getLocalMindroomErrorMessage(error)); + } finally { + setStartingPair(false); + } + }, [browserAccessToken, provisioningUrl]); + + const handleCopyCommand = useCallback(() => { + if (!pairSession) return; + copyToClipboard(getMindroomPairingCommand(pairSession.pairCode)); + setCopiedCommand(true); + window.setTimeout(() => setCopiedCommand(false), 1600); + }, [pairSession]); + + const handleRevokeConnection = useCallback( + async (connectionId: string) => { + setRevokingId(connectionId); + setRevokeError(undefined); + + try { + await revokeLocalMindroomConnection(connectionId, browserAccessToken, provisioningUrl); + setConfirmRevokeId(undefined); + await loadConnections(); + } catch (error) { + setRevokeError(getLocalMindroomErrorMessage(error)); + } finally { + setRevokingId(undefined); + } + }, + [loadConnections, browserAccessToken, provisioningUrl] + ); + + const hasConnections = (connections?.length ?? 0) > 0; + const pairingCommand = pairSession ? getMindroomPairingCommand(pairSession.pairCode) : ''; + const showGenerateNewCode = !pairSession || pairStatus === 'expired'; + let pairStatusLabel = `${secondsRemaining}s`; + if (pairStatus === 'connected') pairStatusLabel = 'Connected'; + else if (pairStatus === 'expired') pairStatusLabel = 'Expired'; + + return ( + + + + + + Local MindRoom + + + + + + + + + + + + + + + Connect Local MindRoom + + + + {provisioningRequest.warning && ( + + {provisioningRequest.warning} + + )} + + {pairSession && ( + + + {pairStatusLabel} + + } + /> + {pairingCommand} + } + after={ + + } + /> + + )} + + {pairStatus === 'pending' && ( + + Waiting for local connection. Run the command above on your machine. + + )} + + {pairStatus === 'connected' && ( + + + Local MindRoom connected successfully. + + {pairConnection && ( + + {getConnectionName(pairConnection, 0)} | Created:{' '} + {formatLocalTimestamp(getConnectionCreatedAt(pairConnection))} | Last seen:{' '} + {formatLocalTimestamp(getConnectionLastSeenAt(pairConnection))} + + )} + + )} + + {pairStatus === 'expired' && ( + + Pair code expired. Generate a new code to continue. + + )} + + {pairError && ( + + {pairError} + + )} + + + + + + + + + + Linked Installations + + {loadingConnections && ( + + + + Loading linked installations... + + + )} + + {!loadingConnections && connectionsError && ( + + + {connectionsError} + + + + )} + + {!loadingConnections && !connectionsError && !hasConnections && ( + + No linked local MindRoom installations yet. + + )} + + {connections?.map((connection, index) => { + const connectionId = getConnectionId(connection); + const isRevoking = connectionId !== undefined && revokingId === connectionId; + const isConfirming = connectionId !== undefined && confirmRevokeId === connectionId; + + return ( + + +
+ Created: {formatLocalTimestamp(getConnectionCreatedAt(connection))} +
+
+ Last seen: {formatLocalTimestamp(getConnectionLastSeenAt(connection))} +
+ + } + /> + + {connectionId && ( + + {!isConfirming && ( + + )} + + {isConfirming && ( + <> + + + + )} + + )} +
+ ); + })} + + {revokeError && ( + + {revokeError} + + )} +
+
+
+
+
+
+
+ ); +} diff --git a/src/app/mindroom/local-mindroom/api.test.ts b/src/app/mindroom/local-mindroom/api.test.ts new file mode 100644 index 0000000000..8a7ab6d741 --- /dev/null +++ b/src/app/mindroom/local-mindroom/api.test.ts @@ -0,0 +1,223 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + getLocalMindroomConnections, + getLocalMindroomErrorMessage, + getLocalMindroomPairStatus, + issueLocalMindroomPairCode, + revokeLocalMindroomConnection, +} from './api'; + +const capacitorMocks = vi.hoisted(() => ({ + isNativePlatform: vi.fn(() => false), + nativeRequest: vi.fn(), +})); + +vi.mock('@capacitor/core', () => ({ + Capacitor: { + isNativePlatform: capacitorMocks.isNativePlatform, + }, + CapacitorHttp: { + request: capacitorMocks.nativeRequest, + }, +})); + +type MockResponse = Pick; + +const createResponse = (status: number, body?: unknown): MockResponse => ({ + ok: status >= 200 && status < 300, + status, + json: vi.fn().mockResolvedValue(body), +}); + +afterEach(() => { + capacitorMocks.isNativePlatform.mockReturnValue(false); + capacitorMocks.nativeRequest.mockReset(); +}); + +describe('local mindroom api', () => { + it('starts pairing and returns pair code payload', async () => { + const request = vi.fn().mockResolvedValue( + createResponse(200, { + pair_code: 'ABC123', + expires_at: '2026-02-27T13:00:00.000Z', + poll_interval_seconds: 2, + }) + ); + + const data = await issueLocalMindroomPairCode(undefined, undefined, request as unknown as typeof fetch); + + expect(data.pair_code).toBe('ABC123'); + expect(request).toHaveBeenCalledWith('/v1/local-mindroom/pair/start', { + credentials: 'omit', + method: 'POST', + headers: { + Accept: 'application/json', + }, + }); + }); + + it('includes matrix access token header when provided', async () => { + const request = vi.fn().mockResolvedValue( + createResponse(200, { + pair_code: 'ABCD-EFGH', + expires_at: '2026-02-27T13:00:00.000Z', + poll_interval_seconds: 3, + }) + ); + + await issueLocalMindroomPairCode( + 'matrix-token-123', + undefined, + request as unknown as typeof fetch + ); + + expect(request).toHaveBeenCalledWith('/v1/local-mindroom/pair/start', { + credentials: 'omit', + method: 'POST', + headers: { + Accept: 'application/json', + 'X-Matrix-Access-Token': 'matrix-token-123', + }, + }); + }); + + it('handles pending -> connected status responses for polling', async () => { + const request = vi + .fn() + .mockResolvedValueOnce(createResponse(200, { status: 'pending' })) + .mockResolvedValueOnce( + createResponse(200, { + status: 'connected', + connection: { id: 'conn-1', client_name: 'MacBook Air' }, + }) + ); + + const pending = await getLocalMindroomPairStatus( + 'ABC123', + undefined, + undefined, + request as unknown as typeof fetch + ); + const connected = await getLocalMindroomPairStatus( + 'ABC123', + undefined, + undefined, + request as unknown as typeof fetch + ); + + expect(pending.status).toBe('pending'); + expect(connected.status).toBe('connected'); + }); + + it('revokes a linked connection', async () => { + const request = vi.fn().mockResolvedValue(createResponse(204)); + + await revokeLocalMindroomConnection( + 'conn-1', + undefined, + undefined, + request as unknown as typeof fetch + ); + + expect(request).toHaveBeenCalledWith('/v1/local-mindroom/connections/conn-1', { + credentials: 'omit', + method: 'DELETE', + headers: { + Accept: 'application/json', + }, + }); + }); + + it('surfaces api error message payloads', async () => { + const request = vi.fn().mockResolvedValue( + createResponse(400, { + detail: 'Invalid or expired pair code', + }) + ); + + await expect( + getLocalMindroomConnections(undefined, undefined, request as unknown as typeof fetch) + ).rejects.toThrow('Invalid or expired pair code'); + }); + + it('returns fallback message for unknown errors', () => { + expect(getLocalMindroomErrorMessage(null)).toBe('Request failed. Please try again.'); + }); + + it('supports provisioning base url override', async () => { + const request = vi.fn().mockResolvedValue( + createResponse(200, { + pair_code: 'ABCD-EFGH', + expires_at: '2026-02-27T13:00:00.000Z', + poll_interval_seconds: 3, + }) + ); + + await issueLocalMindroomPairCode( + 'matrix-token-123', + 'https://provisioning.example', + request as unknown as typeof fetch + ); + + expect(request).toHaveBeenCalledWith('https://provisioning.example/v1/local-mindroom/pair/start', { + credentials: 'omit', + method: 'POST', + headers: { + Accept: 'application/json', + 'X-Matrix-Access-Token': 'matrix-token-123', + }, + }); + }); + + it('throws clear error for non-json successful responses', async () => { + const request = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: vi.fn().mockRejectedValue(new SyntaxError('Unexpected token <')), + } as unknown as Response); + + await expect( + issueLocalMindroomPairCode(undefined, undefined, request as unknown as typeof fetch) + ).rejects.toThrow('Provisioning API returned invalid JSON. Verify provisioning URL/proxy configuration.'); + }); + + it('uses native http transport on native platforms when no custom request is provided', async () => { + capacitorMocks.isNativePlatform.mockReturnValue(true); + capacitorMocks.nativeRequest.mockResolvedValue({ + status: 200, + data: { + pair_code: 'ABCD-EFGH', + expires_at: '2026-02-27T13:00:00.000Z', + poll_interval_seconds: 3, + }, + headers: {}, + url: 'https://mindroom.chat/v1/local-mindroom/pair/start', + }); + + const data = await issueLocalMindroomPairCode( + 'matrix-token-123', + 'https://mindroom.chat' + ); + + expect(data.pair_code).toBe('ABCD-EFGH'); + expect(capacitorMocks.nativeRequest).toHaveBeenCalledWith({ + url: 'https://mindroom.chat/v1/local-mindroom/pair/start', + method: 'POST', + headers: { + Accept: 'application/json', + 'X-Matrix-Access-Token': 'matrix-token-123', + }, + responseType: 'json', + }); + }); + + it('replaces browser transport errors with a user-facing provisioning error', async () => { + const request = vi.fn().mockRejectedValue(new TypeError('Load failed')); + + await expect( + issueLocalMindroomPairCode(undefined, undefined, request as unknown as typeof fetch) + ).rejects.toThrow( + 'Unable to reach the provisioning API. Verify the server/proxy is reachable from this app.' + ); + }); +}); diff --git a/src/app/mindroom/local-mindroom/api.ts b/src/app/mindroom/local-mindroom/api.ts new file mode 100644 index 0000000000..6afac3585d --- /dev/null +++ b/src/app/mindroom/local-mindroom/api.ts @@ -0,0 +1,266 @@ +import { Capacitor, CapacitorHttp } from '@capacitor/core'; +import type { HttpHeaders, HttpOptions, HttpResponse } from '@capacitor/core'; + +export type LocalMindroomConnection = { + id?: string; + client_name?: string; + created_at?: string; + last_seen_at?: string; + [key: string]: unknown; +}; + +export type LocalMindroomPairStartResponse = { + pair_code: string; + expires_at: string; + poll_interval_seconds: number; +}; + +export type LocalMindroomPairStatusResponse = + | { + status: 'pending'; + expires_at?: string; + } + | { + status: 'connected'; + connection?: LocalMindroomConnection; + } + | { + status: 'expired'; + }; + +export type LocalMindroomConnectionsResponse = { + connections: LocalMindroomConnection[]; +}; + +const LOCAL_MINDROOM_API_PATH = '/v1/local-mindroom'; +const LOCAL_MINDROOM_TRANSPORT_ERROR = + 'Unable to reach the provisioning API. Verify the server/proxy is reachable from this app.'; + +export class LocalMindroomApiError extends Error { + status: number; + + constructor(message: string, status: number) { + super(message); + this.name = 'LocalMindroomApiError'; + this.status = status; + } +} + +const toErrorMessage = async (response: Response): Promise => { + try { + const payload = (await response.json()) as unknown; + if (payload && typeof payload === 'object') { + if ('message' in payload && typeof payload.message === 'string') return payload.message; + if ('error' in payload && typeof payload.error === 'string') return payload.error; + if ('detail' in payload && typeof payload.detail === 'string') return payload.detail; + } + } catch { + // Ignore JSON parse failures and fallback to status-based message. + } + + return `Request failed (${response.status})`; +}; + +const toPayloadErrorMessage = (payload: unknown, status: number): string => { + if (payload && typeof payload === 'object') { + if ('message' in payload && typeof payload.message === 'string') return payload.message; + if ('error' in payload && typeof payload.error === 'string') return payload.error; + if ('detail' in payload && typeof payload.detail === 'string') return payload.detail; + } + + if (typeof payload === 'string' && payload.trim()) return payload; + return `Request failed (${status})`; +}; + +const getApiBaseUrl = (provisioningBaseUrl?: string): string => { + const baseUrl = provisioningBaseUrl?.trim(); + if (!baseUrl) return LOCAL_MINDROOM_API_PATH; + return `${baseUrl.replace(/\/+$/, '')}${LOCAL_MINDROOM_API_PATH}`; +}; + +const shouldUseNativeHttp = (request?: typeof fetch): boolean => + request === undefined && Capacitor.isNativePlatform(); + +const toHttpHeaders = (headers?: HeadersInit): HttpHeaders => { + if (!headers) return {}; + + if (headers instanceof Headers) { + return Object.fromEntries(headers.entries()); + } + + if (Array.isArray(headers)) { + return Object.fromEntries(headers); + } + + return Object.fromEntries( + Object.entries(headers).map(([key, value]) => [key, value?.toString() ?? '']) + ); +}; + +const toNativeHttpOptions = (url: string, init: RequestInit): HttpOptions => ({ + url, + method: init.method, + headers: toHttpHeaders(init.headers), + responseType: 'json', +}); + +const parseNativeJson = (response: HttpResponse): T => { + const { data } = response; + if (typeof data === 'string') { + try { + return JSON.parse(data) as T; + } catch { + throw new LocalMindroomApiError( + 'Provisioning API returned invalid JSON. Verify provisioning URL/proxy configuration.', + response.status + ); + } + } + + return data as T; +}; + +const wrapTransportError = (error: unknown): LocalMindroomApiError => { + if (error instanceof LocalMindroomApiError) return error; + return new LocalMindroomApiError(LOCAL_MINDROOM_TRANSPORT_ERROR, 0); +}; + +const requestJson = async ( + request: typeof fetch | undefined, + url: string, + init: RequestInit +): Promise => { + try { + if (shouldUseNativeHttp(request)) { + const response = await CapacitorHttp.request(toNativeHttpOptions(url, init)); + if (response.status < 200 || response.status >= 300) { + throw new LocalMindroomApiError( + toPayloadErrorMessage(response.data, response.status), + response.status + ); + } + + return parseNativeJson(response); + } + + const requestFn = request ?? fetch; + const response = await requestFn(url, { + credentials: 'omit', + ...init, + }); + + if (!response.ok) { + throw new LocalMindroomApiError(await toErrorMessage(response), response.status); + } + + try { + return (await response.json()) as T; + } catch { + throw new LocalMindroomApiError( + 'Provisioning API returned invalid JSON. Verify provisioning URL/proxy configuration.', + response.status + ); + } + } catch (error) { + throw wrapTransportError(error); + } +}; + +const requestNoContent = async ( + request: typeof fetch | undefined, + url: string, + init: RequestInit +) => { + try { + if (shouldUseNativeHttp(request)) { + const response = await CapacitorHttp.request(toNativeHttpOptions(url, init)); + if (response.status < 200 || response.status >= 300) { + throw new LocalMindroomApiError( + toPayloadErrorMessage(response.data, response.status), + response.status + ); + } + return; + } + + const requestFn = request ?? fetch; + const response = await requestFn(url, { + credentials: 'omit', + ...init, + }); + + if (!response.ok) { + throw new LocalMindroomApiError(await toErrorMessage(response), response.status); + } + } catch (error) { + throw wrapTransportError(error); + } +}; + +const browserAuthHeaders = (accessToken?: string): HeadersInit => ({ + Accept: 'application/json', + ...(accessToken ? { 'X-Matrix-Access-Token': accessToken } : {}), +}); + +export const issueLocalMindroomPairCode = async ( + accessToken?: string, + provisioningBaseUrl?: string, + request?: typeof fetch +): Promise => + requestJson( + request, + `${getApiBaseUrl(provisioningBaseUrl)}/pair/start`, + { + method: 'POST', + headers: browserAuthHeaders(accessToken), + } + ); + +export const getLocalMindroomPairStatus = async ( + pairCode: string, + accessToken?: string, + provisioningBaseUrl?: string, + request?: typeof fetch +): Promise => + requestJson( + request, + `${getApiBaseUrl(provisioningBaseUrl)}/pair/status?pair_code=${encodeURIComponent(pairCode)}`, + { + method: 'GET', + headers: browserAuthHeaders(accessToken), + } + ); + +export const getLocalMindroomConnections = async ( + accessToken?: string, + provisioningBaseUrl?: string, + request?: typeof fetch +): Promise => + requestJson( + request, + `${getApiBaseUrl(provisioningBaseUrl)}/connections`, + { + method: 'GET', + headers: browserAuthHeaders(accessToken), + } + ); + +export const revokeLocalMindroomConnection = async ( + connectionId: string, + accessToken?: string, + provisioningBaseUrl?: string, + request?: typeof fetch +): Promise => + requestNoContent( + request, + `${getApiBaseUrl(provisioningBaseUrl)}/connections/${encodeURIComponent(connectionId)}`, + { + method: 'DELETE', + headers: browserAuthHeaders(accessToken), + } + ); + +export const getLocalMindroomErrorMessage = (error: unknown): string => { + if (error instanceof Error && error.message) return error.message; + return 'Request failed. Please try again.'; +}; diff --git a/src/app/mindroom/local-mindroom/mindroom.test.ts b/src/app/mindroom/local-mindroom/mindroom.test.ts new file mode 100644 index 0000000000..ebd5d294f1 --- /dev/null +++ b/src/app/mindroom/local-mindroom/mindroom.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_MINDROOM_DOCS_URL, + getConnectionRevokedAt, + getMindroomDocsUrl, + getMindroomPairingCommand, + getPairingSecondsRemaining, + getWelcomeSetupFirstSeenStorageKey, + isConnectionRevoked, + resolveMindroomProvisioningRequest, + shouldShowWelcomeSetupPrompt, +} from './mindroom'; + +describe('local mindroom helpers', () => { + it('falls back to default docs url when value is empty', () => { + expect(getMindroomDocsUrl(' ')).toBe(DEFAULT_MINDROOM_DOCS_URL); + }); + + it('builds expected pairing command', () => { + expect(getMindroomPairingCommand('ABC123')).toBe('uvx mindroom connect --pair-code ABC123'); + }); + + it('returns 0 seconds for expired pairing windows', () => { + const nowMs = Date.parse('2026-02-27T12:00:00.000Z'); + expect(getPairingSecondsRemaining('2026-02-27T11:59:59.000Z', nowMs)).toBe(0); + }); + + it('uses active session homeserver origin for provisioning by default', () => { + const result = resolveMindroomProvisioningRequest({ + sessionHomeserverUrl: 'https://matrix.example.org', + accessToken: 'session-token', + }); + + expect(result).toEqual({ + provisioningBaseUrl: 'https://matrix.example.org', + accessToken: 'session-token', + }); + expect(result.provisioningBaseUrl).not.toBe('https://mindroom.chat'); + }); + + it('does not forward token to mismatched override origin by default', () => { + const result = resolveMindroomProvisioningRequest({ + sessionHomeserverUrl: 'https://matrix.example.org', + provisioningOverrideUrl: 'https://provisioning.other.example', + accessToken: 'session-token', + }); + + expect(result.provisioningBaseUrl).toBe('https://provisioning.other.example'); + expect(result.accessToken).toBeUndefined(); + expect(result.warning).toContain('Access token forwarding is blocked by default'); + }); + + it('forwards token when provisioning origin matches session homeserver origin', () => { + const result = resolveMindroomProvisioningRequest({ + sessionHomeserverUrl: 'https://matrix.example.org', + provisioningOverrideUrl: 'https://matrix.example.org', + accessToken: 'session-token', + }); + + expect(result).toEqual({ + provisioningBaseUrl: 'https://matrix.example.org', + accessToken: 'session-token', + }); + }); + + it('reads revoked timestamp from snake_case responses', () => { + expect(getConnectionRevokedAt({ revoked_at: '2026-02-28T12:00:00.000Z' })).toBe( + '2026-02-28T12:00:00.000Z' + ); + expect(isConnectionRevoked({ revoked_at: '2026-02-28T12:00:00.000Z' })).toBe(true); + }); + + it('reads revoked timestamp from camelCase responses', () => { + expect(getConnectionRevokedAt({ revokedAt: '2026-02-28T12:00:00.000Z' })).toBe( + '2026-02-28T12:00:00.000Z' + ); + expect(isConnectionRevoked({ revokedAt: '2026-02-28T12:00:00.000Z' })).toBe(true); + }); + + it('treats active connections as not revoked', () => { + expect(getConnectionRevokedAt({ id: 'conn-1' })).toBeUndefined(); + expect(isConnectionRevoked({ id: 'conn-1' })).toBe(false); + }); + + it('scopes welcome setup prompt first-seen storage by user id', () => { + expect(getWelcomeSetupFirstSeenStorageKey('@alice:mindroom.chat')).toBe( + 'mindroom_welcome_setup_first_seen_at::@alice:mindroom.chat' + ); + }); + + it('shows welcome setup prompt only after one day without active connections', () => { + const firstSeenAtMs = Date.parse('2026-05-13T12:00:00.000Z'); + + expect( + shouldShowWelcomeSetupPrompt({ + activeConnectionCount: 0, + firstSeenAtMs, + nowMs: Date.parse('2026-05-14T12:00:00.000Z'), + }) + ).toBe(true); + expect( + shouldShowWelcomeSetupPrompt({ + activeConnectionCount: 0, + firstSeenAtMs, + nowMs: Date.parse('2026-05-14T11:59:59.000Z'), + }) + ).toBe(false); + expect( + shouldShowWelcomeSetupPrompt({ + activeConnectionCount: 1, + firstSeenAtMs, + nowMs: Date.parse('2026-05-15T12:00:00.000Z'), + }) + ).toBe(false); + expect( + shouldShowWelcomeSetupPrompt({ + activeConnectionCount: 0, + firstSeenAtMs: undefined, + nowMs: Date.parse('2026-05-15T12:00:00.000Z'), + }) + ).toBe(false); + }); +}); diff --git a/src/app/mindroom/local-mindroom/mindroom.ts b/src/app/mindroom/local-mindroom/mindroom.ts new file mode 100644 index 0000000000..ba4445f966 --- /dev/null +++ b/src/app/mindroom/local-mindroom/mindroom.ts @@ -0,0 +1,154 @@ +import { LocalMindroomConnection } from './api'; + +export const DEFAULT_MINDROOM_DOCS_URL = 'https://docs.mindroom.chat/'; +const WELCOME_SETUP_PROMPT_DELAY_MS = 24 * 60 * 60 * 1000; + +export const getMindroomDocsUrl = (url?: string): string => + url?.trim() || DEFAULT_MINDROOM_DOCS_URL; + +type ResolveProvisioningRequest = { + sessionHomeserverUrl?: string; + provisioningOverrideUrl?: string; + accessToken?: string; +}; + +export type LocalMindroomProvisioningRequest = { + provisioningBaseUrl?: string; + accessToken?: string; + warning?: string; +}; + +const getOrigin = (url?: string): string | undefined => { + const value = url?.trim(); + if (!value) return undefined; + + try { + return new URL(value).origin; + } catch { + return undefined; + } +}; + +export const resolveMindroomProvisioningRequest = ({ + sessionHomeserverUrl, + provisioningOverrideUrl, + accessToken, +}: ResolveProvisioningRequest): LocalMindroomProvisioningRequest => { + const sessionOrigin = getOrigin(sessionHomeserverUrl); + const overrideOrigin = getOrigin(provisioningOverrideUrl); + const provisioningBaseUrl = overrideOrigin ?? sessionOrigin; + + if (!provisioningBaseUrl) return {}; + + const isCrossOriginOverride = + overrideOrigin !== undefined && sessionOrigin !== undefined && overrideOrigin !== sessionOrigin; + + if (isCrossOriginOverride) { + return { + provisioningBaseUrl, + warning: + 'Provisioning URL uses a different origin than your active homeserver. Access token forwarding is blocked by default for safety.', + }; + } + + return { + provisioningBaseUrl, + accessToken, + }; +}; + +export const getMindroomPairingCommand = (pairCode: string): string => + `uvx mindroom connect --pair-code ${pairCode}`; + +export const getWelcomeSetupFirstSeenStorageKey = (userId: string): string => + `mindroom_welcome_setup_first_seen_at::${userId}`; + +export type WelcomeSetupPromptState = { + activeConnectionCount: number; + firstSeenAtMs: number | undefined; + nowMs?: number; +}; + +export const shouldShowWelcomeSetupPrompt = ({ + activeConnectionCount, + firstSeenAtMs, + nowMs = Date.now(), +}: WelcomeSetupPromptState): boolean => + activeConnectionCount === 0 && + firstSeenAtMs !== undefined && + nowMs - firstSeenAtMs >= WELCOME_SETUP_PROMPT_DELAY_MS; + +export const getPairingSecondsRemaining = ( + expiresAt: string, + nowMs: number = Date.now() +): number => { + const expiresAtMs = Date.parse(expiresAt); + if (Number.isNaN(expiresAtMs)) return 0; + return Math.max(0, Math.ceil((expiresAtMs - nowMs) / 1000)); +}; + +export const getConnectionId = (connection: LocalMindroomConnection): string | undefined => { + if (typeof connection.id === 'string' && connection.id.length > 0) return connection.id; + + const rawId = connection.connection_id; + if (typeof rawId === 'string' && rawId.length > 0) return rawId; + + const numericId = connection.connection_id; + if (typeof numericId === 'number') return numericId.toString(); + + return undefined; +}; + +export const getConnectionName = (connection: LocalMindroomConnection, index: number): string => { + if (typeof connection.client_name === 'string' && connection.client_name.trim()) { + return connection.client_name; + } + + const altName = connection.clientName; + if (typeof altName === 'string' && altName.trim()) return altName; + + return `Local MindRoom ${index + 1}`; +}; + +export const getConnectionCreatedAt = (connection: LocalMindroomConnection): string | undefined => { + if (typeof connection.created_at === 'string' && connection.created_at) + return connection.created_at; + + const alt = connection.createdAt; + if (typeof alt === 'string' && alt) return alt; + + return undefined; +}; + +export const getConnectionLastSeenAt = ( + connection: LocalMindroomConnection +): string | undefined => { + if (typeof connection.last_seen_at === 'string' && connection.last_seen_at) { + return connection.last_seen_at; + } + + const alt = connection.lastSeenAt; + if (typeof alt === 'string' && alt) return alt; + + return undefined; +}; + +export const getConnectionRevokedAt = (connection: LocalMindroomConnection): string | undefined => { + const direct = connection.revoked_at; + if (typeof direct === 'string' && direct) return direct; + + const alt = connection.revokedAt; + if (typeof alt === 'string' && alt) return alt; + + return undefined; +}; + +export const isConnectionRevoked = (connection: LocalMindroomConnection): boolean => + getConnectionRevokedAt(connection) !== undefined; + +export const formatLocalTimestamp = (timestamp?: string): string => { + if (!timestamp) return 'Unknown'; + const date = new Date(timestamp); + if (Number.isNaN(date.valueOf())) return 'Unknown'; + return date.toLocaleString(); +}; diff --git a/src/app/mindroom/local-mindroom/settingsIntegration.test.ts b/src/app/mindroom/local-mindroom/settingsIntegration.test.ts new file mode 100644 index 0000000000..95f49e24e9 --- /dev/null +++ b/src/app/mindroom/local-mindroom/settingsIntegration.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { ScreenSize } from '../../hooks/useScreenSize'; +import { SettingsPages } from '../../features/settings/settingsPages'; +import { getSettingsMenuItems, resolveSettingsInitialPage } from '../../features/settings/settingsMenu'; +import { LOCAL_MINDROOM_SETTINGS_PAGE } from './settingsPage'; + +describe('getSettingsMenuItems', () => { + it('omits the Local MindRoom entry when disabled', () => { + const items = getSettingsMenuItems(false); + + expect(items.some((item) => item.page === LOCAL_MINDROOM_SETTINGS_PAGE)).toBe(false); + }); + + it('keeps the Local MindRoom entry when enabled', () => { + const items = getSettingsMenuItems(true); + + expect(items.find((item) => item.page === LOCAL_MINDROOM_SETTINGS_PAGE)).toMatchObject({ + name: 'Local MindRoom', + }); + }); +}); + +describe('resolveSettingsInitialPage', () => { + it('preserves an explicit General page request', () => { + expect( + resolveSettingsInitialPage(SettingsPages.GeneralPage, ScreenSize.Desktop, false) + ).toBe(SettingsPages.GeneralPage); + }); + + it('falls back to General when Local MindRoom is requested but disabled', () => { + expect( + resolveSettingsInitialPage( + LOCAL_MINDROOM_SETTINGS_PAGE, + ScreenSize.Desktop, + false + ) + ).toBe(SettingsPages.GeneralPage); + }); + + it('keeps mobile settings closed when no initial page is requested', () => { + expect(resolveSettingsInitialPage(undefined, ScreenSize.Mobile, false)).toBeUndefined(); + }); +}); diff --git a/src/app/mindroom/local-mindroom/settingsMenu.test.ts b/src/app/mindroom/local-mindroom/settingsMenu.test.ts new file mode 100644 index 0000000000..1a24905121 --- /dev/null +++ b/src/app/mindroom/local-mindroom/settingsMenu.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import { getLocalMindroomSettingsMenuItem } from './settingsMenu'; +import { LOCAL_MINDROOM_SETTINGS_PAGE } from './settingsPage'; + +describe('getLocalMindroomSettingsMenuItem', () => { + it('owns the Local MindRoom settings menu entry', () => { + expect(getLocalMindroomSettingsMenuItem()).toMatchObject({ + page: LOCAL_MINDROOM_SETTINGS_PAGE, + name: 'Local MindRoom', + }); + }); +}); diff --git a/src/app/mindroom/local-mindroom/settingsMenu.ts b/src/app/mindroom/local-mindroom/settingsMenu.ts new file mode 100644 index 0000000000..e3facd3570 --- /dev/null +++ b/src/app/mindroom/local-mindroom/settingsMenu.ts @@ -0,0 +1,19 @@ +import { Icons, type IconSrc } from 'folds'; +import { MINDROOM_APP_NAME } from '../branding/branding'; +import { LOCAL_MINDROOM_SETTINGS_PAGE, type LocalMindroomSettingsPage } from './settingsPage'; + +export type LocalMindroomSettingsMenuItem = { + page: LocalMindroomSettingsPage; + name: string; + icon: IconSrc; +}; + +export const getLocalMindroomSettingsMenuItem = (): LocalMindroomSettingsMenuItem => ({ + page: LOCAL_MINDROOM_SETTINGS_PAGE, + name: `Local ${MINDROOM_APP_NAME}`, + icon: Icons.Link, +}); + +export const getLocalMindroomSettingsMenuItems = ( + enabled: boolean +): LocalMindroomSettingsMenuItem[] => (enabled ? [getLocalMindroomSettingsMenuItem()] : []); diff --git a/src/app/mindroom/local-mindroom/settingsPage.ts b/src/app/mindroom/local-mindroom/settingsPage.ts new file mode 100644 index 0000000000..603251a870 --- /dev/null +++ b/src/app/mindroom/local-mindroom/settingsPage.ts @@ -0,0 +1,20 @@ +import { SettingsPages, type SettingsPage } from '../../features/settings/settingsPages'; + +export const LOCAL_MINDROOM_SETTINGS_PAGE = 'mindroom.local-mindroom' as const; + +export type LocalMindroomSettingsPage = typeof LOCAL_MINDROOM_SETTINGS_PAGE; + +export const isLocalMindroomSettingsPage = ( + page: SettingsPage | undefined +): page is LocalMindroomSettingsPage => page === LOCAL_MINDROOM_SETTINGS_PAGE; + +export const resolveLocalMindroomInitialSettingsPage = ( + initialPage: SettingsPage | undefined, + enabled: boolean +): SettingsPage | undefined => { + if (!enabled && isLocalMindroomSettingsPage(initialPage)) { + return SettingsPages.GeneralPage; + } + + return initialPage; +}; diff --git a/src/app/mindroom/local-mindroom/settingsRenderer.tsx b/src/app/mindroom/local-mindroom/settingsRenderer.tsx new file mode 100644 index 0000000000..c2cd16b626 --- /dev/null +++ b/src/app/mindroom/local-mindroom/settingsRenderer.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { type SettingsPage } from '../../features/settings/settingsPages'; +import { LocalMindroom } from './LocalMindroom'; +import { isLocalMindroomSettingsPage } from './settingsPage'; + +export const renderLocalMindroomSettingsPage = ( + activePage: SettingsPage | undefined, + enabled: boolean, + requestClose: () => void +): React.ReactNode => { + if (!enabled || !isLocalMindroomSettingsPage(activePage)) return null; + + return ; +}; diff --git a/src/app/mindroom/matrix/matrixClientFactory.test.ts b/src/app/mindroom/matrix/matrixClientFactory.test.ts new file mode 100644 index 0000000000..c6937a823c --- /dev/null +++ b/src/app/mindroom/matrix/matrixClientFactory.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createMatrixFetchFn } from './matrixClientFactory'; + +describe('createMatrixFetchFn', () => { + it('adds credentials for same-origin requests', async () => { + const baseFetch = vi.fn().mockResolvedValue({ ok: true }); + const originalLocation = (globalThis as { location?: Location }).location; + + try { + (globalThis as { location?: Location }).location = { + origin: 'https://example.com', + } as Location; + + const fetchFn = createMatrixFetchFn(baseFetch as unknown as typeof fetch); + await fetchFn('/_matrix/client', { method: 'GET' }); + } finally { + (globalThis as { location?: Location }).location = originalLocation; + } + + expect(baseFetch).toHaveBeenCalledWith('/_matrix/client', { + method: 'GET', + credentials: 'include', + }); + }); + + it('does not add credentials for cross-origin requests', async () => { + const baseFetch = vi.fn().mockResolvedValue({ ok: true }); + const originalLocation = (globalThis as { location?: Location }).location; + + try { + (globalThis as { location?: Location }).location = { + origin: 'https://example.com', + } as Location; + + const fetchFn = createMatrixFetchFn(baseFetch as unknown as typeof fetch); + await fetchFn('https://other.example.com/_matrix/client', { method: 'GET' }); + } finally { + (globalThis as { location?: Location }).location = originalLocation; + } + + expect(baseFetch).toHaveBeenCalledWith('https://other.example.com/_matrix/client', { + method: 'GET', + }); + }); +}); diff --git a/src/app/mindroom/matrix/matrixClientFactory.ts b/src/app/mindroom/matrix/matrixClientFactory.ts new file mode 100644 index 0000000000..38d46ee48d --- /dev/null +++ b/src/app/mindroom/matrix/matrixClientFactory.ts @@ -0,0 +1,50 @@ +import { createClient, type ICreateClientOpts } from 'matrix-js-sdk'; + +type MindroomCreateClientOpts = ICreateClientOpts & { + threadSupport?: boolean; +}; + +type RuntimeLocation = { + origin: string; +}; + +const getRuntimeOrigin = (): string | undefined => { + if (typeof globalThis === 'undefined') return undefined; + const { location } = globalThis as { location?: RuntimeLocation }; + if (!location?.origin) return undefined; + return location.origin; +}; + +const resolveRequestUrl = (input: Parameters[0]): URL | null => { + try { + if (typeof Request !== 'undefined' && input instanceof Request) return new URL(input.url); + if (input instanceof URL) return input; + return new URL(String(input), getRuntimeOrigin()); + } catch { + return null; + } +}; + +const isSameOriginRequest = (input: Parameters[0]): boolean => { + const origin = getRuntimeOrigin(); + if (!origin) return false; + const url = resolveRequestUrl(input); + if (!url) return false; + return url.origin === origin; +}; + +export const createMatrixFetchFn = ( + baseFetch: typeof globalThis.fetch = globalThis.fetch +): typeof globalThis.fetch => (input, init) => + !isSameOriginRequest(input) + ? baseFetch(input, init) + : baseFetch(input, { + ...init, + credentials: 'include', + }); + +export const createMatrixClient = (options: MindroomCreateClientOpts) => + createClient({ + ...options, + fetchFn: createMatrixFetchFn(options.fetchFn ?? globalThis.fetch), + } as ICreateClientOpts); diff --git a/src/app/mindroom/notifications/MindroomMarkRoomReadChip.test.ts b/src/app/mindroom/notifications/MindroomMarkRoomReadChip.test.ts new file mode 100644 index 0000000000..6baee83c81 --- /dev/null +++ b/src/app/mindroom/notifications/MindroomMarkRoomReadChip.test.ts @@ -0,0 +1,73 @@ +import React from 'react'; +import { act, create } from 'react-test-renderer'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const { state, markRoomAndThreadsAsReadMock } = vi.hoisted(() => ({ + state: { + hideActivity: false, + mx: { clientId: 'mx' }, + }, + markRoomAndThreadsAsReadMock: vi.fn(), +})); + +vi.mock('folds', async () => { + const reactModule = await import('react'); + + return { + Chip: ({ + children, + onClick, + }: { + children: React.ReactNode; + onClick?: () => void; + }) => reactModule.createElement('button', { onClick, type: 'button' }, children), + Icon: ({ src }: { src: string }) => reactModule.createElement('span', { 'data-icon': src }), + Icons: { + CheckTwice: 'CheckTwice', + }, + Text: ({ children }: { children: React.ReactNode }) => + reactModule.createElement('span', null, children), + }; +}); + +vi.mock('../../hooks/useMatrixClient', () => ({ + useMatrixClient: () => state.mx, +})); + +vi.mock('../../state/hooks/settings', () => ({ + useSetting: () => [state.hideActivity], +})); + +vi.mock('../../state/settings', () => ({ + settingsAtom: {}, +})); + +vi.mock('./readReceipts', () => ({ + markRoomAndThreadsAsRead: markRoomAndThreadsAsReadMock, +})); + +afterEach(() => { + state.hideActivity = false; + markRoomAndThreadsAsReadMock.mockReset(); +}); + +describe('MindroomMarkRoomReadChip', () => { + it('marks the room and its threads as read', async () => { + state.hideActivity = true; + const { MindroomMarkRoomReadChip } = await import('./MindroomMarkRoomReadChip'); + const renderer = create( + React.createElement(MindroomMarkRoomReadChip, { roomId: '!room:example.org' }) + ); + const button = renderer.root.findByType('button'); + + act(() => { + button.props.onClick(); + }); + + expect(markRoomAndThreadsAsReadMock).toHaveBeenCalledWith( + state.mx, + '!room:example.org', + true + ); + }); +}); diff --git a/src/app/mindroom/notifications/MindroomMarkRoomReadChip.tsx b/src/app/mindroom/notifications/MindroomMarkRoomReadChip.tsx new file mode 100644 index 0000000000..0d7daef79e --- /dev/null +++ b/src/app/mindroom/notifications/MindroomMarkRoomReadChip.tsx @@ -0,0 +1,30 @@ +import React, { useCallback } from 'react'; +import { Chip, Icon, Icons, Text } from 'folds'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useSetting } from '../../state/hooks/settings'; +import { settingsAtom } from '../../state/settings'; +import { markRoomAndThreadsAsRead } from './readReceipts'; + +type MindroomMarkRoomReadChipProps = { + roomId: string; +}; + +export function MindroomMarkRoomReadChip({ roomId }: MindroomMarkRoomReadChipProps) { + const mx = useMatrixClient(); + const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); + + const handleMarkAsRead = useCallback(() => { + void markRoomAndThreadsAsRead(mx, roomId, hideActivity); + }, [hideActivity, mx, roomId]); + + return ( + } + > + Mark as Read + + ); +} diff --git a/src/app/mindroom/notifications/MindroomMarkRoomReadMenuItem.test.ts b/src/app/mindroom/notifications/MindroomMarkRoomReadMenuItem.test.ts new file mode 100644 index 0000000000..7c393e48bb --- /dev/null +++ b/src/app/mindroom/notifications/MindroomMarkRoomReadMenuItem.test.ts @@ -0,0 +1,108 @@ +import React from 'react'; +import type { Room } from 'matrix-js-sdk'; +import { act, create } from 'react-test-renderer'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const { state, markRoomAndThreadsAsReadMock } = vi.hoisted(() => ({ + state: { + hideActivity: false, + mx: { clientId: 'mx' }, + unread: true, + }, + markRoomAndThreadsAsReadMock: vi.fn(), +})); + +vi.mock('folds', async () => { + const reactModule = await import('react'); + + return { + Icon: ({ src }: { src: string }) => reactModule.createElement('span', { 'data-icon': src }), + Icons: { + CheckTwice: 'CheckTwice', + }, + MenuItem: ({ + children, + 'aria-disabled': ariaDisabled, + disabled, + onClick, + }: { + children: React.ReactNode; + 'aria-disabled'?: boolean; + disabled?: boolean; + onClick?: () => void; + }) => + reactModule.createElement( + 'button', + { 'aria-disabled': ariaDisabled, disabled, onClick, type: 'button' }, + children + ), + Text: ({ children }: { children: React.ReactNode }) => + reactModule.createElement('span', null, children), + }; +}); + +vi.mock('../../hooks/useMatrixClient', () => ({ + useMatrixClient: () => state.mx, +})); + +vi.mock('../../state/hooks/settings', () => ({ + useSetting: () => [state.hideActivity], +})); + +vi.mock('../../state/settings', () => ({ + settingsAtom: {}, +})); + +vi.mock('../../state/hooks/unread', () => ({ + useRoomUnread: () => state.unread, +})); + +vi.mock('../../state/room/roomToUnread', () => ({ + roomToUnreadAtom: {}, +})); + +vi.mock('./readReceipts', () => ({ + markRoomAndThreadsAsRead: markRoomAndThreadsAsReadMock, +})); + +const room = { roomId: '!room:example.org' } as Room; + +afterEach(() => { + state.hideActivity = false; + state.unread = true; + markRoomAndThreadsAsReadMock.mockReset(); +}); + +describe('MindroomMarkRoomReadMenuItem', () => { + it('marks the room and its threads as read, then closes the menu', async () => { + const onClose = vi.fn(); + state.hideActivity = true; + const { MindroomMarkRoomReadMenuItem } = await import('./MindroomMarkRoomReadMenuItem'); + const renderer = create(React.createElement(MindroomMarkRoomReadMenuItem, { room, onClose })); + const button = renderer.root.findByType('button'); + + act(() => { + button.props.onClick(); + }); + + expect(markRoomAndThreadsAsReadMock).toHaveBeenCalledWith(state.mx, '!room:example.org', true); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('keeps the unavailable action tabbable and does nothing when the room has no unread state', async () => { + const onClose = vi.fn(); + state.unread = false; + const { MindroomMarkRoomReadMenuItem } = await import('./MindroomMarkRoomReadMenuItem'); + const renderer = create(React.createElement(MindroomMarkRoomReadMenuItem, { room, onClose })); + const button = renderer.root.findByType('button'); + + act(() => { + button.props.onClick(); + }); + + expect(button.props.disabled).toBeUndefined(); + expect(button.props['aria-disabled']).toBe(true); + expect(markRoomAndThreadsAsReadMock).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/mindroom/notifications/MindroomMarkRoomReadMenuItem.tsx b/src/app/mindroom/notifications/MindroomMarkRoomReadMenuItem.tsx new file mode 100644 index 0000000000..ca01f083f2 --- /dev/null +++ b/src/app/mindroom/notifications/MindroomMarkRoomReadMenuItem.tsx @@ -0,0 +1,40 @@ +import React, { useCallback } from 'react'; +import type { Room } from 'matrix-js-sdk'; +import { Icon, Icons, MenuItem, Text } from 'folds'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useRoomUnread } from '../../state/hooks/unread'; +import { useSetting } from '../../state/hooks/settings'; +import { roomToUnreadAtom } from '../../state/room/roomToUnread'; +import { settingsAtom } from '../../state/settings'; +import { markRoomAndThreadsAsRead } from './readReceipts'; + +type MindroomMarkRoomReadMenuItemProps = { + onClose: () => void; + room: Room; +}; + +export function MindroomMarkRoomReadMenuItem({ onClose, room }: MindroomMarkRoomReadMenuItemProps) { + const mx = useMatrixClient(); + const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); + const unread = useRoomUnread(room.roomId, roomToUnreadAtom); + + const handleMarkAsRead = useCallback(() => { + if (!unread) return; + void markRoomAndThreadsAsRead(mx, room.roomId, hideActivity); + onClose(); + }, [hideActivity, mx, onClose, room.roomId, unread]); + + return ( + } + radii="300" + aria-disabled={!unread} + > + + Mark as Read + + + ); +} diff --git a/src/app/mindroom/notifications/MindroomMarkRoomsReadMenuItem.test.ts b/src/app/mindroom/notifications/MindroomMarkRoomsReadMenuItem.test.ts new file mode 100644 index 0000000000..e6e9617030 --- /dev/null +++ b/src/app/mindroom/notifications/MindroomMarkRoomsReadMenuItem.test.ts @@ -0,0 +1,126 @@ +import React from 'react'; +import { act, create } from 'react-test-renderer'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const { state, markRoomAndThreadsAsReadMock } = vi.hoisted(() => ({ + state: { + hideActivity: false, + mx: { clientId: 'mx' }, + unread: { total: 2, highlight: 0, from: new Set() }, + }, + markRoomAndThreadsAsReadMock: vi.fn(), +})); + +vi.mock('folds', async () => { + const reactModule = await import('react'); + + return { + Icon: ({ src }: { src: string }) => reactModule.createElement('span', { 'data-icon': src }), + Icons: { + CheckTwice: 'CheckTwice', + }, + MenuItem: ({ + children, + 'aria-disabled': ariaDisabled, + disabled, + onClick, + }: { + children: React.ReactNode; + 'aria-disabled'?: boolean; + disabled?: boolean; + onClick?: () => void; + }) => + reactModule.createElement( + 'button', + { 'aria-disabled': ariaDisabled, disabled, onClick, type: 'button' }, + children + ), + Text: ({ children }: { children: React.ReactNode }) => + reactModule.createElement('span', null, children), + }; +}); + +vi.mock('../../hooks/useMatrixClient', () => ({ + useMatrixClient: () => state.mx, +})); + +vi.mock('../../state/hooks/settings', () => ({ + useSetting: () => [state.hideActivity], +})); + +vi.mock('../../state/settings', () => ({ + settingsAtom: {}, +})); + +vi.mock('../../state/hooks/unread', () => ({ + useRoomsUnread: () => state.unread, +})); + +vi.mock('../../state/room/roomToUnread', () => ({ + roomToUnreadAtom: {}, +})); + +vi.mock('./readReceipts', () => ({ + markRoomAndThreadsAsRead: markRoomAndThreadsAsReadMock, +})); + +afterEach(() => { + state.hideActivity = false; + state.unread = { total: 2, highlight: 0, from: new Set() }; + markRoomAndThreadsAsReadMock.mockReset(); +}); + +describe('MindroomMarkRoomsReadMenuItem', () => { + it('marks every listed room and its threads as read, then closes the menu', async () => { + const onClose = vi.fn(); + state.hideActivity = true; + const { MindroomMarkRoomsReadMenuItem } = await import('./MindroomMarkRoomsReadMenuItem'); + const renderer = create( + React.createElement(MindroomMarkRoomsReadMenuItem, { + roomIds: ['!a:example.org', '!b:example.org'], + onClose, + }) + ); + const button = renderer.root.findByType('button'); + + act(() => { + button.props.onClick(); + }); + + expect(markRoomAndThreadsAsReadMock).toHaveBeenNthCalledWith( + 1, + state.mx, + '!a:example.org', + true + ); + expect(markRoomAndThreadsAsReadMock).toHaveBeenNthCalledWith( + 2, + state.mx, + '!b:example.org', + true + ); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('keeps the unavailable action tabbable and does nothing when no listed room is unread', async () => { + const onClose = vi.fn(); + state.unread = undefined; + const { MindroomMarkRoomsReadMenuItem } = await import('./MindroomMarkRoomsReadMenuItem'); + const renderer = create( + React.createElement(MindroomMarkRoomsReadMenuItem, { + roomIds: ['!a:example.org'], + onClose, + }) + ); + const button = renderer.root.findByType('button'); + + act(() => { + button.props.onClick(); + }); + + expect(button.props.disabled).toBeUndefined(); + expect(button.props['aria-disabled']).toBe(true); + expect(markRoomAndThreadsAsReadMock).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/mindroom/notifications/MindroomMarkRoomsReadMenuItem.tsx b/src/app/mindroom/notifications/MindroomMarkRoomsReadMenuItem.tsx new file mode 100644 index 0000000000..5af397f33d --- /dev/null +++ b/src/app/mindroom/notifications/MindroomMarkRoomsReadMenuItem.tsx @@ -0,0 +1,44 @@ +import React, { useCallback } from 'react'; +import { Icon, Icons, MenuItem, Text } from 'folds'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useRoomsUnread } from '../../state/hooks/unread'; +import { useSetting } from '../../state/hooks/settings'; +import { roomToUnreadAtom } from '../../state/room/roomToUnread'; +import { settingsAtom } from '../../state/settings'; +import { markRoomAndThreadsAsRead } from './readReceipts'; + +type MindroomMarkRoomsReadMenuItemProps = { + onClose: () => void; + roomIds: string[]; +}; + +export function MindroomMarkRoomsReadMenuItem({ + onClose, + roomIds, +}: MindroomMarkRoomsReadMenuItemProps) { + const mx = useMatrixClient(); + const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); + const unread = useRoomsUnread(roomIds, roomToUnreadAtom); + + const handleMarkAsRead = useCallback(() => { + if (!unread) return; + roomIds.forEach((roomId) => { + void markRoomAndThreadsAsRead(mx, roomId, hideActivity); + }); + onClose(); + }, [hideActivity, mx, onClose, roomIds, unread]); + + return ( + } + radii="300" + aria-disabled={!unread} + > + + Mark as Read + + + ); +} diff --git a/src/app/mindroom/notifications/SystemNotificationMindroomExtensions.test.ts b/src/app/mindroom/notifications/SystemNotificationMindroomExtensions.test.ts new file mode 100644 index 0000000000..da393909fa --- /dev/null +++ b/src/app/mindroom/notifications/SystemNotificationMindroomExtensions.test.ts @@ -0,0 +1,24 @@ +import React from 'react'; +import { create } from 'react-test-renderer'; +import { describe, expect, it, vi } from 'vitest'; + +import { + getMindroomEmailNotificationPusherData, + MindroomNativeNotificationSettings, +} from './SystemNotificationMindroomExtensions'; + +vi.mock('../native/IOSPushNotification', () => ({ + IOSPushNotification: () => React.createElement('mock-ios-push-notification'), +})); + +describe('SystemNotificationMindroomExtensions', () => { + it('provides MindRoom email pusher branding', () => { + expect(getMindroomEmailNotificationPusherData()).toEqual({ brand: 'MindRoom' }); + }); + + it('mounts the MindRoom native notification settings component', () => { + const renderer = create(React.createElement(MindroomNativeNotificationSettings)); + + expect(renderer.root.findByType('mock-ios-push-notification')).toBeTruthy(); + }); +}); diff --git a/src/app/mindroom/notifications/SystemNotificationMindroomExtensions.tsx b/src/app/mindroom/notifications/SystemNotificationMindroomExtensions.tsx new file mode 100644 index 0000000000..1620c92248 --- /dev/null +++ b/src/app/mindroom/notifications/SystemNotificationMindroomExtensions.tsx @@ -0,0 +1,12 @@ +import React from 'react'; + +import { MINDROOM_NOTIFICATION_BRAND } from '../branding/branding'; +import { IOSPushNotification } from '../native/IOSPushNotification'; + +export const getMindroomEmailNotificationPusherData = (): Record => ({ + brand: MINDROOM_NOTIFICATION_BRAND, +}); + +export function MindroomNativeNotificationSettings() { + return ; +} diff --git a/src/app/mindroom/notifications/readReceipts.test.ts b/src/app/mindroom/notifications/readReceipts.test.ts new file mode 100644 index 0000000000..3132227e41 --- /dev/null +++ b/src/app/mindroom/notifications/readReceipts.test.ts @@ -0,0 +1,146 @@ +import { Direction, MatrixEvent, RelationType, ReceiptType } from 'matrix-js-sdk'; +import { MAIN_ROOM_TIMELINE } from 'matrix-js-sdk/lib/@types/read_receipts'; +import { describe, expect, it, vi } from 'vitest'; +import { + markMainTimelineAsRead, + markRoomAndThreadsAsRead, + markThreadAsRead, +} from './readReceipts'; + +const ROOM_ID = '!room:example.org'; +const USER_ID = '@alice:example.org'; +const THREAD_ID = '$thread-root'; + +const makeMessageEvent = (eventId: string, ts = 1) => + new MatrixEvent({ + content: { + body: eventId, + msgtype: 'm.text', + }, + event_id: eventId, + origin_server_ts: ts, + room_id: ROOM_ID, + sender: '@bob:example.org', + type: 'm.room.message', + }); + +const makeThreadReplyEvent = (eventId: string, ts = 1) => + new MatrixEvent({ + content: { + 'm.relates_to': { + event_id: THREAD_ID, + 'm.in_reply_to': { + event_id: THREAD_ID, + }, + is_falling_back: true, + rel_type: RelationType.Thread, + }, + body: eventId, + msgtype: 'm.text', + }, + event_id: eventId, + origin_server_ts: ts, + room_id: ROOM_ID, + sender: '@bob:example.org', + type: 'm.room.message', + }); + +const makeRoom = (events: MatrixEvent[]) => + ({ + roomId: ROOM_ID, + findEventById: vi.fn(), + getEventReadUpTo: vi.fn(() => null), + getLiveTimeline: vi.fn(() => ({ + getEvents: () => events, + })), + getThread: vi.fn(() => null), + }); + +const makeClient = (room: ReturnType) => + ({ + fetchRelations: vi.fn(), + getEventMapper: vi.fn(() => (rawEvent: ConstructorParameters[0]) => + new MatrixEvent(rawEvent) + ), + getRoom: vi.fn(() => room), + getUserId: vi.fn(() => USER_ID), + sendReadReceipt: vi.fn(), + sendReceipt: vi.fn(), + }); + +describe('markMainTimelineAsRead', () => { + it('filters hidden thread replies and sends a main-timeline receipt', async () => { + const visibleMainEvent = makeMessageEvent('$main', 1); + const hiddenThreadReply = makeThreadReplyEvent('$thread-reply', 2); + const room = makeRoom([visibleMainEvent, hiddenThreadReply]); + const mx = makeClient(room); + + await markMainTimelineAsRead(mx as never, ROOM_ID, false); + + expect(mx.sendReceipt).toHaveBeenCalledWith(visibleMainEvent, ReceiptType.Read, { + thread_id: MAIN_ROOM_TIMELINE, + }); + expect(mx.sendReadReceipt).not.toHaveBeenCalled(); + }); +}); + +describe('markThreadAsRead', () => { + it('sends a thread-scoped receipt for the latest loaded thread reply', async () => { + const latestReply = makeThreadReplyEvent('$reply-2', 2); + const room = { + ...makeRoom([]), + getThread: vi.fn(() => ({ + events: [makeThreadReplyEvent('$reply-1', 1), latestReply], + getEventReadUpTo: vi.fn(() => null), + })), + }; + const mx = makeClient(room); + + await markThreadAsRead(mx as never, ROOM_ID, THREAD_ID, false); + + expect(mx.sendReceipt).toHaveBeenCalledWith(latestReply, ReceiptType.Read, { + thread_id: THREAD_ID, + }); + }); + + it('fetches the authoritative thread tail when the SDK thread model is unavailable', async () => { + const staleLoadedReply = makeThreadReplyEvent('$reply-1', 1); + const latestRemoteReply = makeThreadReplyEvent('$reply-2', 2); + const room = makeRoom([staleLoadedReply]); + const mx = makeClient(room); + mx.fetchRelations.mockResolvedValue({ + chunk: [latestRemoteReply.event], + }); + + await markThreadAsRead(mx as never, ROOM_ID, THREAD_ID, false); + + expect(mx.fetchRelations).toHaveBeenCalledWith( + ROOM_ID, + THREAD_ID, + RelationType.Thread, + null, + expect.objectContaining({ dir: Direction.Backward, limit: 1 }) + ); + expect((mx.sendReceipt.mock.calls[0]?.[0] as MatrixEvent | undefined)?.getId()).toBe( + latestRemoteReply.getId() + ); + expect(mx.sendReceipt.mock.calls[0]?.[2]).toEqual({ + thread_id: THREAD_ID, + }); + }); +}); + +describe('markRoomAndThreadsAsRead', () => { + it('still sends an explicit unthreaded receipt when only older thread unread remains', async () => { + const olderThreadReply = makeThreadReplyEvent('$thread-reply', 1); + const newestMainEvent = makeMessageEvent('$main', 2); + const room = makeRoom([olderThreadReply, newestMainEvent]); + room.getEventReadUpTo.mockReturnValue(newestMainEvent.getId()); + const mx = makeClient(room); + + await markRoomAndThreadsAsRead(mx as never, ROOM_ID, true); + + expect(mx.sendReadReceipt).toHaveBeenCalledWith(newestMainEvent, ReceiptType.ReadPrivate, true); + expect(mx.sendReceipt).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/mindroom/notifications/readReceipts.ts b/src/app/mindroom/notifications/readReceipts.ts new file mode 100644 index 0000000000..abcb1c3209 --- /dev/null +++ b/src/app/mindroom/notifications/readReceipts.ts @@ -0,0 +1,129 @@ +import { Direction, MatrixClient, MatrixEvent, RelationType, Room, ReceiptType } from 'matrix-js-sdk'; +import { MAIN_ROOM_TIMELINE } from 'matrix-js-sdk/lib/@types/read_receipts'; +import { isThreadOnlyRoomActivity } from '../threads/threadRenderUtils'; +import { eventBelongsToThread } from '../threads/threadUtils'; + +const getReceiptType = (privateReceipt: boolean): ReceiptType => + privateReceipt ? ReceiptType.ReadPrivate : ReceiptType.Read; + +const getLatestReceiptTarget = ( + events: MatrixEvent[], + readEventId: string | null, + isEligible: (event: MatrixEvent) => boolean = () => true +): MatrixEvent | undefined => { + for (let i = events.length - 1; i >= 0; i -= 1) { + const event = events[i]; + if (event.getId() === readEventId) return undefined; + if (!isEligible(event) || event.isSending()) continue; + return event; + } + return undefined; +}; + +const isThreadReplyReceiptTarget = (event: MatrixEvent, threadId: string): boolean => + event.getId() !== threadId && + eventBelongsToThread(event, threadId) && + event.getRelation()?.rel_type === RelationType.Thread; + +const getLoadedThreadReplyTarget = ( + room: Room, + threadId: string, + readEventId: string | null +): MatrixEvent | undefined => { + const thread = room.getThread(threadId); + if (thread) { + return getLatestReceiptTarget(thread.events, readEventId, (event) => + isThreadReplyReceiptTarget(event, threadId) + ); + } + + return getLatestReceiptTarget(room.getLiveTimeline().getEvents(), readEventId, (event) => + isThreadReplyReceiptTarget(event, threadId) + ); +}; + +const getLatestThreadReplyTarget = async ( + mx: MatrixClient, + room: Room, + threadId: string +): Promise => { + const userId = mx.getUserId(); + const thread = room.getThread(threadId); + if (thread) { + const readEventId = userId ? thread.getEventReadUpTo(userId) : null; + return getLoadedThreadReplyTarget(room, threadId, readEventId); + } + + const relationResponse = await mx.fetchRelations(room.roomId, threadId, RelationType.Thread, null, { + dir: Direction.Backward, + limit: 1, + }); + const latestReply = relationResponse.chunk?.[0]; + if (!latestReply) { + return undefined; + } + + const mappedReply = mx.getEventMapper()(latestReply); + if (mappedReply.isSending() || !isThreadReplyReceiptTarget(mappedReply, threadId)) { + return getLoadedThreadReplyTarget(room, threadId, null); + } + + return mappedReply; +}; + +export async function markMainTimelineAsRead( + mx: MatrixClient, + roomId: string, + privateReceipt: boolean +) { + const room = mx.getRoom(roomId); + const userId = mx.getUserId(); + if (!room || !userId) return; + + const timeline = room.getLiveTimeline().getEvents(); + if (timeline.length === 0) return; + + const latestEvent = getLatestReceiptTarget(timeline, room.getEventReadUpTo(userId), (event) => + !isThreadOnlyRoomActivity(room, event) + ); + if (!latestEvent) return; + + await mx.sendReceipt(latestEvent, getReceiptType(privateReceipt), { + thread_id: MAIN_ROOM_TIMELINE, + }); +} + +export async function markThreadAsRead( + mx: MatrixClient, + roomId: string, + threadId: string, + privateReceipt: boolean +) { + const room = mx.getRoom(roomId); + if (!room) return; + + const latestReply = await getLatestThreadReplyTarget(mx, room, threadId); + if (!latestReply) return; + + await mx.sendReceipt(latestReply, getReceiptType(privateReceipt), { + thread_id: threadId, + }); +} + +export async function markRoomAndThreadsAsRead( + mx: MatrixClient, + roomId: string, + privateReceipt: boolean +) { + const room = mx.getRoom(roomId); + const userId = mx.getUserId(); + if (!room || !userId) return; + + const timeline = room.getLiveTimeline().getEvents(); + if (timeline.length === 0) return; + + const latestEvent = getLatestReceiptTarget(timeline, null); + if (!latestEvent) return; + + await mx.sendReadReceipt(latestEvent, getReceiptType(privateReceipt), true); +} diff --git a/src/app/mindroom/recent-threads/RecentThreadEntry.test.ts b/src/app/mindroom/recent-threads/RecentThreadEntry.test.ts new file mode 100644 index 0000000000..aad1d70ea6 --- /dev/null +++ b/src/app/mindroom/recent-threads/RecentThreadEntry.test.ts @@ -0,0 +1,134 @@ +import React from 'react'; +import { act, create, ReactTestRenderer } from 'react-test-renderer'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const { + bumpRecentThreadMock, + navigateRoomThreadMock, + navigateRoomThreadDirectMock, + rekeyRecentThreadMock, +} = vi.hoisted(() => ({ + bumpRecentThreadMock: vi.fn(), + navigateRoomThreadMock: vi.fn(), + navigateRoomThreadDirectMock: vi.fn(), + rekeyRecentThreadMock: vi.fn(), +})); + +vi.mock('folds', () => ({ + Text: ({ children }: { children?: React.ReactNode }) => React.createElement('span', null, children), +})); + +vi.mock('../../hooks/useRoomNavigate', () => ({ + useRoomNavigate: () => ({ + navigateRoomThread: navigateRoomThreadMock, + navigateRoomThreadDirect: navigateRoomThreadDirectMock, + }), +})); + +vi.mock('../../hooks/useRelativeTime', () => ({ + useRelativeTime: () => '1m ago', +})); + +vi.mock('../../hooks/useRoomMeta', () => ({ + useRoomName: () => 'Room Name', +})); + +vi.mock('./recentThreads', () => ({ + bumpRecentThread: bumpRecentThreadMock, + rekeyRecentThread: rekeyRecentThreadMock, +})); + +vi.mock('../threads/recentThreadViewModel', () => ({ + useRecentThreadViewModel: () => ({ + id: { + roomId: '!room:example.org', + threadRootId: '$resolved', + }, + storedThreadId: '$thread', + openedAt: 123, + roomName: 'Room Name', + summaryText: 'Thread summary', + persistableSummaryText: 'Thread summary', + shouldRekey: true, + }), +})); + +vi.mock('./recentThreads.css', () => ({ + EntryButton: 'EntryButton', + EntryTopRow: 'EntryTopRow', + EntryRoomName: 'EntryRoomName', + EntryTime: 'EntryTime', + EntrySummary: 'EntrySummary', +})); + +import { RecentThreadEntry } from './RecentThreadEntry'; + +describe('RecentThreadEntry', () => { + let renderer: ReactTestRenderer | undefined; + + afterEach(() => { + if (renderer) { + act(() => { + renderer?.unmount(); + }); + } + renderer = undefined; + vi.clearAllMocks(); + }); + + it('navigates recent-thread clicks directly to the target thread route', () => { + act(() => { + renderer = create( + React.createElement(RecentThreadEntry, { + room: { roomId: '!room:example.org', hasEncryptionStateEvent: () => false } as never, + threadId: '$thread', + openedAt: Date.now(), + }) + ); + }); + + const button = renderer!.root.findByType('button'); + + act(() => { + button.props.onClick(); + }); + + expect(navigateRoomThreadDirectMock).toHaveBeenCalledWith('!room:example.org', '$resolved'); + expect(navigateRoomThreadMock).not.toHaveBeenCalled(); + }); + + it('persists a resolved summary snapshot without waiting for room view state', () => { + act(() => { + renderer = create( + React.createElement(RecentThreadEntry, { + room: { roomId: '!room:example.org', hasEncryptionStateEvent: () => false } as never, + threadId: '$thread', + openedAt: Date.now(), + }) + ); + }); + + expect(bumpRecentThreadMock).toHaveBeenCalledWith( + '!room:example.org', + '$resolved', + expect.any(Number), + 'Thread summary' + ); + }); + + it('exposes a stable accessible label for browser navigation tests and assistive tech', () => { + act(() => { + renderer = create( + React.createElement(RecentThreadEntry, { + room: { roomId: '!room:example.org', hasEncryptionStateEvent: () => false } as never, + threadId: '$thread', + openedAt: Date.now(), + }) + ); + }); + + const button = renderer!.root.findByType('button'); + + expect(button.props['aria-label']).toBe('Open thread: Thread summary. Room Name. Opened 1m ago'); + }); +}); diff --git a/src/app/mindroom/recent-threads/RecentThreadEntry.tsx b/src/app/mindroom/recent-threads/RecentThreadEntry.tsx new file mode 100644 index 0000000000..1c85881097 --- /dev/null +++ b/src/app/mindroom/recent-threads/RecentThreadEntry.tsx @@ -0,0 +1,90 @@ +import React, { memo, useEffect } from 'react'; +import { Text } from 'folds'; +import type { Room } from 'matrix-js-sdk'; +import { useRoomNavigate } from '../../hooks/useRoomNavigate'; +import { useRelativeTime } from '../../hooks/useRelativeTime'; +import { bumpRecentThread, rekeyRecentThread } from './recentThreads'; +import { useRecentThreadViewModel } from '../threads/recentThreadViewModel'; +import { getRoomViewMode } from '../threads/roomViewMode'; +import * as css from './recentThreads.css'; + +type RecentThreadEntryProps = { + room: Room; + threadId: string; + openedAt: number; + summaryText?: string; +}; + +export const RecentThreadEntry = memo(({ + room, + threadId, + openedAt, + summaryText, +}: RecentThreadEntryProps) => { + const viewModel = useRecentThreadViewModel(room, threadId, openedAt, summaryText); + const relativeTime = useRelativeTime(openedAt); + const { navigateRoom, navigateRoomThreadDirect } = useRoomNavigate(); + const ariaLabel = [ + `Open thread: ${viewModel.summaryText}`, + viewModel.roomName, + relativeTime ? `Opened ${relativeTime}` : undefined, + ] + .filter(Boolean) + .join('. '); + + useEffect(() => { + if (!viewModel.shouldRekey) return; + + rekeyRecentThread(room.roomId, viewModel.storedThreadId, viewModel.id.threadRootId); + }, [room.roomId, viewModel.id.threadRootId, viewModel.shouldRekey, viewModel.storedThreadId]); + + useEffect(() => { + if (!viewModel.persistableSummaryText) return; + if (viewModel.persistableSummaryText === summaryText) return; + + bumpRecentThread( + room.roomId, + viewModel.id.threadRootId, + openedAt, + viewModel.persistableSummaryText + ); + }, [ + openedAt, + room.roomId, + summaryText, + viewModel.id.threadRootId, + viewModel.persistableSummaryText, + ]); + + return ( + + ); +}); + +RecentThreadEntry.displayName = 'RecentThreadEntry'; diff --git a/src/app/mindroom/recent-threads/RecentThreadsDivider.test.ts b/src/app/mindroom/recent-threads/RecentThreadsDivider.test.ts new file mode 100644 index 0000000000..e32b987647 --- /dev/null +++ b/src/app/mindroom/recent-threads/RecentThreadsDivider.test.ts @@ -0,0 +1,212 @@ +import React from 'react'; +import { act, create, ReactTestRenderer } from 'react-test-renderer'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('./recentThreads.css', () => ({ + Divider: 'Divider', + DividerActive: 'DividerActive', + DividerHandle: 'DividerHandle', + DividerToggle: 'DividerToggle', + DividerToggleHandle: 'DividerToggleHandle', +})); + +import { RecentThreadsDivider } from './RecentThreadsDivider'; + +const getSeparator = (renderer: ReactTestRenderer) => + renderer.root.find( + (node) => typeof node.type === 'string' && node.props.role === 'separator' + ); + +const getToggleButton = (renderer: ReactTestRenderer) => + renderer.root.find( + (node) => typeof node.type === 'string' && node.type === 'button' + ); + +describe('RecentThreadsDivider', () => { + let renderer: ReactTestRenderer | undefined; + const originalWindow = globalThis.window; + + beforeEach(() => { + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }, + }); + }); + + afterEach(() => { + if (renderer) { + act(() => { + renderer?.unmount(); + }); + } + renderer = undefined; + vi.restoreAllMocks(); + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: originalWindow, + }); + }); + + describe('mode=resize', () => { + it('exposes keyboard-accessible separator semantics and arrow-key resizing', () => { + const onPreviewHeightChange = vi.fn(); + const onCommitHeightChange = vi.fn(); + + act(() => { + renderer = create( + React.createElement(RecentThreadsDivider, { + mode: 'resize', + panelHeight: 32, + minHeight: 80, + maxHeight: 240, + collapsedHeight: 32, + onPreviewHeightChange, + onCommitHeightChange, + }) + ); + }); + + const separator = getSeparator(renderer!); + expect(separator.props.tabIndex).toBe(0); + expect(separator.props['aria-valuemin']).toBe(32); + expect(separator.props['aria-valuemax']).toBe(240); + expect(separator.props['aria-valuenow']).toBe(32); + + const preventDefault = vi.fn(); + act(() => { + separator.props.onKeyDown({ + key: 'ArrowUp', + preventDefault, + }); + }); + + expect(preventDefault).toHaveBeenCalledOnce(); + expect(onPreviewHeightChange).toHaveBeenLastCalledWith(80); + expect(onCommitHeightChange).toHaveBeenLastCalledWith(80); + + act(() => { + renderer?.update( + React.createElement(RecentThreadsDivider, { + mode: 'resize', + panelHeight: 80, + minHeight: 80, + maxHeight: 240, + collapsedHeight: 32, + onPreviewHeightChange, + onCommitHeightChange, + }) + ); + }); + + act(() => { + getSeparator(renderer!).props.onKeyDown({ + key: 'ArrowDown', + preventDefault: vi.fn(), + }); + }); + + expect(onPreviewHeightChange).toHaveBeenLastCalledWith(32); + expect(onCommitHeightChange).toHaveBeenLastCalledWith(32); + }); + + it('cleans up the previous window listeners before starting a second drag', () => { + const addEventListenerSpy = vi.spyOn(window, 'addEventListener'); + const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener'); + + act(() => { + renderer = create( + React.createElement(RecentThreadsDivider, { + mode: 'resize', + panelHeight: 120, + minHeight: 80, + maxHeight: 240, + collapsedHeight: 32, + onPreviewHeightChange: vi.fn(), + onCommitHeightChange: vi.fn(), + }) + ); + }); + + const separator = getSeparator(renderer!); + + act(() => { + separator.props.onPointerDown({ + pointerId: 1, + clientY: 200, + preventDefault: vi.fn(), + }); + }); + + expect(addEventListenerSpy).toHaveBeenCalledTimes(3); + + act(() => { + separator.props.onPointerDown({ + pointerId: 2, + clientY: 180, + preventDefault: vi.fn(), + }); + }); + + expect(removeEventListenerSpy).toHaveBeenCalledTimes(3); + expect(removeEventListenerSpy).toHaveBeenNthCalledWith( + 1, + 'pointermove', + expect.any(Function) + ); + expect(removeEventListenerSpy).toHaveBeenNthCalledWith(2, 'pointerup', expect.any(Function)); + expect(removeEventListenerSpy).toHaveBeenNthCalledWith( + 3, + 'pointercancel', + expect.any(Function) + ); + }); + }); + + describe('mode=toggle', () => { + it('renders a single labeled toggle button with heading semantics and click toggle behavior', () => { + const onToggle = vi.fn(); + + act(() => { + renderer = create( + React.createElement(RecentThreadsDivider, { + entryCount: 3, + mode: 'toggle', + isExpanded: false, + onToggle, + }) + ); + }); + + const button = getToggleButton(renderer!); + const heading = renderer!.root.find((node) => node.props.role === 'heading'); + + expect(button.props['aria-label']).toBe('Recent Threads'); + expect(button.props['aria-expanded']).toBe(false); + expect(heading.props['aria-level']).toBe(2); + + act(() => { + button.props.onClick(); + }); + + expect(onToggle).toHaveBeenCalledOnce(); + }); + + it('does not attach a custom keydown handler to the native toggle button', () => { + act(() => { + renderer = create( + React.createElement(RecentThreadsDivider, { + entryCount: 0, + mode: 'toggle', + isExpanded: true, + onToggle: vi.fn(), + }) + ); + }); + + expect(getToggleButton(renderer!).props.onKeyDown).toBeUndefined(); + }); + }); +}); diff --git a/src/app/mindroom/recent-threads/RecentThreadsDivider.tsx b/src/app/mindroom/recent-threads/RecentThreadsDivider.tsx new file mode 100644 index 0000000000..bf6f984a8f --- /dev/null +++ b/src/app/mindroom/recent-threads/RecentThreadsDivider.tsx @@ -0,0 +1,249 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Box, Icon, Icons, Text } from 'folds'; +import * as css from './recentThreads.css'; + +type RecentThreadsResizeProps = { + mode: 'resize'; + panelHeight: number; + minHeight: number; + maxHeight: number; + collapsedHeight: number; + onPreviewHeightChange: (height: number) => void; + onCommitHeightChange: (height: number) => void; + entryCount?: never; + isExpanded?: boolean; + onToggle?: never; +}; + +type RecentThreadsToggleProps = { + entryCount: number; + mode: 'toggle'; + isExpanded: boolean; + onToggle: () => void; + panelHeight?: never; + minHeight?: never; + maxHeight?: never; + collapsedHeight?: never; + onPreviewHeightChange?: never; + onCommitHeightChange?: never; +}; + +type RecentThreadsDividerProps = RecentThreadsResizeProps | RecentThreadsToggleProps; + +type DragState = { + pointerId: number; + startY: number; + startHeight: number; + lastHeight: number; +}; + +const KEYBOARD_RESIZE_STEP = 16; + +const getResolvedHeight = ( + nextHeight: number, + minHeight: number, + maxHeight: number, + collapsedHeight: number +): number => { + const roundedHeight = Math.round(nextHeight); + if (roundedHeight < minHeight) { + return collapsedHeight; + } + + return Math.min(Math.max(roundedHeight, minHeight), maxHeight); +}; + +export function RecentThreadsDivider(props: RecentThreadsDividerProps) { + const { mode } = props; + const resizeProps = mode === 'resize' ? props : undefined; + const toggleProps = mode === 'toggle' ? props : undefined; + const countLabel = + toggleProps && toggleProps.entryCount > 0 ? `${toggleProps.entryCount}` : undefined; + const [dragging, setDragging] = useState(false); + const dragStateRef = useRef(undefined); + + const clearWindowListenersRef = useRef<() => void>(() => {}); + + const commitResolvedHeight = useCallback( + (nextHeight: number) => { + if (!resizeProps) return; + + const resolvedHeight = getResolvedHeight( + nextHeight, + resizeProps.minHeight, + resizeProps.maxHeight, + resizeProps.collapsedHeight + ); + resizeProps.onPreviewHeightChange(resolvedHeight); + resizeProps.onCommitHeightChange(resolvedHeight); + }, + [resizeProps] + ); + + const handlePointerMove = useCallback( + (evt: PointerEvent) => { + const dragState = dragStateRef.current; + if (!dragState || evt.pointerId !== dragState.pointerId || !resizeProps) return; + + const deltaY = evt.clientY - dragState.startY; + const nextHeight = getResolvedHeight( + dragState.startHeight - deltaY, + resizeProps.minHeight, + resizeProps.maxHeight, + resizeProps.collapsedHeight + ); + + dragState.lastHeight = nextHeight; + resizeProps.onPreviewHeightChange(nextHeight); + }, + [resizeProps] + ); + + const finishDrag = useCallback( + (pointerId: number, commit: boolean) => { + const dragState = dragStateRef.current; + if (!dragState || dragState.pointerId !== pointerId) return; + + dragStateRef.current = undefined; + clearWindowListenersRef.current(); + setDragging(false); + + if (!resizeProps) { + return; + } + + if (commit) { + resizeProps.onCommitHeightChange(dragState.lastHeight); + return; + } + + resizeProps.onPreviewHeightChange(dragState.startHeight); + }, + [resizeProps] + ); + + const handlePointerUp = useCallback( + (evt: PointerEvent) => { + finishDrag(evt.pointerId, true); + }, + [finishDrag] + ); + + const handlePointerCancel = useCallback( + (evt: PointerEvent) => { + finishDrag(evt.pointerId, false); + }, + [finishDrag] + ); + + useEffect( + () => () => { + clearWindowListenersRef.current(); + }, + [] + ); + + const handlePointerDown = (evt: React.PointerEvent) => { + if (!resizeProps) return; + + evt.preventDefault(); + clearWindowListenersRef.current(); + + dragStateRef.current = { + pointerId: evt.pointerId, + startY: evt.clientY, + startHeight: resizeProps.panelHeight, + lastHeight: resizeProps.panelHeight, + }; + setDragging(true); + + const clearWindowListeners = () => { + window.removeEventListener('pointermove', handlePointerMove); + window.removeEventListener('pointerup', handlePointerUp); + window.removeEventListener('pointercancel', handlePointerCancel); + }; + + clearWindowListenersRef.current = clearWindowListeners; + + window.addEventListener('pointermove', handlePointerMove); + window.addEventListener('pointerup', handlePointerUp); + window.addEventListener('pointercancel', handlePointerCancel); + }; + + const handleResizeKeyDown = (evt: React.KeyboardEvent) => { + if (!resizeProps || (evt.key !== 'ArrowUp' && evt.key !== 'ArrowDown')) return; + + evt.preventDefault(); + + if (evt.key === 'ArrowUp') { + const nextHeight = + resizeProps.panelHeight <= resizeProps.collapsedHeight + ? resizeProps.minHeight + : resizeProps.panelHeight + KEYBOARD_RESIZE_STEP; + commitResolvedHeight(nextHeight); + return; + } + + const nextHeight = + resizeProps.panelHeight <= resizeProps.collapsedHeight || + resizeProps.panelHeight - KEYBOARD_RESIZE_STEP < resizeProps.minHeight + ? resizeProps.collapsedHeight + : resizeProps.panelHeight - KEYBOARD_RESIZE_STEP; + commitResolvedHeight(nextHeight); + }; + + return ( + <> + {toggleProps ? ( + + ) : ( + <> + {/* eslint-disable jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/no-noninteractive-tabindex */} +
+ +
+ {/* eslint-enable jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/no-noninteractive-tabindex */} + + )} + + ); +} diff --git a/src/app/mindroom/recent-threads/RecentThreadsPanel.test.ts b/src/app/mindroom/recent-threads/RecentThreadsPanel.test.ts new file mode 100644 index 0000000000..a71d30dfaf --- /dev/null +++ b/src/app/mindroom/recent-threads/RecentThreadsPanel.test.ts @@ -0,0 +1,237 @@ +import React from 'react'; +import { getDefaultStore } from 'jotai'; +import { act, create, ReactTestRenderer } from 'react-test-renderer'; +import type { Room } from 'matrix-js-sdk'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ScreenSize, ScreenSizeProvider } from '../../hooks/useScreenSize'; +import { allRoomsAtom } from '../../state/room-list/roomList'; +import { clearRecentThreadsStore } from './recentThreads'; +import { RecentThreadsPageNav, RecentThreadsPanel } from './RecentThreadsPanel'; +import { buildVisibleRecentThreadEntries } from './recentThreadsPanelUtils'; + +vi.mock('./recentThreads.css', () => ({ + EmptyState: 'EmptyState', + Divider: 'Divider', + DividerActive: 'DividerActive', + DividerHandle: 'DividerHandle', + DividerToggle: 'DividerToggle', + DividerToggleHandle: 'DividerToggleHandle', + PageNavSection: 'PageNavSection', + Panel: 'Panel', + PanelBody: 'PanelBody', + PanelHeader: 'PanelHeader', + PanelList: 'PanelList', +})); + +vi.mock('./RecentThreadEntry', () => ({ + RecentThreadEntry: ({ threadId }: { threadId: string }) => + React.createElement('button', { 'data-recent-thread-id': threadId }, threadId), +})); + +const mxMock = { + getUserId: vi.fn(() => '@alice:example.org'), + getRoom: vi.fn(), +}; + +vi.mock('../../hooks/useMatrixClient', () => ({ + useMatrixClient: () => mxMock, +})); + +const RECENT_THREADS_STORAGE_KEY = 'recentThreads:@alice:example.org'; + +const createStorage = (): Storage => { + const state = new Map(); + + return { + getItem: vi.fn((key: string) => state.get(key) ?? null), + setItem: vi.fn((key: string, value: string) => { + state.set(key, value); + }), + removeItem: vi.fn((key: string) => { + state.delete(key); + }), + clear: vi.fn(() => { + state.clear(); + }), + key: vi.fn(() => null), + length: 0, + }; +}; + +beforeEach(() => { + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: createStorage(), + }); + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + addEventListener: vi.fn(), + clearTimeout, + innerHeight: 900, + removeEventListener: vi.fn(), + setTimeout, + }, + }); +}); + +describe('buildVisibleRecentThreadEntries', () => { + it('keeps only joined rooms in the visible panel entries', () => { + const joinedRoom = { + roomId: '!joined:example.org', + getMyMembership: () => 'join', + } as unknown as Room; + const leftRoom = { + roomId: '!left:example.org', + getMyMembership: () => 'leave', + } as unknown as Room; + + const visibleEntries = buildVisibleRecentThreadEntries( + (roomId) => + ({ + '!joined:example.org': joinedRoom, + '!left:example.org': leftRoom, + }[roomId]), + [ + { roomId: '!joined:example.org', threadId: '$joined', openedAt: 3 }, + { roomId: '!left:example.org', threadId: '$left', openedAt: 2 }, + { roomId: '!missing:example.org', threadId: '$missing', openedAt: 1 }, + ] + ); + + expect(visibleEntries).toEqual([ + { + roomId: '!joined:example.org', + threadId: '$joined', + openedAt: 3, + room: joinedRoom, + }, + ]); + }); +}); + +describe('RecentThreadsPanel', () => { + let renderer: ReactTestRenderer | undefined; + + afterEach(() => { + if (renderer) { + act(() => { + renderer?.unmount(); + }); + } + renderer = undefined; + localStorage.removeItem(RECENT_THREADS_STORAGE_KEY); + clearRecentThreadsStore('@alice:example.org'); + getDefaultStore().set(allRoomsAtom, { type: 'INITIALIZE', rooms: [] }); + mxMock.getRoom.mockReset(); + }); + + it('keeps the desktop header as a static h2 heading', () => { + act(() => { + renderer = create( + React.createElement(RecentThreadsPanel, { + entries: [], + height: 32, + collapsed: true, + }) + ); + }); + + const heading = renderer!.root.find( + (node) => typeof node.type === 'string' && node.type === 'h2' + ); + + expect(heading.children).toContain('Recent Threads'); + }); + + it('renders no static header shell for the collapsed mobile toggle variant', () => { + act(() => { + renderer = create( + React.createElement(RecentThreadsPanel, { + entries: [], + height: 0, + collapsed: true, + showHeader: false, + }) + ); + }); + + expect(renderer!.toJSON()).toBeNull(); + }); +}); + +describe('RecentThreadsPageNav', () => { + let renderer: ReactTestRenderer | undefined; + + afterEach(() => { + if (renderer) { + act(() => { + renderer?.unmount(); + }); + } + renderer = undefined; + localStorage.removeItem(RECENT_THREADS_STORAGE_KEY); + clearRecentThreadsStore('@alice:example.org'); + getDefaultStore().set(allRoomsAtom, { type: 'INITIALIZE', rooms: [] }); + mxMock.getRoom.mockReset(); + }); + + it('recomputes visible recent threads when the room list arrives after storage hydration', () => { + const joinedRoom = { + roomId: '!room:example.org', + getMyMembership: () => 'join', + } as unknown as Room; + let roomAvailable = false; + mxMock.getRoom.mockImplementation((roomId: string) => + roomAvailable && roomId === '!room:example.org' ? joinedRoom : undefined + ); + localStorage.setItem( + RECENT_THREADS_STORAGE_KEY, + JSON.stringify({ + v: 1, + entries: [ + { + roomId: '!room:example.org', + threadId: '$thread', + openedAt: 123, + summaryText: 'Cached recent thread', + }, + ], + }) + ); + + act(() => { + renderer = create( + React.createElement( + ScreenSizeProvider, + { value: ScreenSize.Desktop }, + React.createElement( + RecentThreadsPageNav, + null, + React.createElement('div', null, 'children') + ) + ) + ); + }); + + expect( + renderer!.root.findAll( + (node) => node.props['data-recent-thread-id'] === '$thread' + ) + ).toHaveLength(0); + + act(() => { + roomAvailable = true; + getDefaultStore().set(allRoomsAtom, { + type: 'INITIALIZE', + rooms: ['!room:example.org'], + }); + }); + + expect( + renderer!.root.findAll( + (node) => node.props['data-recent-thread-id'] === '$thread' + ) + ).toHaveLength(1); + }); +}); diff --git a/src/app/mindroom/recent-threads/RecentThreadsPanel.tsx b/src/app/mindroom/recent-threads/RecentThreadsPanel.tsx new file mode 100644 index 0000000000..7bfd0c304b --- /dev/null +++ b/src/app/mindroom/recent-threads/RecentThreadsPanel.tsx @@ -0,0 +1,168 @@ +import React, { ReactNode, useEffect, useMemo, useState } from 'react'; +import { useAtom, useAtomValue } from 'jotai'; +import { Box, Scroll, Text } from 'folds'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useScreenSizeContext } from '../../hooks/useScreenSize'; +import { allRoomsAtom } from '../../state/room-list/roomList'; +import { makeRecentThreadsAtom } from './recentThreads'; +import { + RECENT_THREADS_PANEL_COLLAPSED_HEIGHT, + RECENT_THREADS_PANEL_MIN_HEIGHT, + makeRecentThreadsPanelHeightAtom, +} from './recentThreadsPanelHeight'; +import { makeRecentThreadsPanelMobileExpandedAtom } from './recentThreadsPanelMobileExpanded'; +import { RecentThreadEntry } from './RecentThreadEntry'; +import { RecentThreadsDivider } from './RecentThreadsDivider'; +import { + buildVisibleRecentThreadEntries, + type VisibleRecentThreadItem, +} from './recentThreadsPanelUtils'; +import { useDebouncedViewportHeight } from './useDebouncedViewportHeight'; +import { useResolvedRecentThreadsLayout } from './useResolvedRecentThreadsLayout'; +import * as css from './recentThreads.css'; + +type RecentThreadsPanelProps = { + collapsed?: boolean; + entries: VisibleRecentThreadItem[]; + height: number; + showHeader?: boolean; +}; + +export function RecentThreadsPanel({ + entries, + height, + collapsed, + showHeader = true, +}: RecentThreadsPanelProps) { + const isCollapsed = collapsed ?? height <= RECENT_THREADS_PANEL_COLLAPSED_HEIGHT; + const countLabel = entries.length === 0 ? undefined : `${entries.length}`; + const headerContent = ( + <> + + Recent Threads + + + {countLabel && ( + + {countLabel} + + )} + + + ); + + if (!showHeader && isCollapsed) { + return null; + } + + return ( +
+ {showHeader && ( +
{headerContent}
+ )} + {!isCollapsed && ( +
+ {entries.length === 0 ? ( +
+ + No recent threads + +
+ ) : ( + +
+ {entries.map((entry) => ( + + ))} +
+
+ )} +
+ )} +
+ ); +} + +type RecentThreadsPageNavProps = { + children: ReactNode; +}; + +export function RecentThreadsPageNav({ children }: RecentThreadsPageNavProps) { + const mx = useMatrixClient(); + const screenSize = useScreenSizeContext(); + const userId = mx.getUserId()!; + + const recentThreadsAtom = useMemo(() => makeRecentThreadsAtom(userId), [userId]); + const recentThreadsPanelHeightAtom = useMemo( + () => makeRecentThreadsPanelHeightAtom(userId), + [userId] + ); + const recentThreadsPanelMobileExpandedAtom = useMemo( + () => makeRecentThreadsPanelMobileExpandedAtom(userId), + [userId] + ); + + const recentThreads = useAtomValue(recentThreadsAtom); + const allRoomIds = useAtomValue(allRoomsAtom); + const [storedPanelHeight, setStoredPanelHeight] = useAtom(recentThreadsPanelHeightAtom); + const [mobileExpanded, setMobileExpanded] = useAtom(recentThreadsPanelMobileExpandedAtom); + const viewportHeight = useDebouncedViewportHeight(); + const resolvedLayout = useResolvedRecentThreadsLayout({ + screenSize, + viewportHeight, + storedDesktopHeight: storedPanelHeight, + mobileExpanded, + onToggleMobileExpanded: setMobileExpanded, + }); + const [panelHeight, setPanelHeight] = useState(resolvedLayout.height); + + useEffect(() => { + setPanelHeight(resolvedLayout.height); + }, [resolvedLayout.height]); + + const maxPanelHeight = resolvedLayout.maxHeight; + + const visibleEntries = useMemo(() => { + // allRoomsAtom changes when joined-room membership changes; mx.getRoom alone is not reactive. + void allRoomIds; + return buildVisibleRecentThreadEntries((roomId) => mx.getRoom(roomId), recentThreads); + }, [allRoomIds, mx, recentThreads]); + + return ( +
+ + {children} + + {resolvedLayout.dividerMode === 'toggle' ? ( + + ) : ( + + )} + +
+ ); +} diff --git a/src/app/mindroom/recent-threads/recentThreadSummaryUtils.ts b/src/app/mindroom/recent-threads/recentThreadSummaryUtils.ts new file mode 100644 index 0000000000..0457015845 --- /dev/null +++ b/src/app/mindroom/recent-threads/recentThreadSummaryUtils.ts @@ -0,0 +1,74 @@ +import type { MatrixEvent, Room } from 'matrix-js-sdk'; +import type { MindroomThreadSummaryInfo } from '../messages/threadSummary'; +import { resolveThreadRootPreviewText } from '../threads/threadPresentation'; + +export const RECENT_THREAD_SUMMARY_LIMIT = 120; + +export const truncateRecentThreadSummaryText = (value: string): string => + value.length <= RECENT_THREAD_SUMMARY_LIMIT + ? value + : `${value.slice(0, RECENT_THREAD_SUMMARY_LIMIT - 3).trimEnd()}...`; + +export const getResolvedRecentThreadRootId = (room: Room, threadId: string): string => { + if (room.getThread(threadId)) return threadId; + + const event = room.findEventById(threadId); + const rootId = event?.threadRootId; + if (rootId && rootId !== threadId) return rootId; + + return threadId; +}; + +export const getRecentThreadFallbackSummary = (room: Room, roomName: string): string => { + if (room.hasEncryptionStateEvent()) return 'Encrypted thread'; + if (roomName.trim().length > 0) return `Thread in ${roomName}`; + return 'Thread'; +}; + +export const shouldPersistRecentThreadSummaryText = ( + room: Room, + roomName: string, + summaryText: string | undefined +): summaryText is string => { + if (typeof summaryText !== 'string') return false; + const trimmedSummaryText = summaryText.trim(); + if (trimmedSummaryText.length === 0) return false; + + return trimmedSummaryText !== getRecentThreadFallbackSummary(room, roomName); +}; + +export const getRecentThreadRootPreviewText = ( + room: Room, + threadRootId: string, + rootEvent: MatrixEvent | undefined +): string | undefined => { + const previewText = resolveThreadRootPreviewText({ + room, + threadRootId, + rootEvent, + }); + + return previewText ? truncateRecentThreadSummaryText(previewText) : undefined; +}; + +type ResolveRecentThreadSummaryTextOptions = { + room: Room; + threadRootId: string; + rootEvent?: MatrixEvent; + summaryInfo?: MindroomThreadSummaryInfo; + fallbackSummaryText?: string; + roomName?: string; +}; + +export const resolveRecentThreadSummaryText = ({ + room, + threadRootId, + rootEvent, + summaryInfo, + fallbackSummaryText, + roomName, +}: ResolveRecentThreadSummaryTextOptions): string | undefined => + (summaryInfo?.summaryText && truncateRecentThreadSummaryText(summaryInfo.summaryText)) || + getRecentThreadRootPreviewText(room, threadRootId, rootEvent) || + (fallbackSummaryText && truncateRecentThreadSummaryText(fallbackSummaryText)) || + (roomName ? getRecentThreadFallbackSummary(room, roomName) : undefined); diff --git a/src/app/mindroom/recent-threads/recentThreads.css.ts b/src/app/mindroom/recent-threads/recentThreads.css.ts new file mode 100644 index 0000000000..2950c2578d --- /dev/null +++ b/src/app/mindroom/recent-threads/recentThreads.css.ts @@ -0,0 +1,186 @@ +import { style } from '@vanilla-extract/css'; +import { DefaultReset, color, config } from 'folds'; + +export const PageNavSection = style({ + display: 'flex', + flexDirection: 'column', + flex: '1 1 auto', + minHeight: 0, +}); + +export const Panel = style({ + display: 'flex', + flexDirection: 'column', + flexShrink: 0, + minHeight: 0, + backgroundColor: color.SurfaceVariant.Container, + borderTop: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`, +}); + +export const PanelHeader = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + minHeight: '32px', + padding: `0 ${config.space.S300}`, + color: color.SurfaceVariant.OnContainer, + borderBottom: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`, +}); + +export const PanelBody = style({ + flex: '1 1 auto', + minHeight: 0, +}); + +export const PanelList = style({ + display: 'flex', + flexDirection: 'column', + gap: config.space.S100, + padding: config.space.S200, +}); + +export const EmptyState = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + minHeight: '100%', + padding: config.space.S300, + color: color.SurfaceVariant.OnContainer, +}); + +export const EntryButton = style([ + DefaultReset, + { + display: 'flex', + flexDirection: 'column', + gap: config.space.S100, + width: '100%', + minWidth: 0, + padding: `${config.space.S200} ${config.space.S200}`, + borderRadius: config.radii.R300, + border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`, + backgroundColor: color.SurfaceVariant.Container, + color: color.SurfaceVariant.OnContainer, + textAlign: 'left', + cursor: 'pointer', + transition: 'background-color 120ms ease, border-color 120ms ease', + ':hover': { + backgroundColor: color.SurfaceVariant.ContainerHover, + }, + ':focus-visible': { + outline: `${config.borderWidth.B300} solid ${color.Primary.Main}`, + outlineOffset: '1px', + }, + }, +]); + +export const EntryTopRow = style({ + display: 'flex', + alignItems: 'baseline', + justifyContent: 'space-between', + gap: config.space.S100, + minWidth: 0, +}); + +export const EntryRoomName = style({ + minWidth: 0, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', +}); + +export const EntryTime = style({ + flexShrink: 0, + whiteSpace: 'nowrap', +}); + +export const EntrySummary = style({ + minWidth: 0, + display: '-webkit-box', + WebkitBoxOrient: 'vertical', + WebkitLineClamp: 2, + overflow: 'hidden', + wordBreak: 'break-word', +}); + +export const Divider = style([ + DefaultReset, + { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + height: '8px', + cursor: 'row-resize', + touchAction: 'none', + userSelect: 'none', + backgroundColor: color.Background.Container, + ':focus-visible': { + outline: `${config.borderWidth.B300} solid ${color.Primary.Main}`, + outlineOffset: '1px', + }, + }, +]); + +export const DividerActive = style({ + backgroundColor: color.Background.ContainerHover, +}); + +export const DividerHandle = style({ + width: '100%', + maxWidth: '40px', + height: '2px', + borderRadius: '999px', + backgroundColor: color.SurfaceVariant.ContainerLine, +}); + +export const DividerToggle = style([ + DefaultReset, + { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + flexShrink: 0, + gap: config.space.S200, + width: '100%', + height: '44px', + padding: `0 ${config.space.S300}`, + cursor: 'pointer', + touchAction: 'manipulation', + backgroundColor: color.Background.Container, + color: color.SurfaceVariant.OnContainer, + ':hover': { + backgroundColor: color.Background.ContainerHover, + }, + ':focus-visible': { + outline: `${config.borderWidth.B300} solid ${color.Primary.Main}`, + outlineOffset: '1px', + }, + }, +]); + +export const DividerToggleHandle = style({ + width: '24px', + flexShrink: 0, +}); + +export const PanelHeaderButton = style([ + DefaultReset, + { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + width: '100%', + minHeight: '32px', + padding: `0 ${config.space.S300}`, + color: color.SurfaceVariant.OnContainer, + cursor: 'pointer', + ':hover': { + backgroundColor: color.SurfaceVariant.ContainerHover, + }, + ':focus-visible': { + outline: `${config.borderWidth.B300} solid ${color.Primary.Main}`, + outlineOffset: '1px', + }, + }, +]); diff --git a/src/app/mindroom/recent-threads/recentThreads.test.ts b/src/app/mindroom/recent-threads/recentThreads.test.ts new file mode 100644 index 0000000000..17c6ef84e0 --- /dev/null +++ b/src/app/mindroom/recent-threads/recentThreads.test.ts @@ -0,0 +1,285 @@ +import { createStore, getDefaultStore } from 'jotai'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + bumpRecentThread, + clearRecentThreadsStore, + makeRecentThreadsAtom, + registerRecentThreadsAtom, + rekeyRecentThread, +} from './recentThreads'; +import { setImperativeJotaiStore } from '../../state/jotaiStore'; + +const USER_ID = '@alice:example.org'; + +const createStorage = (): Storage => { + const state = new Map(); + + return { + getItem: vi.fn((key: string) => state.get(key) ?? null), + setItem: vi.fn((key: string, value: string) => { + state.set(key, value); + }), + removeItem: vi.fn((key: string) => { + state.delete(key); + }), + clear: vi.fn(() => { + state.clear(); + }), + key: vi.fn(() => null), + length: 0, + }; +}; + +describe('recentThreads', () => { + beforeEach(() => { + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: createStorage(), + }); + }); + + afterEach(() => { + clearRecentThreadsStore(USER_ID); + }); + + it('bumps an entry to the top and de-duplicates the same room/thread pair', () => { + const recentThreadsAtom = makeRecentThreadsAtom(USER_ID); + const store = getDefaultStore(); + + store.set(recentThreadsAtom, { + type: 'BUMP', + roomId: '!room:example.org', + threadId: '$older', + openedAt: 100, + }); + store.set(recentThreadsAtom, { + type: 'BUMP', + roomId: '!room:example.org', + threadId: '$newer', + openedAt: 200, + }); + store.set(recentThreadsAtom, { + type: 'BUMP', + roomId: '!room:example.org', + threadId: '$older', + openedAt: 300, + }); + + expect(store.get(recentThreadsAtom)).toEqual([ + { + roomId: '!room:example.org', + threadId: '$older', + openedAt: 300, + }, + { + roomId: '!room:example.org', + threadId: '$newer', + openedAt: 200, + }, + ]); + }); + + it('ignores local-echo thread ids when bumping recent threads', () => { + const recentThreadsAtom = makeRecentThreadsAtom(USER_ID); + const store = getDefaultStore(); + + store.set(recentThreadsAtom, { + type: 'BUMP', + roomId: '!room:example.org', + threadId: '~pending', + openedAt: 100, + }); + + expect(store.get(recentThreadsAtom)).toEqual([]); + }); + + it('drops legacy local-echo thread ids from stored recent threads', () => { + localStorage.setItem( + 'recentThreads:@alice:example.org', + JSON.stringify({ + v: 1, + entries: [ + { + roomId: '!room:example.org', + threadId: '~pending', + openedAt: 200, + }, + { + roomId: '!room:example.org', + threadId: '$confirmed', + openedAt: 100, + }, + ], + }) + ); + + const recentThreadsAtom = makeRecentThreadsAtom(USER_ID); + + expect(getDefaultStore().get(recentThreadsAtom)).toEqual([ + { + roomId: '!room:example.org', + threadId: '$confirmed', + openedAt: 100, + }, + ]); + }); + + it('rekeys reply ids to canonical roots without downgrading a newer canonical entry', () => { + const recentThreadsAtom = makeRecentThreadsAtom(USER_ID); + const store = getDefaultStore(); + + store.set(recentThreadsAtom, { + type: 'BUMP', + roomId: '!room:example.org', + threadId: '$root', + openedAt: 200, + }); + store.set(recentThreadsAtom, { + type: 'BUMP', + roomId: '!room:example.org', + threadId: '$other', + openedAt: 150, + }); + store.set(recentThreadsAtom, { + type: 'BUMP', + roomId: '!room:example.org', + threadId: '$reply', + openedAt: 100, + }); + + store.set(recentThreadsAtom, { + type: 'REKEY', + roomId: '!room:example.org', + threadId: '$reply', + nextThreadId: '$root', + }); + + expect(store.get(recentThreadsAtom)).toEqual([ + { + roomId: '!room:example.org', + threadId: '$root', + openedAt: 200, + }, + { + roomId: '!room:example.org', + threadId: '$other', + openedAt: 150, + }, + ]); + }); + + it('renames a stored reply id to the canonical root when no canonical entry exists yet', () => { + const recentThreadsAtom = makeRecentThreadsAtom(USER_ID); + const store = getDefaultStore(); + + store.set(recentThreadsAtom, { + type: 'BUMP', + roomId: '!room:example.org', + threadId: '$reply', + openedAt: 125, + }); + + store.set(recentThreadsAtom, { + type: 'REKEY', + roomId: '!room:example.org', + threadId: '$reply', + nextThreadId: '$root', + }); + + expect(store.get(recentThreadsAtom)).toEqual([ + { + roomId: '!room:example.org', + threadId: '$root', + openedAt: 125, + }, + ]); + }); + + it('does not rekey a recent thread to a local-echo id', () => { + const providerStore = createStore(); + const recentThreadsAtom = makeRecentThreadsAtom(USER_ID); + const unregisterStore = setImperativeJotaiStore(providerStore); + const unregisterAtom = registerRecentThreadsAtom(recentThreadsAtom); + + providerStore.set(recentThreadsAtom, { + type: 'BUMP', + roomId: '!room:example.org', + threadId: '$confirmed', + openedAt: 100, + }); + + rekeyRecentThread('!room:example.org', '$confirmed', '~pending'); + + expect(providerStore.get(recentThreadsAtom)).toEqual([ + { + roomId: '!room:example.org', + threadId: '$confirmed', + openedAt: 100, + }, + ]); + + unregisterAtom(); + unregisterStore(); + }); + + it('writes imperative recent-thread bumps into the registered provider store', () => { + const providerStore = createStore(); + const recentThreadsAtom = makeRecentThreadsAtom(USER_ID); + const unregisterStore = setImperativeJotaiStore(providerStore); + const unregisterAtom = registerRecentThreadsAtom(recentThreadsAtom); + + bumpRecentThread('!room:example.org', '$thread', 200, 'Preview text'); + + expect(providerStore.get(recentThreadsAtom)).toEqual([ + { + roomId: '!room:example.org', + threadId: '$thread', + openedAt: 200, + summaryText: 'Preview text', + }, + ]); + expect(getDefaultStore().get(recentThreadsAtom)).toEqual([]); + + unregisterAtom(); + unregisterStore(); + }); + + it('updates a stored summary without changing recency order', () => { + const providerStore = createStore(); + const recentThreadsAtom = makeRecentThreadsAtom(USER_ID); + const unregisterStore = setImperativeJotaiStore(providerStore); + const unregisterAtom = registerRecentThreadsAtom(recentThreadsAtom); + + providerStore.set(recentThreadsAtom, { + type: 'BUMP', + roomId: '!room:example.org', + threadId: '$older', + openedAt: 100, + }); + providerStore.set(recentThreadsAtom, { + type: 'BUMP', + roomId: '!room:example.org', + threadId: '$newer', + openedAt: 200, + }); + + bumpRecentThread('!room:example.org', '$older', 100, 'Stored preview'); + + expect(providerStore.get(recentThreadsAtom)).toEqual([ + { + roomId: '!room:example.org', + threadId: '$newer', + openedAt: 200, + }, + { + roomId: '!room:example.org', + threadId: '$older', + openedAt: 100, + summaryText: 'Stored preview', + }, + ]); + + unregisterAtom(); + unregisterStore(); + }); +}); diff --git a/src/app/mindroom/recent-threads/recentThreads.ts b/src/app/mindroom/recent-threads/recentThreads.ts new file mode 100644 index 0000000000..404339293f --- /dev/null +++ b/src/app/mindroom/recent-threads/recentThreads.ts @@ -0,0 +1,254 @@ +import { WritableAtom, atom } from 'jotai'; +import { + atomWithLocalStorage, + getLocalStorageItem, + setLocalStorageItem, +} from '../../state/utils/atomWithLocalStorage'; +import { getActiveSession } from '../../state/sessions'; +import { isRecord } from '../../utils/isRecord'; +import { getImperativeJotaiStore } from '../../state/jotaiStore'; +import { isConfirmedMatrixEventId } from '../threads/threadRouteUtils'; + +const RECENT_THREADS = 'recentThreads'; +const RECENT_THREADS_STORE_VERSION = 1; +export const MAX_RECENT_THREADS = 50; + +export type RecentThreadItem = { + roomId: string; + threadId: string; + openedAt: number; + summaryText?: string; +}; + +type RecentThreadsStore = { + v: typeof RECENT_THREADS_STORE_VERSION; + entries: RecentThreadItem[]; +}; + +type RecentThreadsAction = + | { + type: 'BUMP'; + roomId: string; + threadId: string; + openedAt?: number; + summaryText?: string; + } + | { + type: 'REMOVE'; + roomId: string; + threadId: string; + } + | { + type: 'REKEY'; + roomId: string; + threadId: string; + nextThreadId: string; + }; + +type RecentThreadsAtom = WritableAtom; + +const isRecentThreadItem = (value: unknown): value is RecentThreadItem => + isRecord(value) && + typeof value.roomId === 'string' && + value.roomId.length > 0 && + isConfirmedMatrixEventId(value.threadId) && + typeof value.openedAt === 'number' && + Number.isFinite(value.openedAt) && + value.openedAt > 0 && + (value.summaryText === undefined || typeof value.summaryText === 'string'); + +const normalizeRecentThreadSummaryText = (value: unknown): string | undefined => { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +}; + +const sortRecentThreads = (entries: RecentThreadItem[]): RecentThreadItem[] => + [...entries].sort((left, right) => right.openedAt - left.openedAt); + +const trimRecentThreads = (entries: RecentThreadItem[]): RecentThreadItem[] => + sortRecentThreads(entries).slice(0, MAX_RECENT_THREADS); + +const sanitizeRecentThreads = (value: unknown): RecentThreadItem[] => { + if (!isRecord(value) || value.v !== RECENT_THREADS_STORE_VERSION || !Array.isArray(value.entries)) { + return []; + } + + const deduped = new Map(); + + sortRecentThreads(value.entries.filter(isRecentThreadItem)).forEach((entry) => { + const key = `${entry.roomId}|${entry.threadId}`; + if (!deduped.has(key)) { + deduped.set(key, entry); + } + }); + + return Array.from(deduped.values()).slice(0, MAX_RECENT_THREADS); +}; + +const serializeRecentThreads = (entries: RecentThreadItem[]): RecentThreadsStore => ({ + v: RECENT_THREADS_STORE_VERSION, + entries, +}); + +const getStoreKey = (userId: string): string => `${RECENT_THREADS}:${userId}`; + +let activeRecentThreadsAtom: RecentThreadsAtom | undefined; +const recentThreadsAtoms = new Map(); + +export const makeRecentThreadsAtom = (userId: string): RecentThreadsAtom => { + const existingAtom = recentThreadsAtoms.get(userId); + if (existingAtom) return existingAtom; + + const storeKey = getStoreKey(userId); + + const baseRecentThreadsAtom = atomWithLocalStorage( + storeKey, + (key) => sanitizeRecentThreads(getLocalStorageItem(key, null)), + (key, value) => setLocalStorageItem(key, serializeRecentThreads(value)) + ); + + const recentThreadsAtom = atom( + (get) => get(baseRecentThreadsAtom), + (get, set, action) => { + const current = get(baseRecentThreadsAtom); + + if (action.type === 'REMOVE') { + const next = current.filter( + (entry) => entry.roomId !== action.roomId || entry.threadId !== action.threadId + ); + if (next.length === current.length) return; + set(baseRecentThreadsAtom, next); + return; + } + + if (action.type === 'REKEY') { + if (!action.roomId || !action.threadId || !action.nextThreadId) return; + if (!isConfirmedMatrixEventId(action.nextThreadId)) return; + if (action.threadId === action.nextThreadId) return; + + const existingEntry = current.find( + (entry) => entry.roomId === action.roomId && entry.threadId === action.threadId + ); + if (!existingEntry) return; + + const canonicalEntry = current.find( + (entry) => entry.roomId === action.roomId && entry.threadId === action.nextThreadId + ); + const openedAt = Math.max(existingEntry.openedAt, canonicalEntry?.openedAt ?? 0); + const next = current.filter( + (entry) => + entry.roomId !== action.roomId || + (entry.threadId !== action.threadId && entry.threadId !== action.nextThreadId) + ); + + next.push({ + roomId: action.roomId, + threadId: action.nextThreadId, + openedAt, + summaryText: + normalizeRecentThreadSummaryText(existingEntry.summaryText) ?? + normalizeRecentThreadSummaryText(canonicalEntry?.summaryText), + }); + + set(baseRecentThreadsAtom, trimRecentThreads(next)); + return; + } + + if (!action.roomId || !isConfirmedMatrixEventId(action.threadId)) return; + + const openedAt = + typeof action.openedAt === 'number' && Number.isFinite(action.openedAt) && action.openedAt > 0 + ? action.openedAt + : Date.now(); + const existingEntry = current.find( + (entry) => entry.roomId === action.roomId && entry.threadId === action.threadId + ); + const next = current.filter( + (entry) => entry.roomId !== action.roomId || entry.threadId !== action.threadId + ); + + next.push({ + roomId: action.roomId, + threadId: action.threadId, + openedAt, + summaryText: + normalizeRecentThreadSummaryText(action.summaryText) ?? + normalizeRecentThreadSummaryText(existingEntry?.summaryText), + }); + + set(baseRecentThreadsAtom, trimRecentThreads(next)); + } + ); + + recentThreadsAtoms.set(userId, recentThreadsAtom); + return recentThreadsAtom; +}; + +export const registerRecentThreadsAtom = (recentThreadsAtom: RecentThreadsAtom) => { + activeRecentThreadsAtom = recentThreadsAtom; + + return () => { + if (activeRecentThreadsAtom === recentThreadsAtom) { + activeRecentThreadsAtom = undefined; + } + }; +}; + +const getResolvedRecentThreadsAtom = (): RecentThreadsAtom | undefined => { + if (activeRecentThreadsAtom) return activeRecentThreadsAtom; + + const userId = getActiveSession()?.userId; + return userId ? makeRecentThreadsAtom(userId) : undefined; +}; + +export const bumpRecentThread = ( + roomId: string, + threadId: string, + openedAt?: number, + summaryText?: string +) => { + const recentThreadsAtom = getResolvedRecentThreadsAtom(); + if (!recentThreadsAtom) return; + + getImperativeJotaiStore().set(recentThreadsAtom, { + type: 'BUMP', + roomId, + threadId, + openedAt, + summaryText, + }); +}; + +export const removeRecentThread = (roomId: string, threadId: string) => { + const recentThreadsAtom = getResolvedRecentThreadsAtom(); + if (!recentThreadsAtom) return; + + getImperativeJotaiStore().set(recentThreadsAtom, { + type: 'REMOVE', + roomId, + threadId, + }); +}; + +export const rekeyRecentThread = (roomId: string, threadId: string, nextThreadId: string) => { + const recentThreadsAtom = getResolvedRecentThreadsAtom(); + if (!recentThreadsAtom) return; + + getImperativeJotaiStore().set(recentThreadsAtom, { + type: 'REKEY', + roomId, + threadId, + nextThreadId, + }); +}; + +export const clearRecentThreadsStore = (userId: string) => { + const recentThreadsAtom = recentThreadsAtoms.get(userId); + if (activeRecentThreadsAtom === recentThreadsAtom) { + activeRecentThreadsAtom = undefined; + } + + recentThreadsAtoms.delete(userId); + localStorage.removeItem(getStoreKey(userId)); +}; diff --git a/src/app/mindroom/recent-threads/recentThreadsPanelHeight.ts b/src/app/mindroom/recent-threads/recentThreadsPanelHeight.ts new file mode 100644 index 0000000000..91e8affda4 --- /dev/null +++ b/src/app/mindroom/recent-threads/recentThreadsPanelHeight.ts @@ -0,0 +1,108 @@ +import { WritableAtom, atom } from 'jotai'; +import { + atomWithLocalStorage, + getLocalStorageItem, + setLocalStorageItem, +} from '../../state/utils/atomWithLocalStorage'; +import { getActiveSession } from '../../state/sessions'; +import { isRecord } from '../../utils/isRecord'; +import { getImperativeJotaiStore } from '../../state/jotaiStore'; + +const RECENT_THREADS_PANEL_HEIGHT = 'recentThreadsPanelHeight'; +const RECENT_THREADS_PANEL_HEIGHT_STORE_VERSION = 1; + +export const RECENT_THREADS_PANEL_DEFAULT_HEIGHT = 200; +export const RECENT_THREADS_PANEL_MIN_HEIGHT = 80; +export const RECENT_THREADS_PANEL_COLLAPSED_HEIGHT = 32; + +type RecentThreadsPanelHeightStore = { + v: typeof RECENT_THREADS_PANEL_HEIGHT_STORE_VERSION; + height: number; +}; + +type RecentThreadsPanelHeightAtom = WritableAtom; + +const normalizePanelHeight = (value: unknown): number => { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { + return RECENT_THREADS_PANEL_DEFAULT_HEIGHT; + } + + return Math.round(value); +}; + +const sanitizeStoredPanelHeight = (value: unknown): number => { + if (!isRecord(value) || value.v !== RECENT_THREADS_PANEL_HEIGHT_STORE_VERSION) { + return RECENT_THREADS_PANEL_DEFAULT_HEIGHT; + } + + return normalizePanelHeight(value.height); +}; + +const serializePanelHeight = (height: number): RecentThreadsPanelHeightStore => ({ + v: RECENT_THREADS_PANEL_HEIGHT_STORE_VERSION, + height, +}); + +const getStoreKey = (userId: string): string => `${RECENT_THREADS_PANEL_HEIGHT}:${userId}`; + +let activeRecentThreadsPanelHeightAtom: RecentThreadsPanelHeightAtom | undefined; +const recentThreadsPanelHeightAtoms = new Map(); + +export const makeRecentThreadsPanelHeightAtom = (userId: string): RecentThreadsPanelHeightAtom => { + const existingAtom = recentThreadsPanelHeightAtoms.get(userId); + if (existingAtom) return existingAtom; + + const storeKey = getStoreKey(userId); + + const baseRecentThreadsPanelHeightAtom = atomWithLocalStorage( + storeKey, + (key) => sanitizeStoredPanelHeight(getLocalStorageItem(key, null)), + (key, value) => setLocalStorageItem(key, serializePanelHeight(normalizePanelHeight(value))) + ); + + const recentThreadsPanelHeightAtom = atom( + (get) => get(baseRecentThreadsPanelHeightAtom), + (_get, set, nextHeight) => { + set(baseRecentThreadsPanelHeightAtom, normalizePanelHeight(nextHeight)); + } + ); + + recentThreadsPanelHeightAtoms.set(userId, recentThreadsPanelHeightAtom); + return recentThreadsPanelHeightAtom; +}; + +export const registerRecentThreadsPanelHeightAtom = ( + recentThreadsPanelHeightAtom: RecentThreadsPanelHeightAtom +) => { + activeRecentThreadsPanelHeightAtom = recentThreadsPanelHeightAtom; + + return () => { + if (activeRecentThreadsPanelHeightAtom === recentThreadsPanelHeightAtom) { + activeRecentThreadsPanelHeightAtom = undefined; + } + }; +}; + +const getResolvedRecentThreadsPanelHeightAtom = (): RecentThreadsPanelHeightAtom | undefined => { + if (activeRecentThreadsPanelHeightAtom) return activeRecentThreadsPanelHeightAtom; + + const userId = getActiveSession()?.userId; + return userId ? makeRecentThreadsPanelHeightAtom(userId) : undefined; +}; + +export const setRecentThreadsPanelHeight = (height: number) => { + const recentThreadsPanelHeightAtom = getResolvedRecentThreadsPanelHeightAtom(); + if (!recentThreadsPanelHeightAtom) return; + + getImperativeJotaiStore().set(recentThreadsPanelHeightAtom, height); +}; + +export const clearRecentThreadsPanelHeightStore = (userId: string) => { + const recentThreadsPanelHeightAtom = recentThreadsPanelHeightAtoms.get(userId); + if (activeRecentThreadsPanelHeightAtom === recentThreadsPanelHeightAtom) { + activeRecentThreadsPanelHeightAtom = undefined; + } + + recentThreadsPanelHeightAtoms.delete(userId); + localStorage.removeItem(getStoreKey(userId)); +}; diff --git a/src/app/mindroom/recent-threads/recentThreadsPanelMobileExpanded.ts b/src/app/mindroom/recent-threads/recentThreadsPanelMobileExpanded.ts new file mode 100644 index 0000000000..2bfe33b330 --- /dev/null +++ b/src/app/mindroom/recent-threads/recentThreadsPanelMobileExpanded.ts @@ -0,0 +1,107 @@ +import { WritableAtom, atom } from 'jotai'; +import { + atomWithLocalStorage, + getLocalStorageItem, + setLocalStorageItem, +} from '../../state/utils/atomWithLocalStorage'; +import { getActiveSession } from '../../state/sessions'; +import { isRecord } from '../../utils/isRecord'; +import { getImperativeJotaiStore } from '../../state/jotaiStore'; + +const RECENT_THREADS_PANEL_MOBILE_EXPANDED = 'recentThreadsPanelMobileExpanded'; +const RECENT_THREADS_PANEL_MOBILE_EXPANDED_STORE_VERSION = 1; + +type RecentThreadsPanelMobileExpandedStore = { + expanded: boolean; + v: typeof RECENT_THREADS_PANEL_MOBILE_EXPANDED_STORE_VERSION; +}; + +type RecentThreadsPanelMobileExpandedAtom = WritableAtom; + +const normalizePanelMobileExpanded = (value: unknown): boolean => value === true; + +const sanitizeStoredPanelMobileExpanded = (value: unknown): boolean => { + if (!isRecord(value) || value.v !== RECENT_THREADS_PANEL_MOBILE_EXPANDED_STORE_VERSION) { + return false; + } + + return normalizePanelMobileExpanded(value.expanded); +}; + +const serializePanelMobileExpanded = ( + expanded: boolean +): RecentThreadsPanelMobileExpandedStore => ({ + expanded, + v: RECENT_THREADS_PANEL_MOBILE_EXPANDED_STORE_VERSION, +}); + +const getStoreKey = (userId: string): string => `${RECENT_THREADS_PANEL_MOBILE_EXPANDED}:${userId}`; + +let activeRecentThreadsPanelMobileExpandedAtom: RecentThreadsPanelMobileExpandedAtom | undefined; +const recentThreadsPanelMobileExpandedAtoms = new Map(); + +export const makeRecentThreadsPanelMobileExpandedAtom = ( + userId: string +): RecentThreadsPanelMobileExpandedAtom => { + const existingAtom = recentThreadsPanelMobileExpandedAtoms.get(userId); + if (existingAtom) return existingAtom; + + const storeKey = getStoreKey(userId); + + const baseRecentThreadsPanelMobileExpandedAtom = atomWithLocalStorage( + storeKey, + (key) => sanitizeStoredPanelMobileExpanded(getLocalStorageItem(key, null)), + (key, value) => + setLocalStorageItem(key, serializePanelMobileExpanded(normalizePanelMobileExpanded(value))) + ); + + const recentThreadsPanelMobileExpandedAtom = atom( + (get) => get(baseRecentThreadsPanelMobileExpandedAtom), + (_get, set, nextExpanded) => { + set(baseRecentThreadsPanelMobileExpandedAtom, normalizePanelMobileExpanded(nextExpanded)); + } + ); + + recentThreadsPanelMobileExpandedAtoms.set(userId, recentThreadsPanelMobileExpandedAtom); + return recentThreadsPanelMobileExpandedAtom; +}; + +export const registerRecentThreadsPanelMobileExpandedAtom = ( + recentThreadsPanelMobileExpandedAtom: RecentThreadsPanelMobileExpandedAtom +) => { + activeRecentThreadsPanelMobileExpandedAtom = recentThreadsPanelMobileExpandedAtom; + + return () => { + if (activeRecentThreadsPanelMobileExpandedAtom === recentThreadsPanelMobileExpandedAtom) { + activeRecentThreadsPanelMobileExpandedAtom = undefined; + } + }; +}; + +const getResolvedRecentThreadsPanelMobileExpandedAtom = (): + | RecentThreadsPanelMobileExpandedAtom + | undefined => { + if (activeRecentThreadsPanelMobileExpandedAtom) { + return activeRecentThreadsPanelMobileExpandedAtom; + } + + const userId = getActiveSession()?.userId; + return userId ? makeRecentThreadsPanelMobileExpandedAtom(userId) : undefined; +}; + +export const setRecentThreadsPanelMobileExpanded = (expanded: boolean) => { + const recentThreadsPanelMobileExpandedAtom = getResolvedRecentThreadsPanelMobileExpandedAtom(); + if (!recentThreadsPanelMobileExpandedAtom) return; + + getImperativeJotaiStore().set(recentThreadsPanelMobileExpandedAtom, expanded); +}; + +export const clearRecentThreadsPanelMobileExpandedStore = (userId: string) => { + const recentThreadsPanelMobileExpandedAtom = recentThreadsPanelMobileExpandedAtoms.get(userId); + if (activeRecentThreadsPanelMobileExpandedAtom === recentThreadsPanelMobileExpandedAtom) { + activeRecentThreadsPanelMobileExpandedAtom = undefined; + } + + recentThreadsPanelMobileExpandedAtoms.delete(userId); + localStorage.removeItem(getStoreKey(userId)); +}; diff --git a/src/app/mindroom/recent-threads/recentThreadsPanelUtils.ts b/src/app/mindroom/recent-threads/recentThreadsPanelUtils.ts new file mode 100644 index 0000000000..b0e9052e93 --- /dev/null +++ b/src/app/mindroom/recent-threads/recentThreadsPanelUtils.ts @@ -0,0 +1,21 @@ +import type { Room } from 'matrix-js-sdk'; +import { type RecentThreadItem } from './recentThreads'; + +export type VisibleRecentThreadItem = RecentThreadItem & { + room: Room; +}; + +export const buildVisibleRecentThreadEntries = ( + getRoom: (roomId: string) => Room | null | undefined, + recentThreads: RecentThreadItem[] +): VisibleRecentThreadItem[] => + recentThreads.reduce((entries, recentThread) => { + const room = getRoom(recentThread.roomId); + if (!room || room.getMyMembership() !== 'join') return entries; + + entries.push({ + ...recentThread, + room, + }); + return entries; + }, []); diff --git a/src/app/mindroom/recent-threads/useDebouncedViewportHeight.ts b/src/app/mindroom/recent-threads/useDebouncedViewportHeight.ts new file mode 100644 index 0000000000..28acfe8ac2 --- /dev/null +++ b/src/app/mindroom/recent-threads/useDebouncedViewportHeight.ts @@ -0,0 +1,36 @@ +import { useEffect, useState } from 'react'; + +const VIEWPORT_RESIZE_DEBOUNCE_MS = 100; + +const getViewportHeight = (): number => + typeof window === 'undefined' ? 0 : window.innerHeight; + +export const useDebouncedViewportHeight = ( + debounceMs = VIEWPORT_RESIZE_DEBOUNCE_MS +): number => { + const [viewportHeight, setViewportHeight] = useState(getViewportHeight); + + useEffect(() => { + if (typeof window === 'undefined') return undefined; + + let timeoutId: number | undefined; + + const handleResize = () => { + window.clearTimeout(timeoutId); + timeoutId = window.setTimeout(() => { + setViewportHeight(getViewportHeight()); + timeoutId = undefined; + }, debounceMs); + }; + + handleResize(); + window.addEventListener('resize', handleResize); + + return () => { + window.clearTimeout(timeoutId); + window.removeEventListener('resize', handleResize); + }; + }, [debounceMs]); + + return viewportHeight; +}; diff --git a/src/app/mindroom/recent-threads/useResolvedRecentThreadsLayout.test.ts b/src/app/mindroom/recent-threads/useResolvedRecentThreadsLayout.test.ts new file mode 100644 index 0000000000..edc758ecf6 --- /dev/null +++ b/src/app/mindroom/recent-threads/useResolvedRecentThreadsLayout.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { ScreenSize } from '../../hooks/useScreenSize'; +import { RECENT_THREADS_PANEL_COLLAPSED_HEIGHT } from './recentThreadsPanelHeight'; +import { resolveRecentThreadsLayout } from './useResolvedRecentThreadsLayout'; + +const screenSizes = [ScreenSize.Desktop, ScreenSize.Tablet, ScreenSize.Mobile] as const; +const viewportHeights = [360, 480, 768, 1080] as const; +const storedHeights = [0, 32, 80, 200, 600] as const; +const mobileExpandedStates = [false, true] as const; + +const getExpectedDesktopHeight = (storedHeight: number, viewportHeight: number): number => { + const maxHeight = Math.max(80, Math.round(viewportHeight * 0.6)); + + if (storedHeight < 80) { + return RECENT_THREADS_PANEL_COLLAPSED_HEIGHT; + } + + return Math.min(Math.max(storedHeight, 80), maxHeight); +}; + +const getExpectedMobileHeight = (viewportHeight: number, mobileExpanded: boolean): number => { + if (!mobileExpanded) { + return 0; + } + + return Math.max(80, Math.min(240, Math.round(viewportHeight * 0.45))); +}; + +describe('resolveRecentThreadsLayout', () => { + it.each( + screenSizes.flatMap((screenSize) => + viewportHeights.flatMap((viewportHeight) => + storedHeights.flatMap((storedDesktopHeight) => + mobileExpandedStates.map((mobileExpanded) => ({ + screenSize, + viewportHeight, + storedDesktopHeight, + mobileExpanded, + })) + ) + ) + ) + )( + 'resolves $screenSize layout for viewport=$viewportHeight storedHeight=$storedDesktopHeight mobileExpanded=$mobileExpanded', + ({ screenSize, viewportHeight, storedDesktopHeight, mobileExpanded }) => { + const resolved = resolveRecentThreadsLayout({ + screenSize, + viewportHeight, + storedDesktopHeight, + mobileExpanded, + }); + + const expectedHeight = + screenSize === ScreenSize.Mobile + ? getExpectedMobileHeight(viewportHeight, mobileExpanded) + : getExpectedDesktopHeight(storedDesktopHeight, viewportHeight); + + expect(resolved.height).toBe(expectedHeight); + expect(resolved.dividerMode).toBe( + screenSize === ScreenSize.Mobile ? 'toggle' : 'resize' + ); + expect(resolved.isCollapsed).toBe(expectedHeight <= RECENT_THREADS_PANEL_COLLAPSED_HEIGHT); + expect(resolved.isExpanded).toBe(expectedHeight > RECENT_THREADS_PANEL_COLLAPSED_HEIGHT); + expect(resolved.maxHeight).toBe(Math.max(80, Math.round(viewportHeight * 0.6))); + } + ); +}); diff --git a/src/app/mindroom/recent-threads/useResolvedRecentThreadsLayout.ts b/src/app/mindroom/recent-threads/useResolvedRecentThreadsLayout.ts new file mode 100644 index 0000000000..1f67a403fe --- /dev/null +++ b/src/app/mindroom/recent-threads/useResolvedRecentThreadsLayout.ts @@ -0,0 +1,127 @@ +import { useCallback, useMemo } from 'react'; +import { ScreenSize } from '../../hooks/useScreenSize'; +import { + RECENT_THREADS_PANEL_COLLAPSED_HEIGHT, + RECENT_THREADS_PANEL_MIN_HEIGHT, +} from './recentThreadsPanelHeight'; + +export type RecentThreadsDividerMode = 'resize' | 'toggle'; + +export type ResolvedRecentThreadsLayout = { + dividerMode: RecentThreadsDividerMode; + height: number; + isCollapsed: boolean; + isExpanded: boolean; + maxHeight: number; + onTogglePanel: () => void; +}; + +type ResolvedRecentThreadsLayoutState = Omit; + +type ResolveRecentThreadsLayoutOptions = { + mobileExpanded: boolean; + onToggleMobileExpanded?: (expanded: boolean) => void; + screenSize: ScreenSize; + storedDesktopHeight: number; + viewportHeight: number; +}; + +const RECENT_THREADS_PANEL_MOBILE_MAX_HEIGHT = 240; +const RECENT_THREADS_PANEL_MOBILE_COLLAPSED_HEIGHT = 0; +const RECENT_THREADS_PANEL_MOBILE_VIEWPORT_RATIO = 0.45; +const RECENT_THREADS_PANEL_DESKTOP_VIEWPORT_RATIO = 0.6; + +export const getMaxRecentThreadsPanelHeight = (viewportHeight: number): number => + Math.max( + RECENT_THREADS_PANEL_MIN_HEIGHT, + Math.round(viewportHeight * RECENT_THREADS_PANEL_DESKTOP_VIEWPORT_RATIO) + ); + +export const getExpandedMobileRecentThreadsPanelHeight = (viewportHeight: number): number => + Math.max( + RECENT_THREADS_PANEL_MIN_HEIGHT, + Math.min( + RECENT_THREADS_PANEL_MOBILE_MAX_HEIGHT, + Math.round(viewportHeight * RECENT_THREADS_PANEL_MOBILE_VIEWPORT_RATIO) + ) + ); + +export const getCollapsedMobileRecentThreadsPanelHeight = (): number => + RECENT_THREADS_PANEL_MOBILE_COLLAPSED_HEIGHT; + +export const resolveDesktopRecentThreadsPanelHeight = ( + height: number, + maxHeight: number +): number => { + if (height < RECENT_THREADS_PANEL_MIN_HEIGHT) { + return RECENT_THREADS_PANEL_COLLAPSED_HEIGHT; + } + + return Math.min(Math.max(height, RECENT_THREADS_PANEL_MIN_HEIGHT), maxHeight); +}; + +export const resolveRecentThreadsLayout = ({ + screenSize, + viewportHeight, + storedDesktopHeight, + mobileExpanded, +}: ResolveRecentThreadsLayoutOptions): ResolvedRecentThreadsLayoutState => { + const maxHeight = getMaxRecentThreadsPanelHeight(viewportHeight); + + if (screenSize === ScreenSize.Mobile) { + const height = mobileExpanded + ? getExpandedMobileRecentThreadsPanelHeight(viewportHeight) + : getCollapsedMobileRecentThreadsPanelHeight(); + + return { + dividerMode: 'toggle', + height, + isCollapsed: !mobileExpanded, + isExpanded: mobileExpanded, + maxHeight, + }; + } + + const height = resolveDesktopRecentThreadsPanelHeight(storedDesktopHeight, maxHeight); + + return { + dividerMode: 'resize', + height, + isCollapsed: height <= RECENT_THREADS_PANEL_COLLAPSED_HEIGHT, + isExpanded: height > RECENT_THREADS_PANEL_COLLAPSED_HEIGHT, + maxHeight, + }; +}; + +export const useResolvedRecentThreadsLayout = ( + options: ResolveRecentThreadsLayoutOptions +): ResolvedRecentThreadsLayout => { + const { + mobileExpanded, + onToggleMobileExpanded, + screenSize, + storedDesktopHeight, + viewportHeight, + } = options; + + const layout = useMemo( + () => + resolveRecentThreadsLayout({ + mobileExpanded, + screenSize, + storedDesktopHeight, + viewportHeight, + }), + [mobileExpanded, screenSize, storedDesktopHeight, viewportHeight] + ); + + const onTogglePanel = useCallback(() => { + if (screenSize !== ScreenSize.Mobile || !onToggleMobileExpanded) return; + onToggleMobileExpanded(!mobileExpanded); + }, [mobileExpanded, onToggleMobileExpanded, screenSize]); + + return { + ...layout, + onTogglePanel, + }; +}; diff --git a/src/app/mindroom/routing/__tests__/ClientLayout.test.ts b/src/app/mindroom/routing/__tests__/ClientLayout.test.ts new file mode 100644 index 0000000000..15ea9bbc53 --- /dev/null +++ b/src/app/mindroom/routing/__tests__/ClientLayout.test.ts @@ -0,0 +1,398 @@ +import React from 'react'; +import { act, create } from 'react-test-renderer'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ClientLayout } from '../../../pages/client/ClientLayout'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { useRoomNavigate } from '../../../hooks/useRoomNavigate'; +import { useActiveSession } from '../../../hooks/useSessionStore'; +import { getLastOpenThread } from '../../threads/lastOpenThread'; +import { updateSessionLastPath } from '../../../state/sessions'; + +vi.mock('folds', async () => { + const reactModule = await import('react'); + return { + Box: ({ children }: { children: React.ReactNode }) => reactModule.createElement('div', null, children), + }; +}); + +vi.mock('react-router-dom', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useLocation: vi.fn(), + useNavigate: vi.fn(), + }; +}); + +vi.mock('../../../hooks/useMatrixClient', () => ({ + useMatrixClient: vi.fn(), +})); + +vi.mock('../../../hooks/useRoomNavigate', () => ({ + useRoomNavigate: vi.fn(), +})); + +vi.mock('../../../hooks/useSessionStore', () => ({ + useActiveSession: vi.fn(), +})); + +vi.mock('../../threads/lastOpenThread', () => ({ + getLastOpenThread: vi.fn(), +})); + +vi.mock('../../../state/sessions', () => ({ + updateSessionLastPath: vi.fn(), +})); + +describe('ClientLayout', () => { + const navigate = vi.fn(); + const navigateRoomThread = vi.fn(); + + beforeEach(() => { + vi.mocked(useMatrixClient).mockReturnValue({ + getRooms: () => [], + getSyncState: () => null, + getRoomIdForAlias: vi.fn().mockRejectedValue(new Error('alias not found')), + } as ReturnType); + vi.mocked(useRoomNavigate).mockReturnValue({ + navigateRoomThread, + } as ReturnType); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('persists the exact active-session route including search and hash', async () => { + const { useLocation, useNavigate } = await import('react-router-dom'); + vi.mocked(useNavigate).mockReturnValue(navigate); + + vi.mocked(useActiveSession).mockReturnValue({ + sessionId: 'session-a', + baseUrl: 'https://chat.mindroom.chat', + userId: '@alice:mindroom.chat', + deviceId: 'DEVICE', + accessToken: 'token', + lastUsedAt: 1, + }); + vi.mocked(useLocation).mockReturnValue({ + pathname: '/space/%23lobby%3Amindroom.chat', + search: '?threadId=%24abc', + hash: '#reply', + } as ReturnType); + + await act(async () => { + create( + React.createElement( + ClientLayout, + { + nav: React.createElement('div', null, 'nav'), + }, + React.createElement('div', null, 'content') + ) + ); + }); + + expect(vi.mocked(updateSessionLastPath)).toHaveBeenCalledWith( + 'session-a', + '/space/%23lobby%3Amindroom.chat?threadId=%24abc#reply' + ); + }); + + it('restores the saved room thread from the exact saved route when startup lands on bare home', async () => { + const { useLocation, useNavigate } = await import('react-router-dom'); + vi.mocked(useNavigate).mockReturnValue(navigate); + + vi.mocked(useActiveSession).mockReturnValue({ + sessionId: 'session-a', + baseUrl: 'https://chat.mindroom.chat', + userId: '@alice:mindroom.chat', + deviceId: 'DEVICE', + accessToken: 'token', + lastUsedAt: 1, + lastKnownPath: '/direct/%21saved%3Amindroom.chat/?threadId=%24stale', + }); + vi.mocked(useLocation).mockReturnValue({ + pathname: '/home/', + search: '', + hash: '', + } as ReturnType); + vi.mocked(getLastOpenThread).mockReturnValue('$saved-thread'); + + await act(async () => { + create( + React.createElement( + ClientLayout, + { + nav: React.createElement('div', null, 'nav'), + }, + React.createElement('div', null, 'content') + ) + ); + }); + + expect(vi.mocked(getLastOpenThread)).toHaveBeenCalledWith('!saved:mindroom.chat'); + expect(navigate).toHaveBeenCalledWith( + '/direct/%21saved%3Amindroom.chat/?threadId=%24saved-thread', + { replace: true } + ); + expect(navigateRoomThread).not.toHaveBeenCalled(); + }); + + it('preserves the saved space route when restoring a thread from bare home', async () => { + const { useLocation, useNavigate } = await import('react-router-dom'); + vi.mocked(useNavigate).mockReturnValue(navigate); + + vi.mocked(useActiveSession).mockReturnValue({ + sessionId: 'session-a', + baseUrl: 'https://chat.mindroom.chat', + userId: '@alice:mindroom.chat', + deviceId: 'DEVICE', + accessToken: 'token', + lastUsedAt: 1, + lastKnownPath: '/%21space%3Amindroom.chat/%21saved%3Amindroom.chat/?threadId=%24stale-thread', + }); + vi.mocked(useLocation).mockReturnValue({ + pathname: '/home/', + search: '', + hash: '', + } as ReturnType); + vi.mocked(getLastOpenThread).mockReturnValue('$saved-thread'); + + await act(async () => { + create( + React.createElement( + ClientLayout, + { + nav: React.createElement('div', null, 'nav'), + }, + React.createElement('div', null, 'content') + ) + ); + }); + + expect(vi.mocked(getLastOpenThread)).toHaveBeenCalledWith('!saved:mindroom.chat'); + expect(navigate).toHaveBeenCalledWith( + '/%21space%3Amindroom.chat/%21saved%3Amindroom.chat/?threadId=%24saved-thread', + { replace: true } + ); + expect(navigateRoomThread).not.toHaveBeenCalled(); + }); + + it('stores room-id restore paths even when the visible route uses canonical aliases', async () => { + const { useLocation, useNavigate } = await import('react-router-dom'); + vi.mocked(useNavigate).mockReturnValue(navigate); + + vi.mocked(useMatrixClient).mockReturnValue({ + getRooms: () => [ + { + roomId: '!space:mindroom.chat', + getCanonicalAlias: () => '#space:mindroom.chat', + getLiveTimeline: () => ({ + getState: () => ({ + getStateEvents: () => undefined, + }), + }), + }, + { + roomId: '!room:mindroom.chat', + getCanonicalAlias: () => '#room:mindroom.chat', + getLiveTimeline: () => ({ + getState: () => ({ + getStateEvents: () => undefined, + }), + }), + }, + ], + } as ReturnType); + vi.mocked(useActiveSession).mockReturnValue({ + sessionId: 'session-a', + baseUrl: 'https://chat.mindroom.chat', + userId: '@alice:mindroom.chat', + deviceId: 'DEVICE', + accessToken: 'token', + lastUsedAt: 1, + }); + vi.mocked(useLocation).mockReturnValue({ + pathname: '/%23space%3Amindroom.chat/%23room%3Amindroom.chat/', + search: '?threadId=%24abc', + hash: '#reply', + } as ReturnType); + + await act(async () => { + create( + React.createElement( + ClientLayout, + { + nav: React.createElement('div', null, 'nav'), + }, + React.createElement('div', null, 'content') + ) + ); + }); + + expect(vi.mocked(updateSessionLastPath)).toHaveBeenCalledWith( + 'session-a', + '/!space%3Amindroom.chat/!room%3Amindroom.chat?threadId=%24abc#reply' + ); + }); + + it('replaces alias startup routes with room-id routes before rendering nested pages', async () => { + const { useLocation, useNavigate } = await import('react-router-dom'); + vi.mocked(useNavigate).mockReturnValue(navigate); + + const getRoomIdForAlias = vi.fn(async (alias: string) => ({ + room_id: alias === '#space:mindroom.chat' ? '!space:mindroom.chat' : '!room:mindroom.chat', + })); + + vi.mocked(useMatrixClient).mockReturnValue({ + getRooms: () => [], + getSyncState: () => null, + getRoomIdForAlias, + } as ReturnType); + vi.mocked(useActiveSession).mockReturnValue({ + sessionId: 'session-a', + baseUrl: 'https://chat.mindroom.chat', + userId: '@alice:mindroom.chat', + deviceId: 'DEVICE', + accessToken: 'token', + lastUsedAt: 1, + }); + vi.mocked(useLocation).mockReturnValue({ + pathname: '/%23space%3Amindroom.chat/%23room%3Amindroom.chat/', + search: '?threadId=%24abc', + hash: '', + } as ReturnType); + + await act(async () => { + create( + React.createElement( + ClientLayout, + { + nav: React.createElement('div', null, 'nav'), + }, + React.createElement('div', null, 'content') + ) + ); + await Promise.resolve(); + }); + + expect(getRoomIdForAlias).toHaveBeenCalledWith('#space:mindroom.chat'); + expect(getRoomIdForAlias).toHaveBeenCalledWith('#room:mindroom.chat'); + expect(navigate).toHaveBeenCalledWith('/!space%3Amindroom.chat/!room%3Amindroom.chat?threadId=%24abc', { + replace: true, + }); + }); + + it('does not override explicit room navigation on startup', async () => { + const { useLocation, useNavigate } = await import('react-router-dom'); + vi.mocked(useNavigate).mockReturnValue(navigate); + + vi.mocked(useActiveSession).mockReturnValue({ + sessionId: 'session-a', + baseUrl: 'https://chat.mindroom.chat', + userId: '@alice:mindroom.chat', + deviceId: 'DEVICE', + accessToken: 'token', + lastUsedAt: 1, + lastKnownPath: '/home/%21saved%3Amindroom.chat/', + }); + vi.mocked(useLocation).mockReturnValue({ + pathname: '/home/%21live%3Amindroom.chat/', + search: '', + hash: '', + } as ReturnType); + + await act(async () => { + create( + React.createElement( + ClientLayout, + { + nav: React.createElement('div', null, 'nav'), + }, + React.createElement('div', null, 'content') + ) + ); + }); + + expect(vi.mocked(getLastOpenThread)).not.toHaveBeenCalled(); + expect(navigateRoomThread).not.toHaveBeenCalled(); + }); + + it('attempts the startup restore only once per mount', async () => { + const { useLocation, useNavigate } = await import('react-router-dom'); + vi.mocked(useNavigate).mockReturnValue(navigate); + + vi.mocked(useActiveSession).mockReturnValue({ + sessionId: 'session-a', + baseUrl: 'https://chat.mindroom.chat', + userId: '@alice:mindroom.chat', + deviceId: 'DEVICE', + accessToken: 'token', + lastUsedAt: 1, + lastKnownPath: '/home/%21saved%3Amindroom.chat/', + }); + vi.mocked(useLocation).mockReturnValue({ + pathname: '/home/', + search: '', + hash: '', + } as ReturnType); + vi.mocked(getLastOpenThread).mockReturnValue('$saved-thread'); + + let renderer: ReturnType; + await act(async () => { + renderer = create( + React.createElement( + ClientLayout, + { + nav: React.createElement('div', null, 'nav'), + }, + React.createElement('div', null, 'content') + ) + ); + }); + + await act(async () => { + renderer!.update( + React.createElement( + ClientLayout, + { + nav: React.createElement('div', null, 'nav'), + }, + React.createElement('div', null, 'content') + ) + ); + }); + + expect(navigateRoomThread).not.toHaveBeenCalled(); + expect(navigate).toHaveBeenCalledTimes(1); + }); + + it('does nothing when there is no active session', async () => { + const { useLocation, useNavigate } = await import('react-router-dom'); + vi.mocked(useNavigate).mockReturnValue(navigate); + + vi.mocked(useActiveSession).mockReturnValue(undefined); + vi.mocked(useLocation).mockReturnValue({ + pathname: '/home/', + search: '', + hash: '', + } as ReturnType); + + await act(async () => { + create( + React.createElement( + ClientLayout, + { + nav: React.createElement('div', null, 'nav'), + }, + React.createElement('div', null, 'content') + ) + ); + }); + + expect(vi.mocked(updateSessionLastPath)).not.toHaveBeenCalled(); + expect(navigateRoomThread).not.toHaveBeenCalled(); + expect(navigate).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/mindroom/routing/clientRouteRestore.test.ts b/src/app/mindroom/routing/clientRouteRestore.test.ts new file mode 100644 index 0000000000..3d6aa085f6 --- /dev/null +++ b/src/app/mindroom/routing/clientRouteRestore.test.ts @@ -0,0 +1,148 @@ +import type { MatrixClient } from 'matrix-js-sdk'; +import { describe, expect, it, vi } from 'vitest'; +import { + buildThreadRestorePath, + canonicalizeSessionPathname, + getLastOpenThreadRestoreTarget, + getRoomIdFromLastKnownPath, + parseMindroomStoredRouteUrl, + pathnameContainsAliasRoute, + resolveCanonicalizedPathname, +} from './clientRouteRestore'; + +const makeRoom = (roomId: string, alias?: string) => ({ + roomId, + getCanonicalAlias: () => alias, + getLiveTimeline: () => ({ + getState: () => ({ + getStateEvents: () => undefined, + }), + }), +}); + +const makeMatrixClient = ({ + rooms = [], + resolvedAliases = {}, +}: { + rooms?: Array>; + resolvedAliases?: Record; +} = {}) => + ({ + getRooms: () => rooms, + getRoomIdForAlias: vi.fn(async (alias: string) => ({ + room_id: resolvedAliases[alias], + })), + } as unknown as MatrixClient); + +describe('clientRouteRestore', () => { + it('parses stored relative routes with a stable MindRoom base URL', () => { + expect(parseMindroomStoredRouteUrl('/home/%21room%3Amindroom.chat/')?.pathname).toBe( + '/home/%21room%3Amindroom.chat/' + ); + expect(parseMindroomStoredRouteUrl('http://[')).toBeUndefined(); + }); + + it('extracts room ids from saved room routes and resolves canonical aliases from local rooms', () => { + const mx = makeMatrixClient({ + rooms: [makeRoom('!room:mindroom.chat', '#room:mindroom.chat')], + }); + + expect(getRoomIdFromLastKnownPath(mx, '/direct/%21direct%3Amindroom.chat/')).toBe( + '!direct:mindroom.chat' + ); + expect(getRoomIdFromLastKnownPath(mx, '/home/%23room%3Amindroom.chat/')).toBe( + '!room:mindroom.chat' + ); + expect(getRoomIdFromLastKnownPath(mx, '/settings/')).toBeUndefined(); + }); + + it('builds thread restore paths without losing existing route search or hash state', () => { + expect( + buildThreadRestorePath( + '/%21space%3Amindroom.chat/%21room%3Amindroom.chat/?viaServers=a#reply', + '$thread' + ) + ).toBe( + '/%21space%3Amindroom.chat/%21room%3Amindroom.chat/?viaServers=a&threadId=%24thread#reply' + ); + expect(buildThreadRestorePath('/settings/', '$thread')).toBeUndefined(); + }); + + it('resolves last-open-thread startup restore targets from saved room routes', () => { + const mx = makeMatrixClient({ + rooms: [makeRoom('!room:mindroom.chat', '#room:mindroom.chat')], + }); + const getThreadForRoom = vi.fn((roomId: string) => + roomId === '!room:mindroom.chat' ? '$thread' : undefined + ); + + expect( + getLastOpenThreadRestoreTarget( + mx, + '/home/%23room%3Amindroom.chat/?viaServers=a#reply', + getThreadForRoom + ) + ).toEqual({ + type: 'path', + path: '/home/%23room%3Amindroom.chat/?viaServers=a&threadId=%24thread#reply', + }); + expect(getThreadForRoom).toHaveBeenCalledWith('!room:mindroom.chat'); + expect(getLastOpenThreadRestoreTarget(mx, '/settings/', getThreadForRoom)).toBeUndefined(); + }); + + it('skips last-open-thread startup restore for classic room mode', () => { + const mx = makeMatrixClient({ + rooms: [makeRoom('!room:mindroom.chat', '#room:mindroom.chat')], + }); + const getThreadForRoom = vi.fn(() => '$thread'); + const getViewModeForRoom = vi.fn(() => 'classic'); + + expect( + getLastOpenThreadRestoreTarget( + mx, + '/home/%23room%3Amindroom.chat/', + getThreadForRoom, + getViewModeForRoom + ) + ).toBeUndefined(); + expect(getViewModeForRoom).toHaveBeenCalledWith('!room:mindroom.chat'); + expect(getThreadForRoom).not.toHaveBeenCalled(); + }); + + it('detects alias routes before rendering children that would flash join fallbacks', () => { + expect(pathnameContainsAliasRoute('/home/%23room%3Amindroom.chat/')).toBe(true); + expect(pathnameContainsAliasRoute('/%23space%3Amindroom.chat/%21room%3Amindroom.chat/')).toBe( + true + ); + expect(pathnameContainsAliasRoute('/home/%21room%3Amindroom.chat/')).toBe(false); + }); + + it('canonicalizes known aliases before persisting the active session path', () => { + const mx = makeMatrixClient({ + rooms: [ + makeRoom('!space:mindroom.chat', '#space:mindroom.chat'), + makeRoom('!room:mindroom.chat', '#room:mindroom.chat'), + ], + }); + + expect( + canonicalizeSessionPathname(mx, '/%23space%3Amindroom.chat/%23room%3Amindroom.chat/') + ).toBe('/!space%3Amindroom.chat/!room%3Amindroom.chat'); + }); + + it('resolves unknown aliases through the homeserver before replacing the visible route', async () => { + const mx = makeMatrixClient({ + resolvedAliases: { + '#space:mindroom.chat': '!space:mindroom.chat', + '#room:mindroom.chat': '!room:mindroom.chat', + }, + }); + + await expect( + resolveCanonicalizedPathname(mx, '/%23space%3Amindroom.chat/%23room%3Amindroom.chat/') + ).resolves.toBe('/!space%3Amindroom.chat/!room%3Amindroom.chat'); + + expect(mx.getRoomIdForAlias).toHaveBeenCalledWith('#space:mindroom.chat'); + expect(mx.getRoomIdForAlias).toHaveBeenCalledWith('#room:mindroom.chat'); + }); +}); diff --git a/src/app/mindroom/routing/clientRouteRestore.ts b/src/app/mindroom/routing/clientRouteRestore.ts new file mode 100644 index 0000000000..86bd1f09dd --- /dev/null +++ b/src/app/mindroom/routing/clientRouteRestore.ts @@ -0,0 +1,393 @@ +import type { MatrixClient } from 'matrix-js-sdk'; +import { generatePath, matchPath } from 'react-router-dom'; +import { + DIRECT_ROOM_PATH, + HOME_ROOM_PATH, + SPACE_LOBBY_PATH, + SPACE_PATH, + SPACE_ROOM_PATH, + SPACE_SEARCH_PATH, +} from '../../pages/paths'; +import { getCanonicalAliasRoomId, isRoomAlias } from '../../utils/matrix'; +import { getLastOpenThread } from '../threads/lastOpenThread'; +import { getRoomViewMode } from '../threads/roomViewMode'; + +const MINDROOM_ROUTE_PARSE_BASE_URL = 'https://mindroom.local'; +const buildStoredRoutePath = ({ + pathname, + search = '', + hash = '', +}: { + pathname: string; + search?: string; + hash?: string; +}): string => `${pathname}${search}${hash}`; + +export type LastOpenThreadRestoreTarget = + | { + type: 'path'; + path: string; + } + | { + type: 'room-thread'; + roomId: string; + threadId: string; + }; + +export const parseMindroomStoredRouteUrl = (storedPath: string): URL | undefined => { + try { + return new URL(storedPath, MINDROOM_ROUTE_PARSE_BASE_URL); + } catch { + return undefined; + } +}; + +const decodeRouteParam = (value: string | undefined): string | undefined => { + if (!value) return undefined; + try { + return decodeURIComponent(value); + } catch { + return value; + } +}; + +const canonicalizeRouteParam = ( + mx: MatrixClient, + value: string | undefined +): string | undefined => { + const decodedValue = decodeRouteParam(value); + if (!decodedValue) return decodedValue; + if (!isRoomAlias(decodedValue)) return decodedValue; + return getCanonicalAliasRoomId(mx, decodedValue) ?? decodedValue; +}; + +const hasAliasRouteParam = (value: string | undefined): boolean => { + const decodedValue = decodeRouteParam(value); + return !!decodedValue && isRoomAlias(decodedValue); +}; + +const encodeMaybeDecodedRouteParam = (value: string | undefined): string | null => + value ? encodeURIComponent(decodeRouteParam(value) ?? value) : null; + +export const getRoomIdFromLastKnownPath = ( + mx: MatrixClient, + lastKnownPath?: string +): string | undefined => { + if (!lastKnownPath) return undefined; + + const parsedUrl = parseMindroomStoredRouteUrl(lastKnownPath); + if (!parsedUrl) return undefined; + + const pathname = parsedUrl.pathname; + const match = + matchPath({ path: HOME_ROOM_PATH, caseSensitive: true, end: true }, pathname) ?? + matchPath({ path: DIRECT_ROOM_PATH, caseSensitive: true, end: true }, pathname) ?? + matchPath({ path: SPACE_ROOM_PATH, caseSensitive: true, end: true }, pathname); + const roomIdOrAlias = match?.params.roomIdOrAlias; + if (!roomIdOrAlias) return undefined; + + const decodedRoomIdOrAlias = decodeRouteParam(roomIdOrAlias) ?? roomIdOrAlias; + if (isRoomAlias(decodedRoomIdOrAlias)) { + return getCanonicalAliasRoomId(mx, decodedRoomIdOrAlias); + } + + return decodedRoomIdOrAlias; +}; + +export const buildThreadRestorePath = ( + lastKnownPath: string | undefined, + threadId: string +): string | undefined => { + if (!lastKnownPath) return undefined; + + const parsedUrl = parseMindroomStoredRouteUrl(lastKnownPath); + if (!parsedUrl) return undefined; + + const pathname = parsedUrl.pathname; + const isRoomRoute = + !!matchPath({ path: HOME_ROOM_PATH, caseSensitive: true, end: true }, pathname) || + !!matchPath({ path: DIRECT_ROOM_PATH, caseSensitive: true, end: true }, pathname) || + !!matchPath({ path: SPACE_ROOM_PATH, caseSensitive: true, end: true }, pathname); + + if (!isRoomRoute) return undefined; + + parsedUrl.searchParams.set('threadId', threadId); + return buildStoredRoutePath({ + pathname, + search: parsedUrl.search, + hash: parsedUrl.hash, + }); +}; + +export const getLastOpenThreadRestoreTarget = ( + mx: MatrixClient, + lastKnownPath: string | undefined, + getThreadForRoom: (roomId: string) => string | undefined = getLastOpenThread, + getViewModeForRoom: (roomId: string) => string | undefined = getRoomViewMode +): LastOpenThreadRestoreTarget | undefined => { + const roomId = getRoomIdFromLastKnownPath(mx, lastKnownPath); + if (!roomId) return undefined; + if (getViewModeForRoom(roomId) === 'classic') return undefined; + + const threadId = getThreadForRoom(roomId); + if (!threadId) return undefined; + + const restorePath = buildThreadRestorePath(lastKnownPath, threadId); + if (restorePath) { + return { + type: 'path', + path: restorePath, + }; + } + + return { + type: 'room-thread', + roomId, + threadId, + }; +}; + +export const pathnameContainsAliasRoute = (pathname: string): boolean => { + const homeRoomMatch = matchPath( + { path: HOME_ROOM_PATH, caseSensitive: true, end: true }, + pathname + ); + if (homeRoomMatch) { + return hasAliasRouteParam(homeRoomMatch.params.roomIdOrAlias); + } + + const directRoomMatch = matchPath( + { path: DIRECT_ROOM_PATH, caseSensitive: true, end: true }, + pathname + ); + if (directRoomMatch) { + return hasAliasRouteParam(directRoomMatch.params.roomIdOrAlias); + } + + const spaceRoomMatch = matchPath( + { path: SPACE_ROOM_PATH, caseSensitive: true, end: true }, + pathname + ); + if (spaceRoomMatch) { + return ( + hasAliasRouteParam(spaceRoomMatch.params.spaceIdOrAlias) || + hasAliasRouteParam(spaceRoomMatch.params.roomIdOrAlias) + ); + } + + const spaceOnlyMatch = matchPath({ path: SPACE_PATH, caseSensitive: true, end: true }, pathname); + if (spaceOnlyMatch) { + return hasAliasRouteParam(spaceOnlyMatch.params.spaceIdOrAlias); + } + + const spaceLobbyMatch = matchPath( + { path: SPACE_LOBBY_PATH, caseSensitive: true, end: true }, + pathname + ); + if (spaceLobbyMatch) { + return hasAliasRouteParam(spaceLobbyMatch.params.spaceIdOrAlias); + } + + const spaceSearchMatch = matchPath( + { path: SPACE_SEARCH_PATH, caseSensitive: true, end: true }, + pathname + ); + if (spaceSearchMatch) { + return hasAliasRouteParam(spaceSearchMatch.params.spaceIdOrAlias); + } + + return false; +}; + +const resolveCanonicalRouteParam = async ( + mx: MatrixClient, + value: string | undefined +): Promise => { + const decodedValue = decodeRouteParam(value); + if (!decodedValue) return decodedValue; + if (!isRoomAlias(decodedValue)) return decodedValue; + + const knownRoomId = getCanonicalAliasRoomId(mx, decodedValue); + if (knownRoomId) return knownRoomId; + + try { + const response = await mx.getRoomIdForAlias(decodedValue); + return response.room_id ?? decodedValue; + } catch { + return decodedValue; + } +}; + +export const canonicalizeSessionPathname = (mx: MatrixClient, pathname: string): string => { + const homeRoomMatch = matchPath( + { path: HOME_ROOM_PATH, caseSensitive: true, end: true }, + pathname + ); + if (homeRoomMatch) { + return generatePath(HOME_ROOM_PATH, { + roomIdOrAlias: encodeURIComponent( + canonicalizeRouteParam(mx, homeRoomMatch.params.roomIdOrAlias) ?? + homeRoomMatch.params.roomIdOrAlias! + ), + eventId: encodeMaybeDecodedRouteParam(homeRoomMatch.params.eventId), + }); + } + + const directRoomMatch = matchPath( + { path: DIRECT_ROOM_PATH, caseSensitive: true, end: true }, + pathname + ); + if (directRoomMatch) { + return generatePath(DIRECT_ROOM_PATH, { + roomIdOrAlias: encodeURIComponent( + canonicalizeRouteParam(mx, directRoomMatch.params.roomIdOrAlias) ?? + directRoomMatch.params.roomIdOrAlias! + ), + eventId: encodeMaybeDecodedRouteParam(directRoomMatch.params.eventId), + }); + } + + const spaceRoomMatch = matchPath( + { path: SPACE_ROOM_PATH, caseSensitive: true, end: true }, + pathname + ); + if (spaceRoomMatch) { + return generatePath(SPACE_ROOM_PATH, { + spaceIdOrAlias: encodeURIComponent( + canonicalizeRouteParam(mx, spaceRoomMatch.params.spaceIdOrAlias) ?? + spaceRoomMatch.params.spaceIdOrAlias! + ), + roomIdOrAlias: encodeURIComponent( + canonicalizeRouteParam(mx, spaceRoomMatch.params.roomIdOrAlias) ?? + spaceRoomMatch.params.roomIdOrAlias! + ), + eventId: encodeMaybeDecodedRouteParam(spaceRoomMatch.params.eventId), + }); + } + + const spaceOnlyMatch = matchPath({ path: SPACE_PATH, caseSensitive: true, end: true }, pathname); + if (spaceOnlyMatch) { + return generatePath(SPACE_PATH, { + spaceIdOrAlias: encodeURIComponent( + canonicalizeRouteParam(mx, spaceOnlyMatch.params.spaceIdOrAlias) ?? + spaceOnlyMatch.params.spaceIdOrAlias! + ), + }); + } + + const spaceLobbyMatch = matchPath( + { path: SPACE_LOBBY_PATH, caseSensitive: true, end: true }, + pathname + ); + if (spaceLobbyMatch) { + return generatePath(SPACE_LOBBY_PATH, { + spaceIdOrAlias: encodeURIComponent( + canonicalizeRouteParam(mx, spaceLobbyMatch.params.spaceIdOrAlias) ?? + spaceLobbyMatch.params.spaceIdOrAlias! + ), + }); + } + + const spaceSearchMatch = matchPath( + { path: SPACE_SEARCH_PATH, caseSensitive: true, end: true }, + pathname + ); + if (spaceSearchMatch) { + return generatePath(SPACE_SEARCH_PATH, { + spaceIdOrAlias: encodeURIComponent( + canonicalizeRouteParam(mx, spaceSearchMatch.params.spaceIdOrAlias) ?? + spaceSearchMatch.params.spaceIdOrAlias! + ), + }); + } + + return pathname; +}; + +export const resolveCanonicalizedPathname = async ( + mx: MatrixClient, + pathname: string +): Promise => { + const homeRoomMatch = matchPath( + { path: HOME_ROOM_PATH, caseSensitive: true, end: true }, + pathname + ); + if (homeRoomMatch) { + return generatePath(HOME_ROOM_PATH, { + roomIdOrAlias: encodeURIComponent( + (await resolveCanonicalRouteParam(mx, homeRoomMatch.params.roomIdOrAlias)) ?? + homeRoomMatch.params.roomIdOrAlias! + ), + eventId: encodeMaybeDecodedRouteParam(homeRoomMatch.params.eventId), + }); + } + + const directRoomMatch = matchPath( + { path: DIRECT_ROOM_PATH, caseSensitive: true, end: true }, + pathname + ); + if (directRoomMatch) { + return generatePath(DIRECT_ROOM_PATH, { + roomIdOrAlias: encodeURIComponent( + (await resolveCanonicalRouteParam(mx, directRoomMatch.params.roomIdOrAlias)) ?? + directRoomMatch.params.roomIdOrAlias! + ), + eventId: encodeMaybeDecodedRouteParam(directRoomMatch.params.eventId), + }); + } + + const spaceRoomMatch = matchPath( + { path: SPACE_ROOM_PATH, caseSensitive: true, end: true }, + pathname + ); + if (spaceRoomMatch) { + const [spaceIdOrAlias, roomIdOrAlias] = await Promise.all([ + resolveCanonicalRouteParam(mx, spaceRoomMatch.params.spaceIdOrAlias), + resolveCanonicalRouteParam(mx, spaceRoomMatch.params.roomIdOrAlias), + ]); + + return generatePath(SPACE_ROOM_PATH, { + spaceIdOrAlias: encodeURIComponent(spaceIdOrAlias ?? spaceRoomMatch.params.spaceIdOrAlias!), + roomIdOrAlias: encodeURIComponent(roomIdOrAlias ?? spaceRoomMatch.params.roomIdOrAlias!), + eventId: encodeMaybeDecodedRouteParam(spaceRoomMatch.params.eventId), + }); + } + + const spaceOnlyMatch = matchPath({ path: SPACE_PATH, caseSensitive: true, end: true }, pathname); + if (spaceOnlyMatch) { + return generatePath(SPACE_PATH, { + spaceIdOrAlias: encodeURIComponent( + (await resolveCanonicalRouteParam(mx, spaceOnlyMatch.params.spaceIdOrAlias)) ?? + spaceOnlyMatch.params.spaceIdOrAlias! + ), + }); + } + + const spaceLobbyMatch = matchPath( + { path: SPACE_LOBBY_PATH, caseSensitive: true, end: true }, + pathname + ); + if (spaceLobbyMatch) { + return generatePath(SPACE_LOBBY_PATH, { + spaceIdOrAlias: encodeURIComponent( + (await resolveCanonicalRouteParam(mx, spaceLobbyMatch.params.spaceIdOrAlias)) ?? + spaceLobbyMatch.params.spaceIdOrAlias! + ), + }); + } + + const spaceSearchMatch = matchPath( + { path: SPACE_SEARCH_PATH, caseSensitive: true, end: true }, + pathname + ); + if (spaceSearchMatch) { + return generatePath(SPACE_SEARCH_PATH, { + spaceIdOrAlias: encodeURIComponent( + (await resolveCanonicalRouteParam(mx, spaceSearchMatch.params.spaceIdOrAlias)) ?? + spaceSearchMatch.params.spaceIdOrAlias! + ), + }); + } + + return pathname; +}; diff --git a/src/app/mindroom/settings/MindroomMessagePreloadLimitSetting.test.ts b/src/app/mindroom/settings/MindroomMessagePreloadLimitSetting.test.ts new file mode 100644 index 0000000000..4943c01622 --- /dev/null +++ b/src/app/mindroom/settings/MindroomMessagePreloadLimitSetting.test.ts @@ -0,0 +1,121 @@ +import React from 'react'; +import { act, create, ReactTestRenderer } from 'react-test-renderer'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const state = vi.hoisted(() => ({ + paginationLimit: 10000, + setPaginationLimit: vi.fn(), +})); + +vi.mock('folds', async () => { + const reactModule = await import('react'); + + return { + Input: (props: React.InputHTMLAttributes) => + reactModule.createElement('input', props), + Text: ({ children }: { children: React.ReactNode }) => + reactModule.createElement('span', null, children), + toRem: (value: number) => `${value}px`, + }; +}); + +vi.mock('../../components/sequence-card', () => ({ + SequenceCard: ({ children, className }: { children?: React.ReactNode; className?: string }) => + React.createElement('section', { className }, children), +})); + +vi.mock('../../components/setting-tile', () => ({ + SettingTile: ({ + title, + description, + after, + }: { + title: string; + description?: string; + after?: React.ReactNode; + }) => + React.createElement( + 'label', + { + 'data-title': title, + 'data-description': description, + }, + after + ), +})); + +vi.mock('../../state/hooks/settings', () => ({ + useSetting: () => [state.paginationLimit, state.setPaginationLimit], +})); + +vi.mock('../../state/settings', () => ({ + settingsAtom: {}, +})); + +vi.mock('./mindroomSettings', () => ({ + mindroomSettingsAtom: {}, +})); + +afterEach(() => { + state.paginationLimit = 10000; + state.setPaginationLimit.mockReset(); +}); + +describe('MindroomMessagePreloadLimitSetting', () => { + it('owns the MindRoom preload setting copy and styling seam', async () => { + const { MindroomMessagePreloadLimitSetting } = await import( + './MindroomMessagePreloadLimitSetting' + ); + + let renderer: ReactTestRenderer | undefined; + act(() => { + renderer = create( + React.createElement(MindroomMessagePreloadLimitSetting, { className: 'settings-card' }) + ); + }); + + expect(renderer!.root.findByProps({ className: 'settings-card' })).toBeDefined(); + expect(renderer!.root.findByProps({ 'data-title': 'Message Preload Limit' })).toBeDefined(); + }); + + it('sanitizes committed preload limits through the MindRoom policy module', async () => { + const { MindroomMessagePreloadLimitInput } = await import( + './MindroomMessagePreloadLimitSetting' + ); + let renderer: ReactTestRenderer | undefined; + act(() => { + renderer = create(React.createElement(MindroomMessagePreloadLimitInput)); + }); + const input = renderer!.root.findByType('input'); + + act(() => { + input.props.onChange({ target: { value: '12' } }); + }); + const updatedInput = renderer!.root.findByType('input'); + act(() => { + updatedInput.props.onBlur(); + }); + + expect(state.setPaginationLimit).toHaveBeenCalledWith(50); + }); + + it('commits valid limits from the Enter key', async () => { + const { MindroomMessagePreloadLimitInput } = await import( + './MindroomMessagePreloadLimitSetting' + ); + let renderer: ReactTestRenderer | undefined; + act(() => { + renderer = create(React.createElement(MindroomMessagePreloadLimitInput)); + }); + const input = renderer!.root.findByType('input'); + + act(() => { + input.props.onKeyDown({ + key: 'Enter', + target: { value: '2500' }, + }); + }); + + expect(state.setPaginationLimit).toHaveBeenCalledWith(2500); + }); +}); diff --git a/src/app/mindroom/settings/MindroomMessagePreloadLimitSetting.tsx b/src/app/mindroom/settings/MindroomMessagePreloadLimitSetting.tsx new file mode 100644 index 0000000000..26ac92ea5d --- /dev/null +++ b/src/app/mindroom/settings/MindroomMessagePreloadLimitSetting.tsx @@ -0,0 +1,81 @@ +import React, { ChangeEventHandler, KeyboardEventHandler, useEffect, useState } from 'react'; +import { Input, toRem } from 'folds'; +import { isKeyHotkey } from 'is-hotkey'; +import { SequenceCard } from '../../components/sequence-card'; +import { SettingTile } from '../../components/setting-tile'; +import { useSetting } from '../../state/hooks/settings'; +import { MIN_PAGINATION_LIMIT, sanitizePaginationLimit } from '../threads/preloadSettings'; +import { mindroomSettingsAtom } from './mindroomSettings'; + +type MindroomMessagePreloadLimitSettingProps = { + className?: string; +}; + +export function MindroomMessagePreloadLimitSetting({ + className, +}: MindroomMessagePreloadLimitSettingProps) { + return ( + + } + /> + + ); +} + +export function MindroomMessagePreloadLimitInput() { + const [paginationLimit, setPaginationLimit] = useSetting(mindroomSettingsAtom, 'paginationLimit'); + const [currentLimit, setCurrentLimit] = useState(`${paginationLimit}`); + + useEffect(() => { + setCurrentLimit(paginationLimit.toString()); + }, [paginationLimit]); + + const commitValue = (raw: string) => { + const parsed = parseInt(raw, 10); + if (Number.isNaN(parsed)) return; + const safe = sanitizePaginationLimit(parsed); + setPaginationLimit(safe); + setCurrentLimit(safe.toString()); + }; + + const handleChange: ChangeEventHandler = (evt) => { + setCurrentLimit(evt.target.value); + }; + + const handleKeyDown: KeyboardEventHandler = (evt) => { + if (isKeyHotkey('escape', evt)) { + evt.stopPropagation(); + setCurrentLimit(paginationLimit.toString()); + } + if ( + isKeyHotkey('enter', evt) && + 'value' in evt.target && + typeof evt.target.value === 'string' + ) { + commitValue(evt.target.value); + } + }; + + const handleBlur = () => { + commitValue(currentLimit); + }; + + return ( + + ); +} diff --git a/src/app/mindroom/settings/mindroomSettings.test.ts b/src/app/mindroom/settings/mindroomSettings.test.ts new file mode 100644 index 0000000000..87c2da954b --- /dev/null +++ b/src/app/mindroom/settings/mindroomSettings.test.ts @@ -0,0 +1,58 @@ +import { createStore } from 'jotai'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +type StorageRecord = Record; + +let storageValue: string | null = null; + +describe('mindroom settings', () => { + beforeEach(() => { + vi.resetModules(); + storageValue = null; + vi.stubGlobal('localStorage', { + getItem: vi.fn(() => storageValue), + setItem: vi.fn((_key: string, value: string) => { + storageValue = value; + }), + removeItem: vi.fn(), + clear: vi.fn(), + length: 0, + key: vi.fn(), + } as unknown as Storage); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('hydrates pagination limits from the existing generic settings localStorage key', async () => { + storageValue = JSON.stringify({ paginationLimit: 120 }); + const { mindroomSettingsAtom } = await import('./mindroomSettings'); + const store = createStore(); + + expect(store.get(mindroomSettingsAtom).paginationLimit).toBe(120); + }); + + it('sanitizes malformed pagination limits with the MindRoom preload defaults', async () => { + storageValue = JSON.stringify({ paginationLimit: 12 }); + const { mindroomSettingsAtom } = await import('./mindroomSettings'); + const store = createStore(); + + expect(store.get(mindroomSettingsAtom).paginationLimit).toBe(50); + }); + + it('persists pagination limits back to the existing settings object for compatibility', async () => { + storageValue = JSON.stringify({ pageZoom: 90, paginationLimit: 120 }); + const { mindroomSettingsAtom } = await import('./mindroomSettings'); + const store = createStore(); + + store.set(mindroomSettingsAtom, { + ...store.get(mindroomSettingsAtom), + paginationLimit: 75, + }); + + const saved = JSON.parse(storageValue ?? '{}') as StorageRecord; + expect(saved.pageZoom).toBe(90); + expect(saved.paginationLimit).toBe(75); + }); +}); diff --git a/src/app/mindroom/settings/mindroomSettings.ts b/src/app/mindroom/settings/mindroomSettings.ts new file mode 100644 index 0000000000..bfb78d5f5a --- /dev/null +++ b/src/app/mindroom/settings/mindroomSettings.ts @@ -0,0 +1,31 @@ +import { atom, type WritableAtom } from 'jotai'; +import { type Settings, settingsAtom } from '../../state/settings'; +import { DEFAULT_PAGINATION_LIMIT, sanitizePaginationLimit } from '../threads/preloadSettings'; + +export type MindroomSettings = Settings & { + paginationLimit: number; +}; + +type SettingsWithMindroomValues = Settings & { + paginationLimit?: unknown; +}; + +export const withMindroomSettings = (settings: Settings): MindroomSettings => ({ + ...settings, + paginationLimit: sanitizePaginationLimit( + (settings as SettingsWithMindroomValues).paginationLimit ?? DEFAULT_PAGINATION_LIMIT + ), +}); + +const genericSettingsAtom = settingsAtom as unknown as WritableAtom< + MindroomSettings, + [MindroomSettings], + undefined +>; + +export const mindroomSettingsAtom = atom( + (get) => withMindroomSettings(get(settingsAtom)), + (_get, set, update) => { + set(genericSettingsAtom, withMindroomSettings(update)); + } +); diff --git a/src/app/mindroom/settings/settingsExtensions.tsx b/src/app/mindroom/settings/settingsExtensions.tsx new file mode 100644 index 0000000000..7410a2341f --- /dev/null +++ b/src/app/mindroom/settings/settingsExtensions.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { type SettingsPage } from '../../features/settings/settingsPages'; +import { renderLocalMindroomSettingsPage } from '../local-mindroom/settingsRenderer'; +import { MindroomMessagePreloadLimitSetting } from './MindroomMessagePreloadLimitSetting'; + +type MindroomGeneralMessageSettingsProps = { + className?: string; +}; + +export const renderMindroomSettingsPage = ( + activePage: SettingsPage | undefined, + enabled: boolean, + requestClose: () => void +): React.ReactNode => renderLocalMindroomSettingsPage(activePage, enabled, requestClose); + +export function MindroomGeneralMessageSettings({ className }: MindroomGeneralMessageSettingsProps) { + return ; +} diff --git a/src/app/mindroom/settings/settingsMenuExtensions.ts b/src/app/mindroom/settings/settingsMenuExtensions.ts new file mode 100644 index 0000000000..3e5ab6eaff --- /dev/null +++ b/src/app/mindroom/settings/settingsMenuExtensions.ts @@ -0,0 +1,25 @@ +import { type IconSrc } from 'folds'; +import { ScreenSize } from '../../hooks/useScreenSize'; +import { type SettingsPage, SettingsPages } from '../../features/settings/settingsPages'; +import { getLocalMindroomSettingsMenuItems } from '../local-mindroom/settingsMenu'; +import { resolveLocalMindroomInitialSettingsPage } from '../local-mindroom/settingsPage'; + +type MindroomSettingsMenuItem = { + page: SettingsPage; + name: string; + icon: IconSrc; +}; + +export const getMindroomSettingsMenuItems = (enabled: boolean): MindroomSettingsMenuItem[] => + getLocalMindroomSettingsMenuItems(enabled); + +export const resolveMindroomSettingsInitialPage = ( + initialPage: SettingsPage | undefined, + screenSize: ScreenSize, + enabled: boolean +): SettingsPage | undefined => { + const resolvedInitialPage = resolveLocalMindroomInitialSettingsPage(initialPage, enabled); + if (resolvedInitialPage !== undefined) return resolvedInitialPage; + + return screenSize === ScreenSize.Mobile ? undefined : SettingsPages.GeneralPage; +}; diff --git a/src/app/mindroom/settings/voiceMessageSettings.test.ts b/src/app/mindroom/settings/voiceMessageSettings.test.ts new file mode 100644 index 0000000000..e10d314aad --- /dev/null +++ b/src/app/mindroom/settings/voiceMessageSettings.test.ts @@ -0,0 +1,188 @@ +import { createStore } from 'jotai'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +type StorageListener = (event: StorageEvent) => void; + +const storageState = new Map(); +const storageListeners = new Set(); + +const emitStorageEvent = (key: string) => { + storageListeners.forEach((listener) => listener({ key } as StorageEvent)); +}; + +describe('voiceMessageSettings', () => { + beforeEach(() => { + vi.resetModules(); + storageState.clear(); + storageListeners.clear(); + + vi.stubGlobal('localStorage', { + get length() { + return storageState.size; + }, + clear: vi.fn(() => { + storageState.clear(); + }), + getItem: vi.fn((key: string) => storageState.get(key) ?? null), + key: vi.fn((index: number) => Array.from(storageState.keys())[index] ?? null), + removeItem: vi.fn((key: string) => { + storageState.delete(key); + }), + setItem: vi.fn((key: string, value: string) => { + storageState.set(key, value); + }), + } as unknown as Storage); + + vi.stubGlobal('window', { + addEventListener: vi.fn((type: string, listener: StorageListener) => { + if (type === 'storage') storageListeners.add(listener); + }), + removeEventListener: vi.fn((type: string, listener: StorageListener) => { + if (type === 'storage') storageListeners.delete(listener); + }), + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + storageState.clear(); + storageListeners.clear(); + }); + + it('defaults storage hydration to 1', async () => { + const { voiceMessagePlaybackRateAtom } = await import('./voiceMessageSettings'); + const store = createStore(); + const unmount = store.sub(voiceMessagePlaybackRateAtom, () => undefined); + + expect(store.get(voiceMessagePlaybackRateAtom)).toBe(1); + + unmount(); + }); + + it('hydrates valid persisted rates', async () => { + storageState.set('voiceMessagePlaybackRate', '1.5'); + + const { voiceMessagePlaybackRateAtom } = await import('./voiceMessageSettings'); + const store = createStore(); + const unmount = store.sub(voiceMessagePlaybackRateAtom, () => undefined); + + expect(store.get(voiceMessagePlaybackRateAtom)).toBe(1.5); + + unmount(); + }); + + it.each(['{not-json}', '"1.5"', '0.5', '3', 'null', 'true'])( + 'falls back to 1 for invalid persisted value %s', + async (storedValue) => { + storageState.set('voiceMessagePlaybackRate', storedValue); + + const { voiceMessagePlaybackRateAtom } = await import('./voiceMessageSettings'); + const store = createStore(); + const unmount = store.sub(voiceMessagePlaybackRateAtom, () => undefined); + + expect(store.get(voiceMessagePlaybackRateAtom)).toBe(1); + + unmount(); + } + ); + + it('loads sanitized storage updates while mounted', async () => { + const { voiceMessagePlaybackRateAtom } = await import('./voiceMessageSettings'); + const store = createStore(); + const unmount = store.sub(voiceMessagePlaybackRateAtom, () => undefined); + + storageState.set('voiceMessagePlaybackRate', '2'); + emitStorageEvent('voiceMessagePlaybackRate'); + expect(store.get(voiceMessagePlaybackRateAtom)).toBe(2); + + storageState.set('voiceMessagePlaybackRate', '4'); + emitStorageEvent('voiceMessagePlaybackRate'); + expect(store.get(voiceMessagePlaybackRateAtom)).toBe(1); + + unmount(); + }); + + it('cycles rates in order', async () => { + const { cycleVoicePlaybackRate, formatVoicePlaybackRate, VOICE_PLAYBACK_RATES } = await import( + './voiceMessageSettings' + ); + + expect(VOICE_PLAYBACK_RATES.map(formatVoicePlaybackRate)).toEqual(['1×', '1.5×', '2×']); + expect(cycleVoicePlaybackRate(1)).toBe(1.5); + expect(cycleVoicePlaybackRate(1.5)).toBe(2); + expect(cycleVoicePlaybackRate(2)).toBe(1); + }); + + it('sanitizes values before persisting', async () => { + const { voiceMessagePlaybackRateAtom } = await import('./voiceMessageSettings'); + const store = createStore(); + const unmount = store.sub(voiceMessagePlaybackRateAtom, () => undefined); + + store.set(voiceMessagePlaybackRateAtom, 2); + expect(storageState.get('voiceMessagePlaybackRate')).toBe('2'); + + store.set(voiceMessagePlaybackRateAtom, 4); + expect(storageState.get('voiceMessagePlaybackRate')).toBe('1'); + expect(store.get(voiceMessagePlaybackRateAtom)).toBe(1); + + unmount(); + }); + + it('defaults volume storage hydration to 1', async () => { + const { voiceMessageVolumeAtom } = await import('./voiceMessageSettings'); + const store = createStore(); + const unmount = store.sub(voiceMessageVolumeAtom, () => undefined); + + expect(store.get(voiceMessageVolumeAtom)).toBe(1); + + unmount(); + }); + + it('hydrates valid persisted volume', async () => { + storageState.set('voiceMessageVolume', '0.35'); + + const { voiceMessageVolumeAtom } = await import('./voiceMessageSettings'); + const store = createStore(); + const unmount = store.sub(voiceMessageVolumeAtom, () => undefined); + + expect(store.get(voiceMessageVolumeAtom)).toBe(0.35); + + unmount(); + }); + + it.each([ + ['-1', 0], + ['2', 1], + ['NaN', 1], + ['"0.5"', 1], + ])('sanitizes persisted volume value %s to %s', async (storedValue, expected) => { + storageState.set('voiceMessageVolume', storedValue); + + const { voiceMessageVolumeAtom } = await import('./voiceMessageSettings'); + const store = createStore(); + const unmount = store.sub(voiceMessageVolumeAtom, () => undefined); + + expect(store.get(voiceMessageVolumeAtom)).toBe(expected); + + unmount(); + }); + + it('persists sanitized volume updates', async () => { + const { voiceMessageVolumeAtom } = await import('./voiceMessageSettings'); + const store = createStore(); + const unmount = store.sub(voiceMessageVolumeAtom, () => undefined); + + store.set(voiceMessageVolumeAtom, 0.25); + expect(storageState.get('voiceMessageVolume')).toBe('0.25'); + + store.set(voiceMessageVolumeAtom, -1); + expect(storageState.get('voiceMessageVolume')).toBe('0'); + expect(store.get(voiceMessageVolumeAtom)).toBe(0); + + store.set(voiceMessageVolumeAtom, Number.NaN); + expect(storageState.get('voiceMessageVolume')).toBe('1'); + expect(store.get(voiceMessageVolumeAtom)).toBe(1); + + unmount(); + }); +}); diff --git a/src/app/mindroom/settings/voiceMessageSettings.ts b/src/app/mindroom/settings/voiceMessageSettings.ts new file mode 100644 index 0000000000..e38d09cce7 --- /dev/null +++ b/src/app/mindroom/settings/voiceMessageSettings.ts @@ -0,0 +1,113 @@ +import { atom } from 'jotai'; +import { atomWithLocalStorage } from '../../state/utils/atomWithLocalStorage'; + +export const VOICE_PLAYBACK_RATES = [1, 1.5, 2] as const; +export type VoicePlaybackRate = typeof VOICE_PLAYBACK_RATES[number]; + +export const DEFAULT_VOICE_PLAYBACK_RATE: VoicePlaybackRate = 1; +export const VOICE_PLAYBACK_RATE_STORAGE_KEY = 'voiceMessagePlaybackRate'; +export const DEFAULT_VOICE_MESSAGE_VOLUME = 1; +export const VOICE_MESSAGE_VOLUME_STORAGE_KEY = 'voiceMessageVolume'; + +type PitchPreservingAudioElement = HTMLAudioElement & { + mozPreservesPitch?: boolean; + webkitPreservesPitch?: boolean; +}; + +export const isVoicePlaybackRate = (value: unknown): value is VoicePlaybackRate => + VOICE_PLAYBACK_RATES.some((rate) => rate === value); + +export const sanitizeVoicePlaybackRate = (value: unknown): VoicePlaybackRate => + isVoicePlaybackRate(value) ? value : DEFAULT_VOICE_PLAYBACK_RATE; + +export const sanitizeVoiceMessageVolume = (value: unknown): number => { + if (typeof value !== 'number' || !Number.isFinite(value)) return DEFAULT_VOICE_MESSAGE_VOLUME; + return Math.min(1, Math.max(0, value)); +}; + +export const formatVoicePlaybackRate = (rate: VoicePlaybackRate): string => `${rate}\u00d7`; + +export const cycleVoicePlaybackRate = (rate: VoicePlaybackRate): VoicePlaybackRate => { + const currentIndex = VOICE_PLAYBACK_RATES.indexOf(rate); + return VOICE_PLAYBACK_RATES[(currentIndex + 1) % VOICE_PLAYBACK_RATES.length]; +}; + +const getStoredVoicePlaybackRate = (key: string): VoicePlaybackRate => { + if (typeof globalThis.localStorage?.getItem !== 'function') return DEFAULT_VOICE_PLAYBACK_RATE; + + const item = globalThis.localStorage.getItem(key); + if (item === null) return DEFAULT_VOICE_PLAYBACK_RATE; + + try { + return sanitizeVoicePlaybackRate(JSON.parse(item) as unknown); + } catch { + return DEFAULT_VOICE_PLAYBACK_RATE; + } +}; + +const setStoredVoicePlaybackRate = (key: string, value: unknown) => { + if (typeof globalThis.localStorage?.setItem !== 'function') return; + + globalThis.localStorage.setItem(key, JSON.stringify(sanitizeVoicePlaybackRate(value))); +}; + +const voiceMessagePlaybackRateStorageAtom = atomWithLocalStorage( + VOICE_PLAYBACK_RATE_STORAGE_KEY, + getStoredVoicePlaybackRate, + setStoredVoicePlaybackRate +); + +const getStoredVoiceMessageVolume = (key: string): number => { + if (typeof globalThis.localStorage?.getItem !== 'function') return DEFAULT_VOICE_MESSAGE_VOLUME; + + const item = globalThis.localStorage.getItem(key); + if (item === null) return DEFAULT_VOICE_MESSAGE_VOLUME; + + try { + return sanitizeVoiceMessageVolume(JSON.parse(item) as unknown); + } catch { + return DEFAULT_VOICE_MESSAGE_VOLUME; + } +}; + +const setStoredVoiceMessageVolume = (key: string, value: unknown) => { + if (typeof globalThis.localStorage?.setItem !== 'function') return; + + globalThis.localStorage.setItem(key, JSON.stringify(sanitizeVoiceMessageVolume(value))); +}; + +const voiceMessageVolumeStorageAtom = atomWithLocalStorage( + VOICE_MESSAGE_VOLUME_STORAGE_KEY, + getStoredVoiceMessageVolume, + setStoredVoiceMessageVolume +); + +export const voiceMessagePlaybackRateAtom = atom( + (get) => sanitizeVoicePlaybackRate(get(voiceMessagePlaybackRateStorageAtom)), + (_get, set, value) => { + set(voiceMessagePlaybackRateStorageAtom, sanitizeVoicePlaybackRate(value)); + } +); + +export const voiceMessageVolumeAtom = atom( + (get) => sanitizeVoiceMessageVolume(get(voiceMessageVolumeStorageAtom)), + (_get, set, value) => { + set(voiceMessageVolumeStorageAtom, sanitizeVoiceMessageVolume(value)); + } +); + +export const applyVoicePlaybackRate = (audioEl: HTMLAudioElement, rate: VoicePlaybackRate) => { + const pitchPreservingAudio = audioEl as PitchPreservingAudioElement; + + audioEl.playbackRate = rate; + audioEl.preservesPitch = true; + pitchPreservingAudio.webkitPreservesPitch = true; + pitchPreservingAudio.mozPreservesPitch = true; +}; + +export const applyVoiceMessageVolume = (audioEl: HTMLAudioElement, volume: number) => { + const v = sanitizeVoiceMessageVolume(volume); + + audioEl.volume = v; + audioEl.muted = v === 0; +}; diff --git a/src/app/mindroom/sidebar/MindroomTab.css.ts b/src/app/mindroom/sidebar/MindroomTab.css.ts new file mode 100644 index 0000000000..ec879f1ac2 --- /dev/null +++ b/src/app/mindroom/sidebar/MindroomTab.css.ts @@ -0,0 +1,14 @@ +import { style } from '@vanilla-extract/css'; +import { color, config, toRem } from 'folds'; + +export const LinkIndicator = style({ + width: toRem(16), + height: toRem(16), + borderRadius: '9999px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: color.Success.Container, + color: color.Success.OnContainer, + boxShadow: `0 0 0 ${config.borderWidth.B300} ${color.Success.ContainerLine}`, +}); diff --git a/src/app/mindroom/sidebar/MindroomTab.test.ts b/src/app/mindroom/sidebar/MindroomTab.test.ts new file mode 100644 index 0000000000..3d83c3825c --- /dev/null +++ b/src/app/mindroom/sidebar/MindroomTab.test.ts @@ -0,0 +1,46 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { create } from 'react-test-renderer'; +import { Icons } from 'folds'; +import { MindroomTab } from './MindroomTab'; + +vi.mock('./MindroomTab.css', () => ({ + LinkIndicator: 'link-indicator', +})); + +vi.mock('../../components/sidebar', () => ({ + SidebarItem: ({ children }: { children: React.ReactNode }) => React.createElement('div', null, children), + SidebarItemBadge: ({ children }: { children: React.ReactNode }) => + React.createElement('div', { 'data-testid': 'mindroom-link-badge' }, children), + SidebarItemTooltip: ({ + children, + }: { + children: (triggerRef: () => void) => React.ReactNode; + }) => React.createElement(React.Fragment, null, children(() => undefined)), + SidebarAvatar: React.forwardRef< + HTMLButtonElement, + React.ButtonHTMLAttributes & { as?: 'button' | 'div' } + >(({ as: As = 'div', children, ...props }, ref) => + React.createElement(As, { ...props, ref }, children) + ), +})); + +vi.mock('../../components/Modal500', () => ({ + Modal500: ({ children }: { children: React.ReactNode }) => React.createElement('div', null, children), +})); + +vi.mock('../../features/settings', () => ({ + Settings: () => React.createElement('div'), +})); + +describe('MindroomTab', () => { + it('renders a link badge on the local MindRoom shortcut', () => { + const renderer = create(React.createElement(MindroomTab)); + + const linkIcons = renderer.root.findAll((node) => node.props?.src === Icons.Link); + expect(linkIcons.length).toBeGreaterThan(0); + + const logos = renderer.root.findAll((node) => node.props?.alt === 'MindRoom'); + expect(logos).toHaveLength(1); + }); +}); diff --git a/src/app/mindroom/sidebar/MindroomTab.tsx b/src/app/mindroom/sidebar/MindroomTab.tsx new file mode 100644 index 0000000000..544869cc96 --- /dev/null +++ b/src/app/mindroom/sidebar/MindroomTab.tsx @@ -0,0 +1,50 @@ +import React, { useState } from 'react'; +import { Icon, Icons } from 'folds'; +import { + SidebarAvatar, + SidebarItem, + SidebarItemBadge, + SidebarItemTooltip, +} from '../../components/sidebar'; +import MindRoomLogo from '../../../../public/res/branding/mindroom-logo.png'; +import { Modal500 } from '../../components/Modal500'; +import { Settings } from '../../features/settings'; +import { LOCAL_MINDROOM_SETTINGS_PAGE } from '../local-mindroom/settingsPage'; +import * as css from './MindroomTab.css'; + +export function MindroomTab() { + const [settings, setSettings] = useState(false); + + return ( + <> + + + {(triggerRef) => ( + setSettings(true)}> + MindRoom + + )} + + + + + + + + {settings && ( + setSettings(false)}> + setSettings(false)} + /> + + )} + + ); +} diff --git a/src/app/mindroom/threads/CollapsibleMessage.css.ts b/src/app/mindroom/threads/CollapsibleMessage.css.ts new file mode 100644 index 0000000000..e482e3b0f2 --- /dev/null +++ b/src/app/mindroom/threads/CollapsibleMessage.css.ts @@ -0,0 +1,79 @@ +import { style } from '@vanilla-extract/css'; +import { recipe } from '@vanilla-extract/recipes'; +import { color, config, toRem } from 'folds'; + +export const CollapsibleContent = recipe({ + base: { + position: 'relative', + }, +}); + +export const CollapsibleGradientOverlay = style({ + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + height: '2.5em', + background: `linear-gradient(transparent, var(--collapsible-gradient-end, ${color.Surface.Container}))`, + cursor: 'pointer', + display: 'flex', + alignItems: 'flex-end', + justifyContent: 'center', + paddingBottom: config.space.S100, + selectors: { + '&:focus-visible': { + outline: `2px solid ${color.Primary.Main}`, + outlineOffset: '1px', + }, + }, +}); + +const pillBase = { + display: 'inline-flex', + alignItems: 'center', + gap: config.space.S100, + padding: `${config.space.S100} ${config.space.S300}`, + borderRadius: config.radii.Pill, + backgroundColor: color.SurfaceVariant.Container, + border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`, + color: color.SurfaceVariant.OnContainer, + fontSize: toRem(12), + fontWeight: config.fontWeight.W500, + lineHeight: 1, + letterSpacing: '0.04em', + textTransform: 'uppercase' as const, + boxShadow: `0 1px 3px rgba(0, 0, 0, 0.18)`, + whiteSpace: 'nowrap' as const, +}; + +export const CollapsibleShowMore = style({ + ...pillBase, + userSelect: 'none', + pointerEvents: 'none', +}); + +export const CollapsibleStickyFooter = style({ + position: 'sticky', + bottom: config.space.S200, + zIndex: 1, + display: 'flex', + justifyContent: 'center', + width: '100%', + marginTop: config.space.S200, + pointerEvents: 'none', +}); + +export const CollapsiblePill = style({ + ...pillBase, + pointerEvents: 'auto', + cursor: 'pointer', + selectors: { + '&:hover': { + backgroundColor: color.SurfaceVariant.ContainerHover, + }, + '&:focus-visible': { + outline: `2px solid ${color.Primary.Main}`, + outlineOffset: '2px', + }, + }, +}); diff --git a/src/app/mindroom/threads/CollapsibleMessage.test.ts b/src/app/mindroom/threads/CollapsibleMessage.test.ts new file mode 100644 index 0000000000..43616ed280 --- /dev/null +++ b/src/app/mindroom/threads/CollapsibleMessage.test.ts @@ -0,0 +1,748 @@ +import React from 'react'; +import { act, create, ReactTestRenderer } from 'react-test-renderer'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('./CollapsibleMessage.css', () => ({ + CollapsibleContent: () => 'collapsible-content', + CollapsibleGradientOverlay: 'collapsible-gradient-overlay', + CollapsibleShowMore: 'collapsible-show-more', + CollapsibleStickyFooter: 'collapsible-sticky-footer', + CollapsiblePill: 'collapsible-pill', +})); + +import { collapseAllMessages, expandAllMessages, CollapsibleMessage } from './CollapsibleMessage'; + +type MockContentElement = Pick; +type MockGradientElement = { focus: ReturnType }; + +let resizeObserverConstructed: ReturnType; +let intersectionObserverConstructed: ReturnType; +let lastIntersectionCallback: IntersectionObserverCallback | null; + +class MockResizeObserver { + public observe = vi.fn(); + + public disconnect = vi.fn(); + + public constructor(_callback: ResizeObserverCallback) { + resizeObserverConstructed(); + } +} + +class MockIntersectionObserver { + public observe = vi.fn(); + + public unobserve = vi.fn(); + + public disconnect = vi.fn(); + + public constructor(callback: IntersectionObserverCallback) { + intersectionObserverConstructed(); + lastIntersectionCallback = callback; + } +} + +const getContentContainer = (renderer: ReactTestRenderer) => + renderer.root.find( + (node) => + node.type === 'div' && + typeof node.props.className === 'string' && + node.props.className.startsWith('collapsible-content') + ); + +const getExpandZone = (renderer: ReactTestRenderer) => + renderer.root.find((node) => node.type === 'div' && node.props.role === 'button'); + +const findExpandZones = (renderer: ReactTestRenderer) => + renderer.root.findAll((node) => node.type === 'div' && node.props.role === 'button'); + +const getCloseButton = (renderer: ReactTestRenderer) => + renderer.root.find((node) => node.type === 'button' && node.props['aria-label'] === 'Show less'); + +const findCloseButtons = (renderer: ReactTestRenderer) => + renderer.root.findAll( + (node) => node.type === 'button' && node.props['aria-label'] === 'Show less' + ); + +const findGradientOverlays = (renderer: ReactTestRenderer) => + renderer.root.findAll( + (node) => + node.type === 'div' && + typeof node.props.className === 'string' && + node.props.className === 'collapsible-gradient-overlay' + ); + +const renderCollapsibleMessage = ( + props: Omit, 'children'>, + contentElement: MockContentElement = { + clientHeight: 72, + scrollHeight: 160, + }, + gradientElement: MockGradientElement = { focus: vi.fn() }, + children: React.ReactNode = React.createElement('span', undefined, 'message') +) => { + let renderer!: ReactTestRenderer; + + act(() => { + renderer = create(React.createElement(CollapsibleMessage as never, props as never, children), { + createNodeMock: (element) => { + if ( + element.type === 'div' && + typeof element.props.className === 'string' && + element.props.className.startsWith('collapsible-content') + ) { + return contentElement; + } + + if ( + element.type === 'div' && + element.props.role === 'button' && + element.props['aria-label'] === 'Show more' + ) { + return gradientElement; + } + + return null; + }, + }); + }); + + return renderer; +}; + +const createMeasuredContentElement = () => { + let scrollHeightReads = 0; + const contentElement = { + clientHeight: 72, + get scrollHeight() { + scrollHeightReads += 1; + return 160; + }, + }; + + return { + contentElement, + getScrollHeightReads: () => scrollHeightReads, + }; +}; + +beforeEach(() => { + resizeObserverConstructed = vi.fn(); + intersectionObserverConstructed = vi.fn(); + lastIntersectionCallback = null; + vi.stubGlobal('ResizeObserver', MockResizeObserver); + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver); + vi.stubGlobal('getComputedStyle', () => ({ fontSize: '16px' })); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('CollapsibleMessage', () => { + it('passes collapsed and expanded state to render-prop children', () => { + const states: boolean[] = []; + const renderer = renderCollapsibleMessage( + { collapseMode: 'default' }, + { clientHeight: 72, scrollHeight: 160 }, + { focus: vi.fn() }, + ({ expanded }: { expanded: boolean }) => { + states.push(expanded); + return React.createElement('span', undefined, expanded ? 'expanded' : 'collapsed'); + } + ); + + expect(states[states.length - 1]).toBe(false); + + act(() => { + getExpandZone(renderer).props.onClick(); + }); + + expect(states[states.length - 1]).toBe(true); + expect(JSON.stringify(renderer.toJSON())).toContain('expanded'); + + act(() => { + renderer.unmount(); + }); + }); + + it('can force the overflow affordance for lazily hydrated collapsed content', () => { + const renderer = renderCollapsibleMessage( + { collapseMode: 'default', forceOverflowing: true }, + { clientHeight: 72, scrollHeight: 24 } + ); + + expect(findExpandZones(renderer)).toHaveLength(1); + expect(resizeObserverConstructed).not.toHaveBeenCalled(); + expect(intersectionObserverConstructed).not.toHaveBeenCalled(); + + act(() => { + renderer.unmount(); + }); + }); + + it('starts collapsed in default mode with gradient expand zone when overflowing', () => { + const renderer = renderCollapsibleMessage({ collapseMode: 'default' }); + const content = getContentContainer(renderer); + const expandZone = getExpandZone(renderer); + + expect(content.props.style.maxHeight).toBe('4.5em'); + expect(content.props.style.overflow).toBe('hidden'); + expect(content.props['aria-expanded']).toBe(false); + expect(content.props.role).toBeUndefined(); + expect(expandZone.props['aria-label']).toBe('Show more'); + expect(resizeObserverConstructed).toHaveBeenCalledTimes(1); + expect(findCloseButtons(renderer)).toHaveLength(0); + + act(() => { + renderer.unmount(); + }); + }); + + it('stays fully expanded in always-expanded mode and ignores collapse controls', () => { + const renderer = renderCollapsibleMessage({ collapseMode: 'always-expanded' }); + const content = getContentContainer(renderer); + + expect(content.props.style.maxHeight).toBeUndefined(); + expect(content.props.style.overflow).toBeUndefined(); + expect(content.props.role).toBeUndefined(); + expect(content.props['aria-expanded']).toBeUndefined(); + expect(findCloseButtons(renderer)).toHaveLength(0); + expect(findExpandZones(renderer)).toHaveLength(0); + expect(resizeObserverConstructed).not.toHaveBeenCalled(); + + act(() => { + collapseAllMessages(); + }); + + expect(getContentContainer(renderer).props.style.maxHeight).toBeUndefined(); + expect(findCloseButtons(renderer)).toHaveLength(0); + + act(() => { + renderer.unmount(); + }); + }); + + it('starts expanded once in initially-expanded mode, consumes the callback once, and responds to global collapse', () => { + const onInitialExpandConsumed = vi.fn(); + const renderer = renderCollapsibleMessage({ + collapseMode: 'initially-expanded', + onInitialExpandConsumed, + }); + + expect(onInitialExpandConsumed).toHaveBeenCalledTimes(1); + expect(getContentContainer(renderer).props.style.maxHeight).toBeUndefined(); + expect(getContentContainer(renderer).props['aria-expanded']).toBe(true); + expect(getCloseButton(renderer).props['aria-label']).toBe('Show less'); + + act(() => { + renderer.update( + React.createElement( + CollapsibleMessage as never, + { + collapseMode: 'initially-expanded', + onInitialExpandConsumed, + } as never, + React.createElement('span', undefined, 'message') + ) + ); + }); + + expect(onInitialExpandConsumed).toHaveBeenCalledTimes(1); + + act(() => { + collapseAllMessages(); + }); + + const content = getContentContainer(renderer); + expect(content.props.style.maxHeight).toBe('4.5em'); + expect(content.props.style.overflow).toBe('hidden'); + expect(content.props['aria-expanded']).toBe(false); + expect(getExpandZone(renderer).props['aria-label']).toBe('Show more'); + + act(() => { + renderer.unmount(); + }); + }); + + it('expands when collapseMode switches to initially-expanded after mount', () => { + const onInitialExpandConsumed = vi.fn(); + const renderer = renderCollapsibleMessage({ + collapseMode: 'default', + onInitialExpandConsumed, + }); + + expect(getContentContainer(renderer).props.style.maxHeight).toBe('4.5em'); + + act(() => { + renderer.update( + React.createElement( + CollapsibleMessage as never, + { + collapseMode: 'initially-expanded', + onInitialExpandConsumed, + } as never, + React.createElement('span', undefined, 'message') + ) + ); + }); + + expect(onInitialExpandConsumed).toHaveBeenCalledTimes(1); + expect(getContentContainer(renderer).props.style.maxHeight).toBeUndefined(); + expect(getCloseButton(renderer).props['aria-label']).toBe('Show less'); + + act(() => { + renderer.unmount(); + }); + }); + + it('expands when gradient overlay is clicked', () => { + const renderer = renderCollapsibleMessage({ collapseMode: 'default' }); + const expandZone = getExpandZone(renderer); + + act(() => { + expandZone.props.onClick(); + }); + + const content = getContentContainer(renderer); + expect(content.props.style.maxHeight).toBeUndefined(); + expect(content.props['aria-expanded']).toBe(true); + expect(getCloseButton(renderer).props['aria-label']).toBe('Show less'); + + act(() => { + renderer.unmount(); + }); + }); + + it('collapses when close button is clicked', () => { + const renderer = renderCollapsibleMessage({ collapseMode: 'default' }); + + act(() => { + getExpandZone(renderer).props.onClick(); + }); + + const closeButton = getCloseButton(renderer); + act(() => { + closeButton.props.onClick({ + stopPropagation: vi.fn(), + }); + }); + + const content = getContentContainer(renderer); + expect(content.props.style.maxHeight).toBe('4.5em'); + expect(content.props.style.overflow).toBe('hidden'); + expect(content.props['aria-expanded']).toBe(false); + + act(() => { + renderer.unmount(); + }); + }); + + it('content wrapper has no click handler when collapsed (nested interactive elements safe)', () => { + const renderer = renderCollapsibleMessage({ collapseMode: 'default' }); + const content = getContentContainer(renderer); + + expect(content.props.onClick).toBeUndefined(); + expect(content.props.onKeyDown).toBeUndefined(); + expect(content.props.role).toBeUndefined(); + expect(content.props.tabIndex).toBeUndefined(); + + act(() => { + renderer.unmount(); + }); + }); + + it('expands on Enter key press on gradient', () => { + const renderer = renderCollapsibleMessage({ collapseMode: 'default' }); + const expandZone = getExpandZone(renderer); + + act(() => { + expandZone.props.onKeyDown({ + key: 'Enter', + preventDefault: vi.fn(), + }); + }); + + expect(getContentContainer(renderer).props.style.maxHeight).toBeUndefined(); + expect(getContentContainer(renderer).props['aria-expanded']).toBe(true); + + act(() => { + renderer.unmount(); + }); + }); + + it('expands on Space key press on gradient', () => { + const renderer = renderCollapsibleMessage({ collapseMode: 'default' }); + const expandZone = getExpandZone(renderer); + + act(() => { + expandZone.props.onKeyDown({ + key: ' ', + preventDefault: vi.fn(), + }); + }); + + expect(getContentContainer(renderer).props.style.maxHeight).toBeUndefined(); + + act(() => { + renderer.unmount(); + }); + }); + + it('collapses on Enter key press on close button', () => { + const renderer = renderCollapsibleMessage({ collapseMode: 'default' }); + + act(() => { + getExpandZone(renderer).props.onClick(); + }); + + const closeButton = getCloseButton(renderer); + act(() => { + closeButton.props.onKeyDown({ + key: 'Enter', + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + }); + }); + + expect(getContentContainer(renderer).props.style.maxHeight).toBe('4.5em'); + + act(() => { + renderer.unmount(); + }); + }); + + it('collapses on Space key press on close button', () => { + const renderer = renderCollapsibleMessage({ collapseMode: 'default' }); + + act(() => { + getExpandZone(renderer).props.onClick(); + }); + + const closeButton = getCloseButton(renderer); + act(() => { + closeButton.props.onKeyDown({ + key: ' ', + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + }); + }); + + expect(getContentContainer(renderer).props.style.maxHeight).toBe('4.5em'); + + act(() => { + renderer.unmount(); + }); + }); + + it('non-overflowing content shows no expand controls, gradient, or close button', () => { + const renderer = renderCollapsibleMessage( + { collapseMode: 'default' }, + { clientHeight: 40, scrollHeight: 40 } + ); + + const content = getContentContainer(renderer); + expect(content.props.style.maxHeight).toBe('4.5em'); + expect(content.props['aria-expanded']).toBe(false); + expect(findExpandZones(renderer)).toHaveLength(0); + expect(findGradientOverlays(renderer)).toHaveLength(0); + expect(findCloseButtons(renderer)).toHaveLength(0); + + act(() => { + renderer.unmount(); + }); + }); + + it('expandAllMessages expands collapsed content', () => { + const renderer = renderCollapsibleMessage({ collapseMode: 'default' }); + + expect(getContentContainer(renderer).props.style.maxHeight).toBe('4.5em'); + expect(getContentContainer(renderer).props['aria-expanded']).toBe(false); + + act(() => { + expandAllMessages(); + }); + + const content = getContentContainer(renderer); + expect(content.props.style.maxHeight).toBeUndefined(); + expect(content.props['aria-expanded']).toBe(true); + expect(getCloseButton(renderer).props['aria-label']).toBe('Show less'); + + act(() => { + renderer.unmount(); + }); + }); + + it('moves focus to gradient after collapse', () => { + const gradientMock = { focus: vi.fn() }; + const renderer = renderCollapsibleMessage( + { collapseMode: 'default' }, + { clientHeight: 72, scrollHeight: 160 }, + gradientMock + ); + + // Expand via gradient click + act(() => { + getExpandZone(renderer).props.onClick(); + }); + + // Collapse via close button + act(() => { + getCloseButton(renderer).props.onClick({ stopPropagation: vi.fn() }); + }); + + expect(gradientMock.focus).toHaveBeenCalled(); + + act(() => { + renderer.unmount(); + }); + }); + + it('preserves overflowing=true when scrollHeight is 0 (off-screen element)', () => { + const renderer = renderCollapsibleMessage( + { collapseMode: 'default' }, + { clientHeight: 0, scrollHeight: 0 } + ); + + // overflowing defaults to true; scrollHeight=0 guard prevents checkOverflow + // from setting it to false, so gradient is shown + const content = getContentContainer(renderer); + expect(content.props.style.maxHeight).toBe('4.5em'); + expect(content.props.style.overflow).toBe('hidden'); + expect(findExpandZones(renderer)).toHaveLength(1); + expect(getExpandZone(renderer).props['aria-label']).toBe('Show more'); + + act(() => { + renderer.unmount(); + }); + }); + + it('IntersectionObserver re-checks overflow when element enters viewport', () => { + // Start with scrollHeight=0 (simulates off-screen element with unknown layout) + const contentElement = { clientHeight: 0, scrollHeight: 0 }; + const renderer = renderCollapsibleMessage({ collapseMode: 'default' }, contentElement); + + // Gradient is shown (overflowing defaults to true, scrollHeight=0 guard preserves it) + expect(findExpandZones(renderer)).toHaveLength(1); + expect(intersectionObserverConstructed).toHaveBeenCalled(); + + // Simulate element entering viewport with real dimensions + contentElement.scrollHeight = 160; + contentElement.clientHeight = 72; + act(() => { + lastIntersectionCallback!( + [{ isIntersecting: true } as IntersectionObserverEntry], + {} as IntersectionObserver + ); + }); + + // Overflow confirmed — gradient still shown + expect(findExpandZones(renderer)).toHaveLength(1); + expect(getContentContainer(renderer).props.style.maxHeight).toBe('4.5em'); + + act(() => { + renderer.unmount(); + }); + }); + + it('IntersectionObserver clears overflowing for short content entering viewport', () => { + // Start with scrollHeight=0 + const contentElement = { clientHeight: 0, scrollHeight: 0 }; + const renderer = renderCollapsibleMessage({ collapseMode: 'default' }, contentElement); + + expect(findExpandZones(renderer)).toHaveLength(1); + + // Simulate element entering viewport — content is short, doesn't overflow + contentElement.scrollHeight = 40; + contentElement.clientHeight = 40; + act(() => { + lastIntersectionCallback!( + [{ isIntersecting: true } as IntersectionObserverEntry], + {} as IntersectionObserver + ); + }); + + // No overflow — gradient removed + expect(findExpandZones(renderer)).toHaveLength(0); + expect(findGradientOverlays(renderer)).toHaveLength(0); + expect(findCloseButtons(renderer)).toHaveLength(0); + + act(() => { + renderer.unmount(); + }); + }); + + it('does not rerun overflow measurement when rerendered with the same measurement key', () => { + const { contentElement, getScrollHeightReads } = createMeasuredContentElement(); + const renderer = renderCollapsibleMessage( + { + collapseMode: 'default', + measurementKey: '$message|active||default', + }, + contentElement + ); + const readsAfterMount = getScrollHeightReads(); + + act(() => { + renderer.update( + React.createElement( + CollapsibleMessage as never, + { + collapseMode: 'default', + measurementKey: '$message|active||default', + } as never, + React.createElement('span', undefined, 'message') + ) + ); + }); + + expect(getScrollHeightReads()).toBe(readsAfterMount); + + act(() => { + renderer.unmount(); + }); + }); + + it('reruns overflow measurement when the measurement key changes', () => { + const { contentElement, getScrollHeightReads } = createMeasuredContentElement(); + const renderer = renderCollapsibleMessage( + { + collapseMode: 'default', + measurementKey: '$message|active||default', + }, + contentElement + ); + const readsAfterMount = getScrollHeightReads(); + + act(() => { + renderer.update( + React.createElement( + CollapsibleMessage as never, + { + collapseMode: 'default', + measurementKey: '$message|active|$edit|default', + } as never, + React.createElement('span', undefined, 'message') + ) + ); + }); + + expect(getScrollHeightReads()).toBeGreaterThan(readsAfterMount); + + act(() => { + renderer.unmount(); + }); + }); + + it('does not disable scroll anchoring on the wrapper', () => { + const renderer = renderCollapsibleMessage({ collapseMode: 'default' }); + const wrapper = renderer.root.findAllByType('div')[0]; + + expect(wrapper.props.style?.overflowAnchor).toBeUndefined(); + + act(() => { + renderer.unmount(); + }); + }); + + it('collapsed gradient renders a non-interactive Show more pill child', () => { + const renderer = renderCollapsibleMessage({ collapseMode: 'default' }); + const expandZone = getExpandZone(renderer); + + // The gradient remains the single tab stop. + expect(expandZone.props.tabIndex).toBe(0); + expect(expandZone.props.role).toBe('button'); + + // The pill child labels the action with "Show more" text. + const pill = renderer.root.find( + (node) => node.type === 'span' && node.props.className === 'collapsible-show-more' + ); + const pillTextNode = pill.find( + (node) => + node.type === 'span' && + typeof node.children?.[0] === 'string' && + node.children[0] === 'Show more' + ); + expect(pillTextNode).toBeDefined(); + + // The pill child must not be a button or a second tab stop. + expect(pill.type).not.toBe('button'); + const buttonsInsideGradient = expandZone.findAll( + (node) => node.type === 'button' || node.props.role === 'button', + { deep: true } + ); + // Only the outer gradient itself qualifies, so no extra interactive descendants. + const nestedInteractive = buttonsInsideGradient.filter((node) => node !== expandZone); + expect(nestedInteractive).toHaveLength(0); + + act(() => { + renderer.unmount(); + }); + }); + + it('expanded close control renders a sticky footer wrapper containing a Show less pill', () => { + const renderer = renderCollapsibleMessage({ collapseMode: 'default' }); + + act(() => { + getExpandZone(renderer).props.onClick(); + }); + + const closeButton = getCloseButton(renderer); + expect(closeButton.props.className).toBe('collapsible-pill'); + + // The button has visible "Show less" text. + const labelNode = closeButton.find( + (node) => + node.type === 'span' && + typeof node.children?.[0] === 'string' && + node.children[0] === 'Show less' + ); + expect(labelNode).toBeDefined(); + + // The button is wrapped by the sticky footer container. + const stickyFooters = renderer.root.findAll( + (node) => + node.type === 'div' && + typeof node.props.className === 'string' && + node.props.className === 'collapsible-sticky-footer' + ); + expect(stickyFooters).toHaveLength(1); + const stickyFooter = stickyFooters[0]; + const buttonInsideFooter = stickyFooter.find( + (node) => node.type === 'button' && node.props['aria-label'] === 'Show less' + ); + expect(buttonInsideFooter).toBe(closeButton); + + // The legacy absolute close-button style class is no longer used. + const legacy = renderer.root.findAll( + (node) => + typeof node.props?.className === 'string' && + node.props.className === 'collapsible-close-button' + ); + expect(legacy).toHaveLength(0); + + act(() => { + renderer.unmount(); + }); + }); + + it('does not render the sticky footer when collapsed or for always-expanded mode', () => { + const collapsedRenderer = renderCollapsibleMessage({ collapseMode: 'default' }); + expect( + collapsedRenderer.root.findAll( + (node) => node.props?.className === 'collapsible-sticky-footer' + ) + ).toHaveLength(0); + act(() => { + collapsedRenderer.unmount(); + }); + + const exemptRenderer = renderCollapsibleMessage({ collapseMode: 'always-expanded' }); + expect( + exemptRenderer.root.findAll((node) => node.props?.className === 'collapsible-sticky-footer') + ).toHaveLength(0); + act(() => { + exemptRenderer.unmount(); + }); + }); +}); diff --git a/src/app/mindroom/threads/CollapsibleMessage.tsx b/src/app/mindroom/threads/CollapsibleMessage.tsx new file mode 100644 index 0000000000..0448eb2575 --- /dev/null +++ b/src/app/mindroom/threads/CollapsibleMessage.tsx @@ -0,0 +1,261 @@ +import React, { ReactNode, useEffect, useLayoutEffect, useRef, useState, useCallback } from 'react'; +import { Icon, Icons } from 'folds'; +import * as css from './CollapsibleMessage.css'; + +const MAX_HEIGHT = '4.5em'; + +// Global expand/collapse event bus +type ExpandAllListener = (expand: boolean) => void; +const listeners = new Set(); + +export function expandAllMessages() { + listeners.forEach((fn) => fn(true)); +} + +export function collapseAllMessages() { + listeners.forEach((fn) => fn(false)); +} + +function useExpandAllListener(onToggle: (expand: boolean) => void, enabled: boolean) { + useEffect(() => { + if (!enabled) return undefined; + listeners.add(onToggle); + return () => { + listeners.delete(onToggle); + }; + }, [enabled, onToggle]); +} + +const getCollapsedMaxHeight = (el: HTMLDivElement): number | undefined => { + if (MAX_HEIGHT.endsWith('px')) { + const pixelHeight = Number.parseFloat(MAX_HEIGHT); + return Number.isFinite(pixelHeight) ? pixelHeight : undefined; + } + + if (!MAX_HEIGHT.endsWith('em') || typeof globalThis.getComputedStyle !== 'function') { + return undefined; + } + + const fontSize = Number.parseFloat(globalThis.getComputedStyle(el).fontSize); + if (!Number.isFinite(fontSize)) return undefined; + + return Number.parseFloat(MAX_HEIGHT) * fontSize; +}; + +/** + * Returns whether the element's content overflows the collapsed max-height. + * Returns `null` when the element hasn't been laid out yet (scrollHeight === 0), + * so callers can preserve their current assumption instead of recording a + * false negative. + */ +const isContentOverflowing = (el: HTMLDivElement, expanded: boolean): boolean | null => { + if (el.scrollHeight === 0) return null; + + const collapsedMaxHeight = getCollapsedMaxHeight(el); + if (collapsedMaxHeight !== undefined) { + return el.scrollHeight > collapsedMaxHeight + 1; + } + + return !expanded && el.scrollHeight > el.clientHeight + 1; +}; + +export type CollapsibleMessageCollapseMode = 'default' | 'always-expanded' | 'initially-expanded'; + +type CollapsibleMessageProps = { + children: ReactNode | ((state: CollapsibleMessageRenderState) => ReactNode); + collapseMode?: CollapsibleMessageCollapseMode; + forceOverflowing?: boolean; + measurementKey?: string; + onInitialExpandConsumed?: () => void; +}; + +export type CollapsibleMessageRenderState = { + expanded: boolean; +}; + +export function CollapsibleMessage({ + children, + collapseMode = 'default', + forceOverflowing = false, + measurementKey, + onInitialExpandConsumed, +}: CollapsibleMessageProps) { + const isExempt = collapseMode === 'always-expanded'; + const contentRef = useRef(null); + const gradientRef = useRef(null); + const initialExpandConsumedRef = useRef(onInitialExpandConsumed); + const previousCollapseModeRef = useRef(undefined); + const needsFocusOnCollapseRef = useRef(false); + const [overflowing, setOverflowing] = useState(true); + const [expanded, setExpanded] = useState(() => collapseMode !== 'default'); + + initialExpandConsumedRef.current = onInitialExpandConsumed; + + const checkOverflow = useCallback(() => { + if (isExempt) return; + if (forceOverflowing) { + setOverflowing(true); + return; + } + const el = contentRef.current; + if (!el) return; + const result = isContentOverflowing(el, expanded); + if (result !== null) { + setOverflowing(result); + } + }, [expanded, forceOverflowing, isExempt]); + + useEffect(() => { + if ( + collapseMode === 'initially-expanded' && + previousCollapseModeRef.current !== 'initially-expanded' + ) { + setExpanded(true); + initialExpandConsumedRef.current?.(); + } + previousCollapseModeRef.current = collapseMode; + }, [collapseMode]); + + // Re-measure when the collapse state or the semantic message identity changes. + useLayoutEffect(checkOverflow, [checkOverflow, measurementKey]); + + // ResizeObserver for async layout shifts (lazy images, font loading, etc.) + useEffect(() => { + const el = contentRef.current; + if (isExempt || forceOverflowing || !el || expanded || typeof ResizeObserver === 'undefined') + return undefined; + const observer = new ResizeObserver(() => { + const result = isContentOverflowing(el, expanded); + if (result !== null) { + setOverflowing(result); + } + }); + observer.observe(el); + return () => observer.disconnect(); + }, [expanded, forceOverflowing, isExempt]); + + // IntersectionObserver: re-check overflow when element enters the viewport. + // Catches elements that had zero scrollHeight when first measured off-screen. + useEffect(() => { + const el = contentRef.current; + if ( + isExempt || + forceOverflowing || + !el || + expanded || + typeof IntersectionObserver === 'undefined' + ) + return undefined; + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting) { + const result = isContentOverflowing(el, expanded); + if (result !== null) { + setOverflowing(result); + } + } + }, + { threshold: 0 } + ); + observer.observe(el); + return () => observer.disconnect(); + }, [expanded, forceOverflowing, isExempt]); + + // Subscribe to global expand/collapse events + const handleGlobalToggle = useCallback((expand: boolean) => { + setExpanded(expand); + if (!expand) { + setOverflowing(false); // will be re-detected by useLayoutEffect + } + }, []); + useExpandAllListener(handleGlobalToggle, !isExempt); + + // Focus management: after collapse, focus the gradient expand control + useEffect(() => { + if (needsFocusOnCollapseRef.current && !expanded && overflowing && gradientRef.current) { + gradientRef.current.focus(); + needsFocusOnCollapseRef.current = false; + } + }, [expanded, overflowing]); + + const handleGradientClick = useCallback(() => { + setExpanded(true); + }, []); + + const handleGradientKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setExpanded(true); + } + }, []); + + const handleCollapseClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + needsFocusOnCollapseRef.current = true; + setExpanded(false); + setOverflowing(false); + }, []); + + const handleCollapseKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + needsFocusOnCollapseRef.current = true; + setExpanded(false); + setOverflowing(false); + } + }, []); + + const showCloseButton = !isExempt && expanded && overflowing; + const showGradient = !isExempt && !expanded && overflowing; + const effectiveExpanded = isExempt || expanded; + const renderedChildren = + typeof children === 'function' ? children({ expanded: effectiveExpanded }) : children; + + return ( +
+
+ {renderedChildren} + {showGradient && ( +
+ + + Show more + +
+ )} + {showCloseButton && ( +
+ +
+ )} +
+
+ ); +} diff --git a/src/app/mindroom/threads/CompactRoomView.css.ts b/src/app/mindroom/threads/CompactRoomView.css.ts new file mode 100644 index 0000000000..f0e1e4d46e --- /dev/null +++ b/src/app/mindroom/threads/CompactRoomView.css.ts @@ -0,0 +1,191 @@ +import { style } from '@vanilla-extract/css'; +import { recipe } from '@vanilla-extract/recipes'; +import { DefaultReset, color, config } from 'folds'; + +export const View = style({ + display: 'flex', + flexDirection: 'column', + flex: '1 1 auto', + gap: config.space.S100, + minWidth: 0, + padding: `0 ${config.space.S300} ${config.space.S300}`, + overflowY: 'auto', + overflowX: 'hidden', + width: '100%', +}); + +export const EmptyState = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + minHeight: '8rem', + padding: config.space.S300, + borderRadius: config.radii.R400, + border: `${config.borderWidth.B300} dashed ${color.SurfaceVariant.ContainerLine}`, + color: color.SurfaceVariant.OnContainer, +}); + +export const Card = style([ + DefaultReset, + { + display: 'flex', + flexDirection: 'column', + gap: config.space.S100, + width: '100%', + minWidth: 0, + padding: `${config.space.S200} ${config.space.S300}`, + borderRadius: config.radii.R300, + border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`, + backgroundColor: color.SurfaceVariant.Container, + color: color.SurfaceVariant.OnContainer, + textAlign: 'left', + cursor: 'pointer', + transition: 'background-color 120ms ease, border-color 120ms ease', + ':hover': { + backgroundColor: color.SurfaceVariant.ContainerHover, + }, + ':focus-visible': { + outline: `${config.borderWidth.B300} solid ${color.Primary.Main}`, + outlineOffset: '1px', + }, + }, +]); + +export const CardResolved = style({ + borderColor: color.Success.ContainerLine, + backgroundColor: color.Success.Container, + color: color.Success.OnContainer, +}); + +export const TitleRow = style({ + display: 'flex', + alignItems: 'center', + gap: config.space.S100, + minWidth: 0, + justifyContent: 'space-between', +}); + +export const TitleLead = style({ + display: 'flex', + alignItems: 'center', + gap: config.space.S100, + minWidth: 0, + flex: 1, +}); + +export const TitleText = style({ + minWidth: 0, + flex: 1, + overflowWrap: 'anywhere', +}); + +export const TimeText = style({ + whiteSpace: 'nowrap', + flexShrink: 0, +}); + +export const MessageRow = style({ + display: 'flex', + alignItems: 'center', + gap: config.space.S100, + minWidth: 0, + justifyContent: 'space-between', +}); + +export const MessageText = style({ + minWidth: 0, + flex: 1, +}); + +export const Stats = style({ + display: 'flex', + alignItems: 'center', + gap: config.space.S100, + rowGap: config.space.S100, + flexShrink: 0, + flexWrap: 'wrap', + justifyContent: 'flex-end', +}); + +export const StatBadge = style({ + flexShrink: 0, + whiteSpace: 'nowrap', +}); + +export const ScheduledIndicator = style({ + minWidth: 0, + flexShrink: 0, + whiteSpace: 'nowrap', +}); + +export const MetadataRow = style({ + display: 'flex', + alignItems: 'center', + gap: config.space.S100, + rowGap: config.space.S100, + minWidth: 0, + flexWrap: 'wrap', +}); + +export const Participants = style({ + display: 'flex', + alignItems: 'center', + flexShrink: 0, +}); + +export const ParticipantAvatar = style({ + flexShrink: 0, +}); + +export const StatusChip = style({ + flexShrink: 0, +}); + +export const UnreadWrap = style({ + flexShrink: 0, + color: color.SurfaceVariant.OnContainer, +}); + +export const UnreadDot = style({ + display: 'inline-block', +}); + +export const ScreenReaderText = style({ + border: 0, + clip: 'rect(0 0 0 0)', + height: '1px', + margin: '-1px', + overflow: 'hidden', + padding: 0, + position: 'absolute', + whiteSpace: 'nowrap', + width: '1px', +}); + +export const AttentionDot = recipe({ + base: { + width: '0.5rem', + height: '0.5rem', + borderRadius: '999px', + flexShrink: 0, + }, + variants: { + state: { + 'needs-attention': { + backgroundColor: color.Critical.Main, + }, + waiting: { + backgroundColor: color.Success.Main, + }, + streaming: { + backgroundColor: color.Primary.Main, + }, + resolved: { + backgroundColor: color.SurfaceVariant.OnContainer, + }, + idle: { + backgroundColor: color.SurfaceVariant.ContainerLine, + }, + }, + }, +}); diff --git a/src/app/mindroom/threads/CompactRoomView.test.ts b/src/app/mindroom/threads/CompactRoomView.test.ts new file mode 100644 index 0000000000..7d5e60f243 --- /dev/null +++ b/src/app/mindroom/threads/CompactRoomView.test.ts @@ -0,0 +1,186 @@ +import React from 'react'; +import { act, create } from 'react-test-renderer'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { CompactThreadCardViewModel, ThreadRecord } from './types'; +import { CompactRoomView } from './CompactRoomView'; + +const { passthrough, renderedCardProps, useCompactThreadCardViewModelsMock } = vi.hoisted(() => ({ + passthrough: 'div', + renderedCardProps: vi.fn(), + useCompactThreadCardViewModelsMock: vi.fn(), +})); + +const makeViewModel = ( + threadRootId: string, + overrides: Partial = {} +): CompactThreadCardViewModel => ({ + id: { + roomId: '!room:server', + threadRootId, + }, + titleText: 'Thread title', + displayTitleText: 'Thread title', + previewText: 'Latest reply', + primarySummaryText: 'Primary summary', + recentThreadSummaryText: 'Recent summary', + messageCount: 2, + messageCountLabel: '2 msgs', + attentionState: 'idle', + attentionStatusText: 'Idle', + participants: [], + tags: [], + isResolved: false, + isUnread: false, + isStreaming: false, + ...overrides, +}); + +const makeThreadRecord = ( + threadRootId: string, + overrides: Partial = {} +): ThreadRecord => ({ + roomId: '!room:server', + threadRootId, + rootEventId: threadRootId, + presentation: { + messageCount: 0, + participantIds: [], + }, + status: { + isKnownThreadRoot: true, + replyCount: 0, + isResolved: false, + isUnread: false, + isStreaming: false, + scheduledTaskCount: 0, + tags: [], + }, + cache: { + eventCount: 0, + relationSnapshotComplete: false, + tailLoaded: false, + }, + absoluteIndex: 0, + ...overrides, +}); + +vi.mock('folds', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + Box: passthrough, + Text: passthrough, + }; +}); + +vi.mock('./compactThreadCardViewModel', () => ({ + useCompactThreadCardViewModels: useCompactThreadCardViewModelsMock, +})); + +vi.mock('./CompactThreadCard', () => ({ + CompactThreadCard: ({ + viewModel, + onClick, + }: { + viewModel: CompactThreadCardViewModel; + onClick: (threadRootId: string, summaryText?: string) => void; + }) => { + renderedCardProps({ viewModel }); + + return React.createElement( + 'button', + { + type: 'button', + 'data-thread-root-id': viewModel.id.threadRootId, + onClick: () => onClick(viewModel.id.threadRootId, viewModel.primarySummaryText), + }, + viewModel.titleText + ); + }, +})); + +vi.mock('./CompactRoomView.css', () => ({ + View: 'View', + EmptyState: 'EmptyState', +})); + +const makeRoom = () => + ({ + roomId: '!room:server', + } as never); + +describe('CompactRoomView', () => { + beforeEach(() => { + vi.clearAllMocks(); + useCompactThreadCardViewModelsMock.mockReturnValue([]); + }); + + it('renders an empty state when there are no thread roots', () => { + const room = makeRoom(); + const renderer = create( + React.createElement(CompactRoomView, { + room, + threadRootIds: [], + threadRecordMap: new Map(), + onThreadClick: vi.fn(), + }) + ); + + expect(useCompactThreadCardViewModelsMock).toHaveBeenCalledWith({ + room, + threadRootIds: [], + threadRecordMap: new Map(), + }); + expect(renderer.root.findAll((node) => node.children.includes('No threads'))).toHaveLength(1); + expect(renderedCardProps).not.toHaveBeenCalled(); + }); + + it('builds compact card view models through the shared MindRoom selector', () => { + const room = makeRoom(); + const threadRecordMap = new Map([['$thread-1', makeThreadRecord('$thread-1')]]); + const viewModel = makeViewModel('$thread-1', { titleText: 'AI summary' }); + useCompactThreadCardViewModelsMock.mockReturnValue([viewModel]); + + create( + React.createElement(CompactRoomView, { + room, + threadRootIds: ['$thread-1'], + threadRecordMap, + onThreadClick: vi.fn(), + }) + ); + + expect(useCompactThreadCardViewModelsMock).toHaveBeenCalledWith({ + room, + threadRootIds: ['$thread-1'], + threadRecordMap, + }); + expect(renderedCardProps).toHaveBeenCalledWith({ viewModel }); + }); + + it('forwards card clicks using the recent-thread summary from the view model', () => { + const onThreadClick = vi.fn(); + const viewModel = makeViewModel('$thread-3', { + primarySummaryText: 'Card title', + recentThreadSummaryText: 'Recent sidebar summary', + }); + useCompactThreadCardViewModelsMock.mockReturnValue([viewModel]); + const renderer = create( + React.createElement(CompactRoomView, { + room: makeRoom(), + threadRootIds: ['$thread-3'], + threadRecordMap: new Map([['$thread-3', makeThreadRecord('$thread-3')]]), + onThreadClick, + }) + ); + + const button = renderer.root.findByProps({ 'data-thread-root-id': '$thread-3' }); + + act(() => { + button.props.onClick(); + }); + + expect(onThreadClick).toHaveBeenCalledWith('$thread-3', 'Recent sidebar summary'); + }); +}); diff --git a/src/app/mindroom/threads/CompactRoomView.tsx b/src/app/mindroom/threads/CompactRoomView.tsx new file mode 100644 index 0000000000..0d82e959ad --- /dev/null +++ b/src/app/mindroom/threads/CompactRoomView.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { Box, Text } from 'folds'; +import type { Room } from 'matrix-js-sdk/lib/models/room'; +import { useCompactThreadCardViewModels } from './compactThreadCardViewModel'; +import type { ThreadRecord } from './types'; +import { CompactThreadCard } from './CompactThreadCard'; +import * as css from './CompactRoomView.css'; + +export type CompactRoomViewProps = { + room: Room; + threadRootIds: string[]; + threadRecordMap: ReadonlyMap; + onThreadClick: (threadRootId: string, summaryText?: string) => void; +}; + +export function CompactRoomView({ + room, + threadRootIds, + threadRecordMap, + onThreadClick, +}: CompactRoomViewProps) { + const cardViewModels = useCompactThreadCardViewModels({ + room, + threadRootIds, + threadRecordMap, + }); + + if (threadRootIds.length === 0) { + return ( + + + + No threads + + + + ); + } + + return ( + + {cardViewModels.map((viewModel) => ( + + onThreadClick(clickedThreadRootId, viewModel.recentThreadSummaryText) + } + /> + ))} + + ); +} diff --git a/src/app/mindroom/threads/CompactThreadCard.tsx b/src/app/mindroom/threads/CompactThreadCard.tsx new file mode 100644 index 0000000000..87a84f963b --- /dev/null +++ b/src/app/mindroom/threads/CompactThreadCard.tsx @@ -0,0 +1,204 @@ +import React from 'react'; +import { Avatar, Badge, Box, Chip, Icon, Icons, Text } from 'folds'; +import { IconCalendarEvent } from '@tabler/icons-react'; +import * as threadIndicatorCss from './ThreadIndicator.css'; +import { UserAvatar } from '../../components/user-avatar'; +import { useRelativeTime } from '../../hooks/useRelativeTime'; +import type { CompactThreadCardViewModel } from './types'; +import * as css from './CompactRoomView.css'; + +const tagColor = (tagName: string): string => { + let hash = 0; + for (let i = 0; i < tagName.length; i++) { + hash = tagName.charCodeAt(i) + ((hash << 5) - hash); + hash &= hash; + } + const hue = ((hash % 360) + 360) % 360; + return `hsl(${hue}, 65%, 82%)`; +}; + +export type CompactThreadCardProps = { + viewModel: CompactThreadCardViewModel; + onClick: (threadRootId: string, summaryText?: string) => void; +}; + +export function CompactThreadCard({ viewModel, onClick }: CompactThreadCardProps) { + const { + id, + titleText, + displayTitleText, + previewText, + messageCountLabel, + attentionState, + attentionStatusText, + participants, + tags, + isResolved, + isUnread, + isStreaming, + scheduledDisplayText, + scheduledTaskLabel, + lastActivityTs, + lastActivityTitle, + primarySummaryText, + } = viewModel; + const relativeTime = useRelativeTime(lastActivityTs); + const ariaLabel = [ + `Open thread: ${titleText}`, + attentionStatusText, + previewText, + messageCountLabel, + isResolved ? 'Resolved thread' : 'Unresolved thread', + isUnread ? 'Unread messages' : undefined, + isStreaming ? 'Agent streaming' : undefined, + scheduledTaskLabel, + relativeTime ? `Last activity ${relativeTime}` : undefined, + ] + .filter(Boolean) + .join('. '); + const hasMetadata = participants.length > 0 || tags.length > 0 || isStreaming || isUnread; + + return ( + + ); +} diff --git a/src/app/mindroom/threads/MindroomCallChatView.tsx b/src/app/mindroom/threads/MindroomCallChatView.tsx new file mode 100644 index 0000000000..4bdeba2de1 --- /dev/null +++ b/src/app/mindroom/threads/MindroomCallChatView.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { Room } from 'matrix-js-sdk'; +import { useSetAtom } from 'jotai'; +import { useParams } from 'react-router-dom'; +import { Box, Text, TooltipProvider, Tooltip, Icon, Icons, IconButton, toRem } from 'folds'; +import { Page, PageHeader } from '../../components/page'; +import { callChatAtom } from '../../state/callEmbed'; +import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; +import { RoomView } from './MindroomRoomView'; + +export function MindroomCallChatView({ room }: { room: Room }) { + const { eventId } = useParams(); + const setChat = useSetAtom(callChatAtom); + const screenSize = useScreenSizeContext(); + + const handleClose = () => setChat(false); + + return ( + + + + + + Chat + + + + + Close + + } + > + {(triggerRef) => ( + + + + )} + + + + + + + + + ); +} diff --git a/src/app/mindroom/threads/MindroomRoom.tsx b/src/app/mindroom/threads/MindroomRoom.tsx new file mode 100644 index 0000000000..3f2989f64f --- /dev/null +++ b/src/app/mindroom/threads/MindroomRoom.tsx @@ -0,0 +1,91 @@ +import React, { useMemo } from 'react'; +import { Box, Line } from 'folds'; +import { useParams, useSearchParams } from 'react-router-dom'; +import { useAtomValue } from 'jotai'; +import { RoomView } from './MindroomRoomView'; +import { MembersDrawer } from '../../features/room/MembersDrawer'; +import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; +import { useSetting } from '../../state/hooks/settings'; +import { settingsAtom } from '../../state/settings'; +import { PowerLevelsContextProvider, usePowerLevels } from '../../hooks/usePowerLevels'; +import { useRoom } from '../../hooks/useRoom'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useRoomMembers } from '../../hooks/useRoomMembers'; +import { CallView } from '../../features/call/CallView'; +import { RoomViewHeader } from './MindroomRoomViewHeader'; +import { callChatAtom } from '../../state/callEmbed'; +import { MindroomCallChatView } from './MindroomCallChatView'; +import { getRoomSearchParams } from '../../pages/pathSearchParam'; +import { useRoomThreadRouteRestore } from './useRoomThreadRouteRestore'; +import { useRoomEscapeReadReceipts } from './useRoomEscapeReadReceipts'; +import { roomViewModeAtomFamily } from './roomViewMode'; + +export function Room() { + const { eventId } = useParams(); + const [searchParams] = useSearchParams(); + const room = useRoom(); + const mx = useMatrixClient(); + const roomSearchParams = useMemo(() => getRoomSearchParams(searchParams), [searchParams]); + const { focusEvent, threadId } = roomSearchParams; + + const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer'); + const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); + const screenSize = useScreenSizeContext(); + const powerLevels = usePowerLevels(room); + const members = useRoomMembers(mx, room.roomId); + const chat = useAtomValue(callChatAtom); + const viewMode = useAtomValue(roomViewModeAtomFamily(room.roomId)); + const routedThreadId = viewMode === 'classic' ? undefined : threadId; + const handleThreadLoadError = useRoomThreadRouteRestore({ + eventId, + roomId: room.roomId, + threadId, + viewMode, + }); + useRoomEscapeReadReceipts({ hideActivity, roomId: room.roomId, threadId: routedThreadId }); + + const callView = room.isCallRoom(); + + return ( + + + {callView && (screenSize === ScreenSize.Desktop || !chat) && ( + + + + + + + )} + {!callView && ( + + + + + + )} + + {callView && chat && ( + <> + {screenSize === ScreenSize.Desktop && ( + + )} + + + )} + {!callView && screenSize === ScreenSize.Desktop && isDrawer && ( + <> + + + + )} + + + ); +} diff --git a/src/app/mindroom/threads/MindroomRoomTimeline.tsx b/src/app/mindroom/threads/MindroomRoomTimeline.tsx new file mode 100644 index 0000000000..dfe5f20791 --- /dev/null +++ b/src/app/mindroom/threads/MindroomRoomTimeline.tsx @@ -0,0 +1,2944 @@ +/* eslint-disable react/destructuring-assignment */ +import React, { + Dispatch, + MouseEventHandler, + RefObject, + SetStateAction, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { Direction, EventTimelineSet, MatrixEvent, Room, MsgType } from 'matrix-js-sdk'; +import { type Relations } from 'matrix-js-sdk/lib/models/relations'; +import { HTMLReactParserOptions } from 'html-react-parser'; +import classNames from 'classnames'; +import { ReactEditor } from 'slate-react'; +import { Editor } from 'slate'; +import { SessionMembershipData } from 'matrix-js-sdk/lib/matrixrtc/CallMembership'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { useAtomValue, useSetAtom } from 'jotai'; +import { + Badge, + Box, + Chip, + ContainerColor, + Icon, + Icons, + Line, + Scroll, + Text, + as, + color, + config, + toRem, +} from 'folds'; +import { isKeyHotkey } from 'is-hotkey'; +import { Opts as LinkifyOpts } from 'linkifyjs'; +import { useTranslation } from 'react-i18next'; +import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../utils/matrix'; +import { + getActiveAnnotationsByKey, + getActiveEventsForAnnotationKey, +} from '../../utils/reactionAnnotations'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useVirtualPaginator } from '../../hooks/useVirtualPaginator'; +import { useAlive } from '../../hooks/useAlive'; +import { editableActiveElement, scrollToBottom } from '../../utils/dom'; +import { + DefaultPlaceholder, + CompactPlaceholder, + Reply, + MessageBase, + MessageUnsupportedContent, + Time, + MessageNotDecryptedContent, + RedactedContent, + MSticker, + ImageContent, + EventContent, +} from '../../components/message'; +import { + factoryRenderLinkifyWithMention, + getReactCustomHtmlParser, + LINKIFY_OPTS, + makeMentionCustomProps, + renderMatrixMention, +} from '../../plugins/react-custom-html-parser'; +import { + canEditEvent, + getEditedEvent, + getEventReactions, + getLatestMessageContent, + getLatestEditableEvt, + getMemberDisplayName, + getReactionContent, + isMembershipChanged, + reactionOrEditEvent, +} from '../../utils/room'; +import { useSetting } from '../../state/hooks/settings'; +import { MessageLayout, settingsAtom } from '../../state/settings'; +import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer'; +import { EncryptedContent } from '../../features/room/message/EncryptedContent'; +import { Reactions } from '../../features/room/message/Reactions'; +import { useMemberEventParser } from '../../hooks/useMemberEventParser'; +import * as customHtmlCss from '../../styles/CustomHtml.css'; +import { RoomIntro } from '../../components/room-intro'; +import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver'; +import * as css from '../../features/room/RoomTimeline.css'; +import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time'; +import { createMentionElement, isEmptyEditor, moveCursor } from '../../components/editor'; +import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts'; +import { usePowerLevelsContext } from '../../hooks/usePowerLevels'; +import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room'; +import { useKeyDown } from '../../hooks/useKeyDown'; +import { RenderMessageContent } from '../../components/RenderMessageContent'; +import { VirtualTile } from '../../components/virtualizer'; +import { CollapsibleMessage, expandAllMessages, collapseAllMessages } from './CollapsibleMessage'; +import { Image } from '../../components/media'; +import { ImageViewer } from '../../components/image-viewer'; +import { roomToParentsAtom } from '../../state/room/roomToParents'; +import { useRoomUnread } from '../../state/hooks/unread'; +import { roomToUnreadAtom } from '../../state/room/roomToUnread'; +import { useMentionClickHandler } from '../../hooks/useMentionClickHandler'; +import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler'; +import { useRoomNavigate } from '../../hooks/useRoomNavigate'; +import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { useIgnoredUsers } from '../../hooks/useIgnoredUsers'; +import { useInitialClientCatchup } from '../../hooks/useInitialClientCatchup'; +import { useImagePackRooms } from '../../hooks/useImagePackRooms'; +import { useIsDirectRoom } from '../../hooks/useRoom'; +import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile'; +import { createSessionId } from '../../state/sessions'; +import { useSpaceOptionally } from '../../hooks/useSpace'; +import { useRoomCreators } from '../../hooks/useRoomCreators'; +import { useRoomPermissions } from '../../hooks/useRoomPermissions'; +import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag'; +import { useTheme } from '../../hooks/useTheme'; +import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag'; +import { usePowerLevelTags } from '../../hooks/usePowerLevelTags'; +import { Event, Message } from '../messages/MindroomMessage'; +import type { MindroomThreadSummaryInfo } from './threadSummaryStore'; +import { + consumeLiveExpandOnceId, + getCollapsibleMessageMeasurementKey, + getCollapsibleMessageMode, + getHydratedLongTextExtrasCollapseKey, + shouldForceCollapsibleMessageOverflow, +} from './threadCollapsibleMessages'; +import { buildResolveConfirmedEventId, dedupeThreadRenderEventEntries } from './threadRenderUtils'; +import { + useTimelineDebugRangeController, + useTimelineDebugTraceIds, +} from './timelineDebugController'; +import { CompactRoomView } from './CompactRoomView'; +import { RoomThreadOverview } from './RoomThreadOverview'; +import { + getRenderableEventEntries, + mergeClassicRoomThreadReplyEntries, +} from './roomTimelineEvents'; +import { + getEmptyTimeline, + getInitialTimeline, + getLiveTimeline, + getRoomUnreadInfo, + getTimelinesEventsCount, + type RecalibrateFilterOpts, + type Timeline, +} from './timelinePagination'; +import { useTimelinePagination } from './timelinePaginationController'; +import { useThreadSummaryPublishController } from './threadSummaryPublishController'; +import { useThreadOverviewRefreshCounter } from './threadOverviewRefreshCounter'; +import { useThreadSortFreezeController } from './threadSortFreezeController'; +import { useMindroomThreadIndex } from './useMindroomThreadIndex'; +import { + getMindroomRoomTimelineApprovalContentIfSupported, + getMindroomRoomTimelineMessageRenderers, + renderMindroomRoomTimelineThreadBadge, +} from './roomTimelineMessageExtensions'; +import type { ThreadFilterKey } from './RoomThreadOverview'; +import { + type ThreadFilterState, + type ThreadSortFreezeState, + type FilterPreset, +} from './roomThreadOverviewModel'; +import type { RoomViewMode } from './roomViewMode'; +import { isTimelineAtLiveEnd, shouldRenderUnreadDividerAt } from './timelineScrollUtils'; +import { + resolveRoomTimelineViewState, + THREAD_OVERVIEW_METADATA_CACHE_LIMIT, +} from './roomTimelineViewState'; +import { useRoomThreadResolutionMap } from './useRoomThreadTags'; +import { useRoomEagerPreload } from './preloadController'; +import { ROOM_TIMELINE_INTERACTIVE_BATCH_SIZE, sanitizePaginationLimit } from './preloadSettings'; +import { mindroomSettingsAtom } from '../settings/mindroomSettings'; +import { useThreadBackPaginationController } from './threadBackPaginationController'; +import { type PendingThreadOpen } from './threadOpenTargetEvent'; +import { useThreadSeedPrewarmController } from './threadSeedPrewarmController'; +import { useThreadOpenCacheController } from './threadOpenCacheController'; +import { useThreadAwareTimelineRefresh } from './useThreadAwareTimelineRefresh'; +import { useThreadOverviewResumeController } from './threadOverviewResumeController'; +import { useThreadCachePersistenceController } from './threadCachePersistenceController'; +import { useCompactRootEditBackfillController } from './compactRootEditBackfillController'; +import { useThreadPaginationCommandController } from './threadPaginationCommandController'; +import { useThreadEditBackfillController } from './threadEditBackfillController'; +import { useRoomPaginationCommandController } from './roomPaginationCommandController'; +import { useRoomCacheLifecycleController } from './roomCacheLifecycleController'; +import { useRoomCacheHydrationController } from './roomCacheHydrationController'; +import { useRoomLiveEventController } from './roomLiveEventController'; +import { useThreadOpenLifecycleController } from './threadOpenLifecycleController'; +import { useRoomTimelineWindowController } from './roomTimelineWindowController'; +import { useTimelineReadReceiptController } from './timelineReadReceiptController'; +import { + useRoomEventOpenController, + useRoomEventRouteOpenController, +} from './roomEventOpenController'; +import { + useRoomFocusScrollController, + type RoomTimelineFocusItem, +} from './roomFocusScrollController'; +import { useRoomTimelineNavigationController } from './roomTimelineNavigationController'; +import { buildMindroomRoomTimelineReplyDraft } from './roomTimelineReplyDraft'; +import { useThreadTimelineState } from './useThreadTimelineState'; + +const TimelineFloat = as<'div', css.TimelineFloatVariants>( + ({ position, className, ...props }, ref) => ( + + ) +); + +const TimelineDivider = as<'div', { variant?: ContainerColor | 'Inherit' }>( + ({ variant, children, ...props }, ref) => ( + + + {children} + + + ) +); + +export type RoomTimelineProps = { + room: Room; + eventId?: string; + focusEventInRoom?: boolean; + threadId?: string; + summaryMap: Map; + onStoreThreadSummary: (threadRootId: string, info: MindroomThreadSummaryInfo | undefined) => void; + threadFilterState: ThreadFilterState; + threadSortFreezeState: ThreadSortFreezeState | null; + onToggle: (key: ThreadFilterKey) => void; + onSortDirectionChange: () => void; + onToggleThreadSortFreeze: () => void; + setThreadSortFreezeState: Dispatch>; + onCycleTag: (tag: string) => void; + onAddTag: (tag: string) => void; + onRemoveTag: (tag: string) => void; + onReset: () => void; + onApplyPreset: (preset: FilterPreset) => void; + onSearchQueryChange: (query: string) => void; + viewMode?: RoomViewMode; + onViewModeChange?: (viewMode: RoomViewMode) => void; + onThreadLoadError?: (threadId: string) => void; + roomInputRef: RefObject; + editor: Editor; +}; + +export function RoomTimeline({ + room, + eventId, + focusEventInRoom, + threadId, + summaryMap, + onStoreThreadSummary, + threadFilterState, + threadSortFreezeState, + onToggle, + onSortDirectionChange, + onToggleThreadSortFreeze, + setThreadSortFreezeState, + onCycleTag, + onAddTag, + onRemoveTag, + onReset, + onApplyPreset, + onSearchQueryChange, + viewMode = 'threaded', + onViewModeChange, + onThreadLoadError, + roomInputRef, + editor, +}: RoomTimelineProps) { + const mx = useMatrixClient(); + const initialClientCatchupInProgress = useInitialClientCatchup(mx); + const sessionId = useMemo(() => createSessionId(mx.getHomeserverUrl(), mx.getSafeUserId()), [mx]); + const useAuthentication = useMediaAuthentication(); + const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); + const [messageLayout] = useSetting(settingsAtom, 'messageLayout'); + const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing'); + const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor'); + const direct = useIsDirectRoom(); + const { + effectiveViewMode, + focusedRoomOverviewRequested, + requestedThreadFilterState, + showRoomThreadOverviewControls, + } = resolveRoomTimelineViewState({ + eventId, + focusEventInRoom, + threadFilterState, + threadId, + viewMode, + }); + const showThreadRepliesInRoom = effectiveViewMode === 'classic'; + const roomEagerPreloadEnabled = !threadId && !eventId && effectiveViewMode !== 'classic'; + const showThreadBadgesInRoom = effectiveViewMode !== 'classic'; + const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents'); + const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents'); + const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); + const [urlPreview] = useSetting(settingsAtom, 'urlPreview'); + const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview'); + const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview; + const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents'); + const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools'); + const [paginationLimitSetting] = useSetting(mindroomSettingsAtom, 'paginationLimit'); + const safePaginationLimit = sanitizePaginationLimit(paginationLimitSetting); + const interactivePaginationLimit = Math.min( + safePaginationLimit, + ROOM_TIMELINE_INTERACTIVE_BATCH_SIZE + ); + const safePaginationLimitRef = useRef(safePaginationLimit); + safePaginationLimitRef.current = safePaginationLimit; + + const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); + const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString'); + + const ignoredUsersList = useIgnoredUsers(); + const ignoredUsersSet = useMemo(() => new Set(ignoredUsersList), [ignoredUsersList]); + + const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId)); + const powerLevels = usePowerLevelsContext(); + const creators = useRoomCreators(room); + + const creatorsTag = useRoomCreatorsTag(); + const powerLevelTags = usePowerLevelTags(room, powerLevels); + const getMemberPowerTag = useGetMemberPowerTag(room, creators, powerLevels); + + const theme = useTheme(); + const accessiblePowerTagColors = useAccessiblePowerTagColors( + theme.kind, + creatorsTag, + powerLevelTags + ); + + const permissions = useRoomPermissions(creators, powerLevels); + + const canRedact = permissions.action('redact', mx.getSafeUserId()); + const canDeleteOwn = permissions.event(MessageEvent.RoomRedaction, mx.getSafeUserId()); + const canSendReaction = permissions.event(MessageEvent.Reaction, mx.getSafeUserId()); + const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, mx.getSafeUserId()); + const [editId, setEditId] = useState(); + + const roomToParents = useAtomValue(roomToParentsAtom); + const unread = useRoomUnread(room.roomId, roomToUnreadAtom); + const { navigateRoom, navigateRoomThread } = useRoomNavigate(); + const mentionClickHandler = useMentionClickHandler(room.roomId); + const spoilerClickHandler = useSpoilerClickHandler(); + const openUserRoomProfile = useOpenUserRoomProfile(); + const space = useSpaceOptionally(); + + const imagePackRooms: Room[] = useImagePackRooms(room.roomId, roomToParents); + + const [unreadInfo, setUnreadInfo] = useState(() => getRoomUnreadInfo(room, true)); + const readUptoEventIdRef = useRef(); + if (unreadInfo) { + readUptoEventIdRef.current = unreadInfo.readUptoEventId; + } + + const atBottomAnchorRef = useRef(null); + const [atBottom, setAtBottom] = useState(true); + const [allExpanded, setAllExpanded] = useState(false); + const atBottomRef = useRef(atBottom); + const liveExpandOnceIds = useRef(new Set()); + const [hydratedLongTextExtrasCollapseKeys, setHydratedLongTextExtrasCollapseKeys] = useState< + ReadonlySet + >(() => new Set()); + atBottomRef.current = atBottom; + + const scrollRef = useRef(null); + const scrollToBottomRef = useRef({ + count: 0, + smooth: true, + }); + + const [focusItem, setFocusItem] = useState(); + const [threadLoadError, setThreadLoadError] = useState(false); + const [roomHasMoreCachedBack, setRoomHasMoreCachedBack] = useState(false); + const [eagerPreloading, setEagerPreloading] = useState(roomEagerPreloadEnabled); + const [roomInitialCacheHydratedKey, setRoomInitialCacheHydratedKey] = useState< + string | undefined + >(); + const [threadHasMoreCachedBack, setThreadHasMoreCachedBack] = useState(false); + const [threadTailLoaded, setThreadTailLoaded] = useState(false); + const [threadPaginatingFront, setThreadPaginatingFront] = useState(false); + const [threadInitialCacheHydrated, setThreadInitialCacheHydrated] = useState(false); + const [threadLatestOpenPending, setThreadLatestOpenPending] = useState(false); + const [threadTimelineTick, setThreadTimelineTick] = useState(0); + const [pendingThreadOpenTick, setPendingThreadOpenTick] = useState(0); + const { + isPaginatingBack: threadPaginatingBack, + suppressOpenBottomPinRef: suppressThreadOpenBottomPinRef, + reset: resetThreadBackPagination, + begin: beginThreadBackPagination, + finish: finishThreadBackPagination, + restorePendingAnchor: restorePendingThreadBackPaginationAnchor, + } = useThreadBackPaginationController(); + const roomIdRef = useRef(room.roomId); + const roomPaginatingBackRef = useRef(false); + const eagerPreloadDoneForRoomRef = useRef(null); + const threadIdRef = useRef(threadId); + const threadFilterStateRef = useRef(requestedThreadFilterState); + const threadEditFetchAttemptedRef = useRef>( + new WeakMap() + ); + const pendingThreadOpenRef = useRef(); + const suppressFocusPaginationRef = useRef(false); + const alive = useAlive(); + roomIdRef.current = room.roomId; + threadIdRef.current = threadId; + threadFilterStateRef.current = requestedThreadFilterState; + const { roomDebugTraceId, threadDebugTraceId } = useTimelineDebugTraceIds({ + eventId, + room, + threadId, + }); + + const linkifyOpts = useMemo( + () => ({ + ...LINKIFY_OPTS, + render: factoryRenderLinkifyWithMention((href) => + renderMatrixMention(mx, room.roomId, href, makeMentionCustomProps(mentionClickHandler)) + ), + }), + [mx, room, mentionClickHandler] + ); + const htmlReactParserOptions = useMemo( + () => + getReactCustomHtmlParser(mx, room.roomId, { + linkifyOpts, + useAuthentication, + handleSpoilerClick: spoilerClickHandler, + handleMentionClick: mentionClickHandler, + }), + [mx, room, linkifyOpts, spoilerClickHandler, mentionClickHandler, useAuthentication] + ); + const parseMemberEvent = useMemberEventParser(); + const [timeline, setTimeline] = useState(() => + eventId + ? getEmptyTimeline() + : getInitialTimeline(room, safePaginationLimit, { + threadId, + ignoredUsersSet, + showHiddenEvents, + hideMembershipEvents, + hideNickAvatarEvents, + showThreadRepliesInRoom, + }) + ); + const prevShowThreadRepliesInRoomRef = useRef(showThreadRepliesInRoom); + const eventsLength = getTimelinesEventsCount(timeline.linkedTimelines); + const threadResolutionMap = useRoomThreadResolutionMap(room); + useEffect(() => { + liveExpandOnceIds.current.clear(); + setHydratedLongTextExtrasCollapseKeys((current) => (current.size === 0 ? current : new Set())); + }, [room.roomId, threadId]); + const markHydratedLongTextExtrasCollapsedExempt = useCallback((collapseKey: string) => { + setHydratedLongTextExtrasCollapseKeys((current) => { + if (current.has(collapseKey)) return current; + + const next = new Set(current); + next.add(collapseKey); + return next; + }); + }, []); + // Reset eagerPreloading when transitioning from event-focused view back to room + // (component is reused since key is roomId:threadId, so useState initializer won't re-run) + // useLayoutEffect so the reset fires before paint, preventing a single-frame skeleton flash + useLayoutEffect(() => { + if (!eventId && !threadId) { + setEagerPreloading(roomEagerPreloadEnabled); + } + }, [eventId, roomEagerPreloadEnabled, threadId]); + useLayoutEffect(() => { + if (prevShowThreadRepliesInRoomRef.current === showThreadRepliesInRoom) return; + prevShowThreadRepliesInRoomRef.current = showThreadRepliesInRoom; + if (eventId || threadId) return; + + setTimeline( + getInitialTimeline(room, safePaginationLimit, { + threadId, + ignoredUsersSet, + showHiddenEvents, + hideMembershipEvents, + hideNickAvatarEvents, + showThreadRepliesInRoom, + }) + ); + }, [ + eventId, + threadId, + room, + safePaginationLimit, + ignoredUsersSet, + showHiddenEvents, + hideMembershipEvents, + hideNickAvatarEvents, + showThreadRepliesInRoom, + ]); + const rawRenderableEventEntries = useMemo( + () => + getRenderableEventEntries( + timeline.linkedTimelines, + room, + threadId, + ignoredUsersSet, + showHiddenEvents, + hideMembershipEvents, + hideNickAvatarEvents, + showThreadRepliesInRoom + ), + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + timeline.linkedTimelines, + eventsLength, + threadId, + ignoredUsersSet, + showHiddenEvents, + hideMembershipEvents, + hideNickAvatarEvents, + showThreadRepliesInRoom, + ] + ); + const resolveConfirmedRoomEventId = useMemo( + () => + threadId + ? undefined + : buildResolveConfirmedEventId( + room, + rawRenderableEventEntries.map(({ event }) => event) + ), + [threadId, room, rawRenderableEventEntries] + ); + const renderableEventEntries = useMemo(() => { + if (threadId) return rawRenderableEventEntries; + + const classicRenderableEventEntries = showThreadRepliesInRoom + ? mergeClassicRoomThreadReplyEntries({ + renderableEventEntries: rawRenderableEventEntries, + room, + ignoredUsersSet, + showHiddenEvents, + hideMembershipEvents, + hideNickAvatarEvents, + }) + : rawRenderableEventEntries; + + return dedupeThreadRenderEventEntries( + classicRenderableEventEntries, + resolveConfirmedRoomEventId + ); + }, [ + threadId, + rawRenderableEventEntries, + showThreadRepliesInRoom, + room, + ignoredUsersSet, + showHiddenEvents, + hideMembershipEvents, + hideNickAvatarEvents, + resolveConfirmedRoomEventId, + ]); + const renderableEvents = useMemo( + () => renderableEventEntries.map(({ event }) => event), + [renderableEventEntries] + ); + + const { overviewRefreshCounter, setOverviewRefreshCounter } = useThreadOverviewRefreshCounter( + room, + threadId + ); + + const compactViewRequested = !threadId && effectiveViewMode === 'compact'; + + const { + showCompactRoomView, + roomSurfaceEventEntries, + visibleThreadRootData, + compactThreadRootData, + normalThreadRecordMap, + threadRecordMap, + threadReplyCountMap, + threadParticipantMap, + threadSummaryInfoMap, + scheduledStatusMap, + availableRoomTags, + readUpToTs, + roomThreadListThreads, + refreshRoomThreadList, + effectiveThreadFilterState, + roomThreadFilterActive, + liveThreadFilterState, + filteredThreadRootIds, + compactFilteredThreadRootIds, + roomOverviewOrderActive, + activeLiveOverviewThreadRootIds, + overviewThreadRootIds, + statusCounts, + tagCounts, + searchQuery: threadIndexSearchQuery, + threadSortControlSignature, + applyThreadOverviewRelationEvents, + } = useMindroomThreadIndex({ + room, + threadId, + eventId, + focusedRoomOverviewRequested, + compactViewRequested, + effectiveViewMode, + linkedTimelines: timeline.linkedTimelines, + renderableEventEntries, + ignoredUsersSet, + showHiddenEvents, + hideMembershipEvents, + hideNickAvatarEvents, + summaryMap, + threadResolutionMap, + currentUserId: mx.getSafeUserId(), + requestedThreadFilterState, + threadSortFreezeState, + overviewRefreshCounter, + sessionId, + mx, + onStoreThreadSummary, + }); + threadFilterStateRef.current = effectiveThreadFilterState; + const roomInitialCacheHydrationKey = !threadId && !eventId ? room.roomId : undefined; + const roomInitialCacheHydrated = + !roomInitialCacheHydrationKey || roomInitialCacheHydratedKey === roomInitialCacheHydrationKey; + const deferEmptyRoomOverview = + !threadId && + (!roomInitialCacheHydrated || initialClientCatchupInProgress) && + visibleThreadRootData.ids.length === 0 && + compactThreadRootData.ids.length === 0; + const shouldShowRoomThreadOverviewControls = + showRoomThreadOverviewControls && !deferEmptyRoomOverview; + + useThreadSortFreezeController({ + activeLiveOverviewThreadRootIds, + setThreadSortFreezeState, + threadId, + threadSortFreezeState, + threadSortControlSignature, + }); + + const threadFilteredEventsRef = useRef([]); + const prevRoomThreadFilterActiveRef = useRef(roomThreadFilterActive); + const liveTimelineLinked = + timeline.linkedTimelines[timeline.linkedTimelines.length - 1] === getLiveTimeline(room); + const canPaginateBack = + typeof timeline.linkedTimelines[0]?.getPaginationToken(Direction.Backward) === 'string'; + const { + canPaginateThreadBack, + canPaginateThreadFront, + roomTimelineSet, + thread, + threadBackwardPaginationToken, + threadEventMap, + threadEventIndexMapRef, + threadEvents, + threadInitialRenderMode, + threadTimelineSet, + setSupplementalThreadEvents, + resetThreadRenderState, + } = useThreadTimelineState({ + room, + threadId, + threadInitialCacheHydrated, + debugTraceId: threadDebugTraceId, + }); + const { + activeTimelineRange, + filteredLength, + priorityThreadSeedPrewarmRoots, + readUptoAbsoluteIndex, + showThreadLoadOlderMessages, + threadFilteredEventEntries, + threadFilteredEvents, + unreadScrollAnchorIndex, + useSurfacePreloadTarget, + } = useRoomTimelineWindowController({ + canPaginateThreadBack, + effectiveViewMode, + filteredRoomOverviewOrderActive: roomOverviewOrderActive, + filteredRoomThreadActive: roomThreadFilterActive, + lastThreadBackwardPaginationToken: threadBackwardPaginationToken, + overviewRefreshCounter, + overviewThreadRootIds, + renderableEventEntries, + renderableEvents, + room, + roomSurfaceEventEntries, + roomThreadListThreads, + safePaginationLimit, + threadEventsLength: threadEvents.length, + threadHasMoreCachedBack, + threadId, + threadReplyCountMap, + threadResolutionMap, + threadTailLoaded, + timeline, + unreadInfo, + }); + threadFilteredEventsRef.current = threadFilteredEvents; + const rangeAtStart = activeTimelineRange.start === 0; + const rangeAtEnd = activeTimelineRange.end === filteredLength; + + useTimelineDebugRangeController({ + activeTimelineRange, + canPaginateThreadBack, + canPaginateThreadFront, + eagerPreloading, + eventsLength, + filteredLength, + renderableEventCount: renderableEventEntries.length, + roomDebugTraceId, + roomSurfaceEventCount: roomSurfaceEventEntries.length, + threadDebugTraceId, + threadEventCount: threadEvents.length, + threadId, + threadInitialCacheHydrated, + threadInitialRenderMode, + threadOverviewCount: threadFilteredEvents.length, + threadTailLoaded, + threadTimelineTick, + useSurfacePreloadTarget, + }); + + useEffect(() => { + const wasActive = prevRoomThreadFilterActiveRef.current; + prevRoomThreadFilterActiveRef.current = roomThreadFilterActive; + + if (wasActive && !roomThreadFilterActive && !threadId) { + setTimeline( + getInitialTimeline(room, safePaginationLimit, { + threadId, + ignoredUsersSet, + showHiddenEvents, + hideMembershipEvents, + hideNickAvatarEvents, + showThreadRepliesInRoom, + }) + ); + } + }, [ + roomThreadFilterActive, + threadId, + room, + ignoredUsersSet, + showHiddenEvents, + hideMembershipEvents, + hideNickAvatarEvents, + showThreadRepliesInRoom, + safePaginationLimit, + ]); + + const timelineAtLiveEnd = isTimelineAtLiveEnd({ + threadId, + liveTimelineLinked, + rangeAtEnd, + canPaginateThreadFront, + threadTailLoaded, + }); + const atLiveEndRef = useRef(timelineAtLiveEnd); + atLiveEndRef.current = timelineAtLiveEnd; + + const recalibrateFilterOptsRef = useRef({ + room, + threadId, + ignoredUsersSet, + showHiddenEvents, + hideMembershipEvents, + hideNickAvatarEvents, + showThreadRepliesInRoom, + }); + recalibrateFilterOptsRef.current = { + room, + threadId, + ignoredUsersSet, + showHiddenEvents, + hideMembershipEvents, + hideNickAvatarEvents, + showThreadRepliesInRoom, + }; + + const handleTimelinePagination = useTimelinePagination( + mx, + timeline, + setTimeline, + interactivePaginationLimit, + recalibrateFilterOptsRef + ); + + const handleRoomTimelinePagination = useRoomPaginationCommandController({ + alive, + handleTimelinePagination, + mx, + recalibrateFilterOptsRef, + room, + roomIdRef, + roomPaginatingBackRef, + safePaginationLimitRef, + sessionId, + setRoomHasMoreCachedBack, + setTimeline, + threadId, + threadIdRef, + timeline, + }); + + useRoomEagerPreload({ + alive, + enabled: roomEagerPreloadEnabled, + eventId, + eagerPreloadDoneForRoomRef, + mx, + recalibrateFilterOptsRef, + room, + roomDebugTraceId, + roomIdRef, + roomPaginatingBackRef, + safePaginationLimitRef, + setEagerPreloading, + setTimeline, + threadId, + threadIdRef, + useSurfacePreloadTarget, + }); + + const { persistThreadCacheFromRoomEvents, persistThreadEventCache, queueRoomThreadCachePersist } = + useThreadCachePersistenceController({ + alive, + room, + roomDebugTraceId, + roomIdRef, + sessionId, + threadDebugTraceId, + threadIdRef, + }); + + const { persistRoomEventCache } = useRoomCacheLifecycleController({ + alive, + eventId, + eventsLength, + persistThreadCacheFromRoomEvents, + room, + roomDebugTraceId, + roomIdRef, + sessionId, + setRoomHasMoreCachedBack, + setTimeline, + threadId, + threadIdRef, + timeline, + }); + + useCompactRootEditBackfillController({ + enabled: !threadId && showCompactRoomView, + mx, + overviewThreadRootIds, + persistRoomEventCache, + room, + roomSurfaceEventEntries, + roomThreadListThreads, + setOverviewRefreshCounter, + }); + + const { + ensureThreadSeedPrewarm, + prewarmedThreadSeedIdsRef, + prewarmingThreadSeedIdsRef, + queuedThreadSeedIdsRef, + prewarmingThreadSeedPromisesRef, + } = useThreadSeedPrewarmController({ + room, + mx, + sessionId, + safePaginationLimitRef, + activeThreadId: threadId, + priorityTargets: priorityThreadSeedPrewarmRoots, + debugTraceId: roomDebugTraceId, + }); + + const forceTimelineUpdate = useCallback(() => { + setTimeline((ct) => ({ ...ct })); + }, []); + + const { + backfillThreadRelationsIntoCache, + hydrateThreadFromCache, + refreshLatestThreadRelationsTail, + refreshLatestThreadSlice, + } = useThreadOpenCacheController({ + alive, + debugTraceId: threadDebugTraceId, + forceTimelineUpdate, + mx, + persistThreadEventCache, + room, + roomIdRef, + roomTimelineSet, + safePaginationLimitRef, + sessionId, + setSupplementalThreadEvents, + setThreadHasMoreCachedBack, + setThreadTailLoaded, + setThreadTimelineTick, + threadIdRef, + }); + + const getScrollElement = useCallback(() => scrollRef.current, []); + const getTimelineItemElement = useCallback( + (index: number) => + (scrollRef.current?.querySelector(`[data-message-item="${index}"]`) as HTMLElement) ?? + undefined, + [] + ); + const roomVirtualPrependAnchorRef = useRef<{ + item: number; + viewportOffset: number; + }>(); + const captureRoomVirtualPrependAnchor = useCallback(() => { + const scrollElement = scrollRef.current; + if (!scrollElement) return; + + const scrollRect = scrollElement.getBoundingClientRect(); + const messageItems = Array.from( + scrollElement.querySelectorAll('[data-message-item]') + ); + const anchorElement = messageItems.find((element) => { + const item = Number.parseInt(element.getAttribute('data-message-item') ?? '', 10); + if (!Number.isFinite(item)) return false; + + const rect = element.getBoundingClientRect(); + return rect.bottom > scrollRect.top && rect.top < scrollRect.bottom; + }); + if (!anchorElement) return; + + const item = Number.parseInt(anchorElement.getAttribute('data-message-item') ?? '', 10); + if (!Number.isFinite(item)) return; + + roomVirtualPrependAnchorRef.current = { + item, + viewportOffset: anchorElement.getBoundingClientRect().top - scrollRect.top, + }; + }, []); + + const { + getItems, + scrollToItem, + scrollToElement, + retryPagination, + observeBackAnchor, + observeFrontAnchor, + } = useVirtualPaginator({ + count: threadId ? 0 : filteredLength, + limit: interactivePaginationLimit, + range: activeTimelineRange, + onRangeChange: useCallback( + (r) => { + if (threadId || roomThreadFilterActive) return; + if (r.start < activeTimelineRange.start) { + captureRoomVirtualPrependAnchor(); + } + setTimeline((cs) => ({ ...cs, range: r })); + }, + [activeTimelineRange.start, captureRoomVirtualPrependAnchor, roomThreadFilterActive, threadId] + ), + getScrollElement, + getItemElement: getTimelineItemElement, + onEnd: handleRoomTimelinePagination, + shouldSuppressPagination: useCallback(() => suppressFocusPaginationRef.current, []), + }); + const timelineItems = getItems(); + const estimateRoomTimelineItemSize = useCallback( + () => (messageLayout === MessageLayout.Compact ? 96 : 144), + [messageLayout] + ); + const roomTimelineVirtualizer = useVirtualizer({ + count: threadId ? 0 : timelineItems.length, + getScrollElement, + estimateSize: estimateRoomTimelineItemSize, + overscan: 10, + getItemKey: (index) => { + const item = timelineItems[index]; + return threadFilteredEventEntries[item]?.event.getId() ?? item ?? index; + }, + }); + useLayoutEffect(() => { + const anchor = roomVirtualPrependAnchorRef.current; + if (!anchor || threadId) return; + + if (anchor.item < activeTimelineRange.start || anchor.item >= activeTimelineRange.end) { + roomVirtualPrependAnchorRef.current = undefined; + return; + } + + const virtualIndex = anchor.item - activeTimelineRange.start; + const offset = Math.max( + virtualIndex * estimateRoomTimelineItemSize() - anchor.viewportOffset, + 0 + ); + roomTimelineVirtualizer.scrollToOffset(offset, { behavior: 'auto' }); + roomVirtualPrependAnchorRef.current = undefined; + + requestAnimationFrame(() => { + const scrollElement = scrollRef.current; + const anchorElement = getTimelineItemElement(anchor.item); + if (!scrollElement || !anchorElement) return; + + const expectedTop = scrollElement.getBoundingClientRect().top + anchor.viewportOffset; + const delta = anchorElement.getBoundingClientRect().top - expectedTop; + if (Math.abs(delta) <= 1) return; + + scrollElement.scrollBy({ top: delta, behavior: 'instant' }); + }); + }, [ + activeTimelineRange.end, + activeTimelineRange.start, + estimateRoomTimelineItemSize, + getTimelineItemElement, + roomTimelineVirtualizer, + threadId, + ]); + const roomTimelineLatestVirtualIndex = useMemo(() => { + if (timelineItems.length === 0) return -1; + if (!roomOverviewOrderActive) return timelineItems.length - 1; + + let latestVirtualIndex = 0; + let latestAbsoluteIndex = Number.NEGATIVE_INFINITY; + timelineItems.forEach((item, virtualIndex) => { + const absoluteIndex = threadFilteredEventEntries[item]?.absoluteIndex ?? item; + if (absoluteIndex >= latestAbsoluteIndex) { + latestAbsoluteIndex = absoluteIndex; + latestVirtualIndex = virtualIndex; + } + }); + + return latestVirtualIndex; + }, [roomOverviewOrderActive, threadFilteredEventEntries, timelineItems]); + const roomScrollToBottomCount = scrollToBottomRef.current.count; + useLayoutEffect(() => { + if (threadId || roomScrollToBottomCount <= 0 || roomTimelineLatestVirtualIndex < 0) return; + + roomTimelineVirtualizer.scrollToIndex(roomTimelineLatestVirtualIndex, { + align: roomOverviewOrderActive ? 'start' : 'end', + behavior: scrollToBottomRef.current.smooth ? 'smooth' : 'auto', + }); + setAtBottom(true); + }, [ + roomOverviewOrderActive, + roomScrollToBottomCount, + roomTimelineLatestVirtualIndex, + roomTimelineVirtualizer, + scrollToBottomRef, + setAtBottom, + threadId, + ]); + const scrollToTimelineItem = useCallback( + (index: number, opts?: Parameters[1]) => { + if (threadId || index < activeTimelineRange.start || index >= activeTimelineRange.end) { + return scrollToItem(index, opts); + } + + if (getTimelineItemElement(index)) { + return scrollToItem(index, opts); + } + + roomTimelineVirtualizer.scrollToIndex(index - activeTimelineRange.start, { + align: opts?.align ?? 'start', + behavior: opts?.behavior === 'smooth' ? 'smooth' : 'auto', + }); + return true; + }, + [ + activeTimelineRange.end, + activeTimelineRange.start, + getTimelineItemElement, + roomTimelineVirtualizer, + scrollToItem, + threadId, + ] + ); + + const { handleOpenEvent, redirectRoomEventDeepLink } = useRoomEventOpenController({ + alive, + effectiveViewMode, + focusEventInRoom, + hideMembershipEvents, + hideNickAvatarEvents, + ignoredUsersSet, + mx, + room, + navigateRoomThread, + overviewThreadRootIds, + pendingThreadOpenRef, + readUpToTs, + readUptoEventIdRef, + recalibrateFilterOptsRef, + roomOverviewOrderActive, + roomThreadListThreads, + safePaginationLimit, + safePaginationLimitRef, + scheduledStatusMap, + scrollRef, + scrollToBottomRef, + scrollToElement, + scrollToItem: scrollToTimelineItem, + searchQuery: threadIndexSearchQuery, + setFocusItem, + setPendingThreadOpenTick, + setThreadTimelineTick, + setTimeline, + showHiddenEvents, + threadEventIndexMapRef, + threadFilteredEvents, + threadFilterStateRef, + threadId, + threadParticipantMap, + threadReplyCountMap, + threadResolutionMap, + threadSortControlSignature, + threadSortFreezeState, + threadSummaryInfoMap, + }); + + useRoomLiveEventController({ + atBottomRef, + atLiveEndRef, + effectiveThreadFilterState, + hideActivity, + hideMembershipEvents, + hideNickAvatarEvents, + ignoredUsersSet, + liveExpandOnceIds, + mx, + normalThreadRecordMap, + onStoreThreadSummary, + persistRoomEventCache, + persistThreadCacheFromRoomEvents, + persistThreadEventCache, + queueRoomThreadCachePersist, + room, + roomDebugTraceId, + roomThreadFilterActive, + scrollRef, + scrollToBottomRef, + setSupplementalThreadEvents, + setThreadTailLoaded, + setThreadTimelineTick, + setTimeline, + setUnreadInfo, + showHiddenEvents, + threadEventIndexMapRef, + threadId, + threadResolutionMap, + timelineAtLiveEnd, + unreadInfo, + }); + + const buildRoomCacheHydratedTimeline = useCallback( + () => + getInitialTimeline(room, safePaginationLimitRef.current, { + threadId: undefined, + ignoredUsersSet: recalibrateFilterOptsRef.current?.ignoredUsersSet ?? new Set(), + showHiddenEvents: recalibrateFilterOptsRef.current?.showHiddenEvents ?? false, + hideMembershipEvents: recalibrateFilterOptsRef.current?.hideMembershipEvents ?? false, + hideNickAvatarEvents: recalibrateFilterOptsRef.current?.hideNickAvatarEvents ?? false, + showThreadRepliesInRoom: recalibrateFilterOptsRef.current?.showThreadRepliesInRoom, + }), + [room] + ); + + useRoomCacheHydrationController({ + alive, + buildInitialTimeline: buildRoomCacheHydratedTimeline, + eagerPreloadDoneForRoomRef, + eventId, + mx, + room, + roomDebugTraceId, + roomIdRef, + safePaginationLimit, + scrollToBottomRef, + sessionId, + setAtBottom, + setEagerPreloading, + setRoomInitialCacheHydratedKey, + setTimeline, + threadId, + threadIdRef, + }); + + useThreadAwareTimelineRefresh({ + room, + threadId, + liveTimelineLinked, + refreshLatestThreadSlice, + onRoomRefresh: useCallback(() => { + setTimeline( + getInitialTimeline(room, safePaginationLimit, { + threadId, + ignoredUsersSet, + showHiddenEvents, + hideMembershipEvents, + hideNickAvatarEvents, + showThreadRepliesInRoom, + }) + ); + }, [ + room, + threadId, + ignoredUsersSet, + showHiddenEvents, + hideMembershipEvents, + hideNickAvatarEvents, + showThreadRepliesInRoom, + safePaginationLimit, + ]), + }); + + // Stay at bottom when room editor resize + useResizeObserver( + useMemo(() => { + let mounted = false; + return (entries) => { + if (!mounted) { + // skip initial mounting call + mounted = true; + return; + } + if (!roomInputRef.current) return; + const editorBaseEntry = getResizeObserverEntry(roomInputRef.current, entries); + const scrollElement = getScrollElement(); + if (!editorBaseEntry || !scrollElement) return; + + if (atBottomRef.current) { + scrollToBottom(scrollElement); + } + }; + }, [getScrollElement, roomInputRef]), + useCallback(() => roomInputRef.current, [roomInputRef]) + ); + + const { handleMarkAsRead } = useTimelineReadReceiptController({ + atBottom, + atBottomAnchorRef, + atBottomRef, + atLiveEndRef, + getScrollElement, + handleOpenEvent, + hideActivity, + mx, + readUptoEventIdRef, + room, + setAtBottom, + threadEventsLength: threadEvents.length, + threadId, + threadInitialRenderMode, + threadTailLoaded, + timelineAtLiveEnd, + unreadInfo, + }); + + // Handle up arrow edit + useKeyDown( + window, + useCallback( + (evt) => { + if ( + isKeyHotkey('arrowup', evt) && + editableActiveElement() && + document.activeElement?.getAttribute('data-editable-name') === 'RoomInput' && + isEmptyEditor(editor) + ) { + const editableEvt = getLatestEditableEvt(room.getLiveTimeline(), (mEvt) => + canEditEvent(mx, mEvt) + ); + const editableEvtId = editableEvt?.getId(); + if (!editableEvtId) return; + setEditId(editableEvtId); + evt.preventDefault(); + } + }, + [mx, room, editor] + ) + ); + + useRoomEventRouteOpenController({ + effectiveViewMode, + eventId, + focusEventInRoom, + handleOpenEvent, + redirectRoomEventDeepLink, + roomId: room.roomId, + roomOverviewOrderActive, + threadId, + }); + + useThreadOpenLifecycleController({ + backfillThreadRelationsIntoCache, + ensureThreadSeedPrewarm, + eventId, + forceTimelineUpdate, + hydrateThreadFromCache, + mx, + onThreadLoadError, + pendingThreadOpenRef, + persistThreadEventCache, + prewarmedThreadSeedIdsRef, + prewarmingThreadSeedIdsRef, + prewarmingThreadSeedPromisesRef, + queuedThreadSeedIdsRef, + refreshLatestThreadRelationsTail, + refreshLatestThreadSlice, + resetThreadBackPagination, + resetThreadRenderState, + room, + roomTimelineSet, + scrollToBottomRef, + setAtBottom, + setFocusItem, + setPendingThreadOpenTick, + setSupplementalThreadEvents, + setThreadHasMoreCachedBack, + setThreadInitialCacheHydrated, + setThreadLatestOpenPending, + setThreadLoadError, + setThreadPaginatingFront, + setThreadTailLoaded, + setThreadTimelineTick, + setTimeline, + suppressThreadOpenBottomPinRef, + threadDebugTraceId, + threadEditFetchAttemptedRef, + threadId, + threadIdRef, + }); + + useRoomFocusScrollController({ + alive, + atBottomAnchorRef, + editId, + focusItem, + focusScrollResetToken: effectiveThreadFilterState, + pendingThreadOpenRef, + pendingThreadOpenTick, + restorePendingThreadBackPaginationAnchor, + retryPagination, + roomId: room.roomId, + scrollRef, + scrollToBottomRef, + scrollToElement, + scrollToItem: scrollToTimelineItem, + setAtBottom, + setFocusItem, + setPendingThreadOpenTick, + suppressFocusPaginationRef, + suppressThreadOpenBottomPinRef, + threadEventIndexMapRef, + threadEventsLength: threadEvents.length, + threadFilteredEvents, + threadFilteredEventsRef, + threadId, + threadInitialRenderMode, + threadLatestOpenPending, + threadTimelineTick, + timelineAtLiveEnd, + unreadInfo, + unreadScrollAnchorIndex, + }); + + // Remove unreadInfo on mark as read + useEffect(() => { + if (!unread) { + setUnreadInfo(undefined); + } + }, [unread]); + + const { handleJumpToLatest, handleJumpToUnread, handleOpenCompactThread, handleOpenReply } = + useRoomTimelineNavigationController({ + classicRoomTimeline: showThreadRepliesInRoom, + eventId, + handleOpenEvent, + hideMembershipEvents, + hideNickAvatarEvents, + ignoredUsersSet, + navigateRoom, + navigateRoomThread, + refreshLatestThreadSlice, + room, + safePaginationLimit, + scrollRef, + scrollToBottomRef, + setAtBottom, + setTimeline, + showHiddenEvents, + showThreadRepliesInRoom, + threadId, + threadIdRef, + unreadInfo, + }); + + const handleUserClick: MouseEventHandler = useCallback( + (evt) => { + evt.preventDefault(); + evt.stopPropagation(); + const userId = evt.currentTarget.getAttribute('data-user-id'); + if (!userId) { + return; + } + openUserRoomProfile( + room.roomId, + space?.roomId, + userId, + evt.currentTarget.getBoundingClientRect() + ); + }, + [room, space, openUserRoomProfile] + ); + const handleUsernameClick: MouseEventHandler = useCallback( + (evt) => { + evt.preventDefault(); + const userId = evt.currentTarget.getAttribute('data-user-id'); + if (!userId) { + return; + } + const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId; + editor.insertNode( + createMentionElement( + userId, + name.startsWith('@') ? name : `@${name}`, + userId === mx.getUserId() + ) + ); + ReactEditor.focus(editor); + moveCursor(editor); + }, + [mx, room, editor] + ); + + const handleReplyClick: MouseEventHandler = useCallback( + (evt, startThread = false) => { + const replyId = evt.currentTarget.getAttribute('data-event-id'); + if (!replyId) { + return; + } + const shouldStartThread = startThread && !showThreadRepliesInRoom; + const replyDraft = buildMindroomRoomTimelineReplyDraft(room, replyId, shouldStartThread); + if (replyDraft) { + setReplyDraft(replyDraft.draft); + if (shouldStartThread) { + navigateRoomThread(room.roomId, replyDraft.threadRootId); + } + setTimeout(() => ReactEditor.focus(editor), 100); + } + }, + [room, showThreadRepliesInRoom, setReplyDraft, editor, navigateRoomThread] + ); + + const handleReactionToggle = useCallback( + (targetEventId: string, key: string, shortcode?: string, currentRelations?: Relations) => { + const reactionRelations = + currentRelations ?? getEventReactions(room.getUnfilteredTimelineSet(), targetEventId); + const reactions = getActiveEventsForAnnotationKey(reactionRelations, key); + const myReaction = reactions.find(factoryEventSentBy(mx.getUserId()!)); + + if (myReaction && !!myReaction?.isRelation()) { + mx.redactEvent(room.roomId, myReaction.getId()!); + return; + } + const rShortcode = + shortcode || + (reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined); + mx.sendEvent( + room.roomId, + MessageEvent.Reaction as any, + getReactionContent(targetEventId, key, rShortcode) + ); + }, + [mx, room] + ); + const handleEdit = useCallback( + (editEvtId?: string) => { + if (editEvtId) { + setEditId(editEvtId); + return; + } + setEditId(undefined); + ReactEditor.focus(editor); + }, + [editor] + ); + const { t } = useTranslation(); + + useThreadSummaryPublishController({ + onStoreThreadSummary, + thread, + threadEvents, + threadId, + threadSummaryInfoMap, + }); + + useThreadOverviewResumeController({ + alive, + activeTimelineRange, + compactFilteredThreadRootIds, + compactViewRequested, + debugTraceId: roomDebugTraceId, + filteredThreadRootIds, + limit: THREAD_OVERVIEW_METADATA_CACHE_LIMIT, + mx, + onApplyThreadRelations: applyThreadOverviewRelationEvents, + onStoreThreadSummary, + persistThreadEventCache, + refreshCompactThreadList: refreshRoomThreadList, + room, + setOverviewRefreshCounter, + showCompactRoomView, + threadFilteredEventEntries, + threadId, + threadIdRef, + threadReplyCountMap, + threadResolutionMap, + }); + + const renderMatrixEvent = useMatrixEventRenderer< + [string, MatrixEvent, number, EventTimelineSet, boolean] + >( + { + [MessageEvent.RoomMessage]: (mEventId, mEvent, item, timelineSet, collapse) => { + const reactionRelations = getEventReactions(timelineSet, mEventId); + const hasReactions = getActiveAnnotationsByKey(reactionRelations).length > 0; + const { replyEventId, threadRootId } = mEvent; + const highlighted = focusItem?.index === item && focusItem.highlight; + + const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet); + const resolvedContent = getLatestMessageContent(mEvent, editedEvent); + const getContent = (() => resolvedContent) as GetContentCallback; + const collapseMode = getCollapsibleMessageMode( + mEventId, + resolvedContent, + liveExpandOnceIds.current, + hydratedLongTextExtrasCollapseKeys + ); + const hydratedLongTextExtrasCollapseKey = getHydratedLongTextExtrasCollapseKey( + mEventId, + resolvedContent + ); + const forceCollapsibleOverflow = shouldForceCollapsibleMessageOverflow(resolvedContent); + const onInitialExpandConsumed = + collapseMode === 'initially-expanded' + ? () => { + consumeLiveExpandOnceId(liveExpandOnceIds.current, mEventId); + } + : undefined; + + const senderId = mEvent.getSender() ?? ''; + const senderDisplayName = + getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId; + const threadSummary = showThreadBadgesInRoom + ? renderMindroomRoomTimelineThreadBadge({ + eventId: mEventId, + event: mEvent, + threadRecordMap, + activeThreadId: threadId, + room, + onClick: handleOpenReply, + includeRecentSummaryData: true, + }) + : null; + + return ( + threadEventMap.get(replyEventId) : undefined} + hideThreadIndicator={!!threadId || showThreadRepliesInRoom} + onClick={handleOpenReply} + getMemberPowerTag={getMemberPowerTag} + accessibleTagColors={accessiblePowerTagColors} + legacyUsernameColor={legacyUsernameColor || direct} + /> + ) + } + reactions={ + (threadSummary || reactionRelations) && ( + <> + {threadSummary} + {reactionRelations && ( + + )} + + ) + } + hideReadReceipts={hideActivity} + showDeveloperTools={showDeveloperTools} + memberPowerTag={getMemberPowerTag(senderId)} + accessibleTagColors={accessiblePowerTagColors} + legacyUsernameColor={legacyUsernameColor || direct} + hour24Clock={hour24Clock} + dateFormatString={dateFormatString} + > + {(() => { + if (mEvent.isRedacted()) { + return ( + + ); + } + const msgType = mEvent.getContent().msgtype; + const isVisualMedia = msgType === MsgType.Image || msgType === MsgType.Video; + const renderContent = (hydrateLongText = true) => ( + + markHydratedLongTextExtrasCollapsedExempt( + hydratedLongTextExtrasCollapseKey + ) + : undefined + } + /> + ); + const content = renderContent(); + const measurementKey = getCollapsibleMessageMeasurementKey( + mEvent, + collapseMode, + editedEvent + ); + if (isVisualMedia) return content; + return ( + + {({ expanded }) => renderContent(expanded)} + + ); + })()} + + ); + }, + ...getMindroomRoomTimelineMessageRenderers( + (mEventId, mEvent, item, timelineSet, collapse) => { + const reactionRelations = getEventReactions(timelineSet, mEventId); + const hasReactions = getActiveAnnotationsByKey(reactionRelations).length > 0; + const { replyEventId, threadRootId } = mEvent; + const highlighted = focusItem?.index === item && focusItem.highlight; + const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet); + const approvalContent = + getMindroomRoomTimelineApprovalContentIfSupported(mEvent, editedEvent) ?? + mEvent.getContent(); + const getContent = (() => approvalContent) as GetContentCallback; + const senderId = mEvent.getSender() ?? ''; + const senderDisplayName = + getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId; + const threadSummary = showThreadBadgesInRoom + ? renderMindroomRoomTimelineThreadBadge({ + eventId: mEventId, + event: mEvent, + threadRecordMap, + activeThreadId: threadId, + room, + onClick: handleOpenReply, + }) + : null; + + return ( + threadEventMap.get(replyEventId) : undefined} + hideThreadIndicator={!!threadId || showThreadRepliesInRoom} + onClick={handleOpenReply} + getMemberPowerTag={getMemberPowerTag} + accessibleTagColors={accessiblePowerTagColors} + legacyUsernameColor={legacyUsernameColor || direct} + /> + ) + } + reactions={ + (threadSummary || reactionRelations) && ( + <> + {threadSummary} + {reactionRelations && ( + + )} + + ) + } + hideReadReceipts={hideActivity} + showDeveloperTools={showDeveloperTools} + memberPowerTag={getMemberPowerTag(senderId)} + accessibleTagColors={accessiblePowerTagColors} + legacyUsernameColor={legacyUsernameColor || direct} + hour24Clock={hour24Clock} + dateFormatString={dateFormatString} + > + {mEvent.isRedacted() ? ( + + ) : ( + + )} + + ); + } + ), + [MessageEvent.RoomMessageEncrypted]: (mEventId, mEvent, item, timelineSet, collapse) => { + const reactionRelations = getEventReactions(timelineSet, mEventId); + const hasReactions = getActiveAnnotationsByKey(reactionRelations).length > 0; + const { replyEventId, threadRootId } = mEvent; + const highlighted = focusItem?.index === item && focusItem.highlight; + const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet); + const resolvedContent = getLatestMessageContent(mEvent, editedEvent); + const threadSummary = showThreadBadgesInRoom + ? renderMindroomRoomTimelineThreadBadge({ + eventId: mEventId, + event: mEvent, + threadRecordMap, + activeThreadId: threadId, + room, + onClick: handleOpenReply, + }) + : null; + + return ( + threadEventMap.get(replyEventId) : undefined} + hideThreadIndicator={!!threadId || showThreadRepliesInRoom} + onClick={handleOpenReply} + getMemberPowerTag={getMemberPowerTag} + accessibleTagColors={accessiblePowerTagColors} + legacyUsernameColor={legacyUsernameColor || direct} + /> + ) + } + reactions={ + (threadSummary || reactionRelations) && ( + <> + {threadSummary} + {reactionRelations && ( + + )} + + ) + } + hideReadReceipts={hideActivity} + showDeveloperTools={showDeveloperTools} + memberPowerTag={getMemberPowerTag(mEvent.getSender() ?? '')} + accessibleTagColors={accessiblePowerTagColors} + legacyUsernameColor={legacyUsernameColor || direct} + hour24Clock={hour24Clock} + dateFormatString={dateFormatString} + > + + {() => { + if (mEvent.isRedacted()) return ; + if (mEvent.getType() === MessageEvent.Sticker) + return ( + ( + } + renderViewer={(p) => } + /> + )} + /> + ); + const approvalContent = getMindroomRoomTimelineApprovalContentIfSupported( + mEvent, + editedEvent + ); + if (approvalContent) { + const getContent = (() => approvalContent) as GetContentCallback; + const senderId = mEvent.getSender() ?? ''; + const senderDisplayName = + getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId; + + return ( + + ); + } + if (mEvent.getType() === MessageEvent.RoomMessage) { + const getContent = (() => resolvedContent) as GetContentCallback; + const collapseMode = getCollapsibleMessageMode( + mEventId, + resolvedContent, + liveExpandOnceIds.current, + hydratedLongTextExtrasCollapseKeys + ); + const hydratedLongTextExtrasCollapseKey = getHydratedLongTextExtrasCollapseKey( + mEventId, + resolvedContent + ); + const forceCollapsibleOverflow = + shouldForceCollapsibleMessageOverflow(resolvedContent); + const measurementKey = getCollapsibleMessageMeasurementKey( + mEvent, + collapseMode, + editedEvent + ); + const onInitialExpandConsumed = + collapseMode === 'initially-expanded' + ? () => { + consumeLiveExpandOnceId(liveExpandOnceIds.current, mEventId); + } + : undefined; + + const senderId = mEvent.getSender() ?? ''; + const senderDisplayName = + getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId; + const renderMessageContent = (hydrateLongText = true) => ( + + markHydratedLongTextExtrasCollapsedExempt( + hydratedLongTextExtrasCollapseKey + ) + : undefined + } + /> + ); + const messageContent = renderMessageContent(); + + const encMsgType = mEvent.getContent().msgtype; + const isEncVisualMedia = + encMsgType === MsgType.Image || encMsgType === MsgType.Video; + if (isEncVisualMedia) return messageContent; + return ( + + {({ expanded }) => renderMessageContent(expanded)} + + ); + } + if (mEvent.getType() === MessageEvent.RoomMessageEncrypted) + return ( + + + + ); + return ( + + + + ); + }} + + + ); + }, + [MessageEvent.Sticker]: (mEventId, mEvent, item, timelineSet, collapse) => { + const reactionRelations = getEventReactions(timelineSet, mEventId); + const hasReactions = getActiveAnnotationsByKey(reactionRelations).length > 0; + const highlighted = focusItem?.index === item && focusItem.highlight; + + return ( + + ) + } + hideReadReceipts={hideActivity} + showDeveloperTools={showDeveloperTools} + memberPowerTag={getMemberPowerTag(mEvent.getSender() ?? '')} + accessibleTagColors={accessiblePowerTagColors} + legacyUsernameColor={legacyUsernameColor || direct} + hour24Clock={hour24Clock} + dateFormatString={dateFormatString} + > + {mEvent.isRedacted() ? ( + + ) : ( + ( + } + renderViewer={(p) => } + /> + )} + /> + )} + + ); + }, + [StateEvent.RoomMember]: (mEventId, mEvent, item) => { + const membershipChanged = isMembershipChanged(mEvent); + if (membershipChanged && hideMembershipEvents) return null; + if (!membershipChanged && hideNickAvatarEvents) return null; + + const highlighted = focusItem?.index === item && focusItem.highlight; + const parsed = parseMemberEvent(mEvent); + + const timeJSX = ( + + } + /> + + ); + }, + [StateEvent.RoomName]: (mEventId, mEvent, item) => { + const highlighted = focusItem?.index === item && focusItem.highlight; + const senderId = mEvent.getSender() ?? ''; + const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId); + + const timeJSX = ( +