diff --git a/.github/renovate.json b/.github/renovate.json index 46ce4fdf..62b0cf2a 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -1,15 +1,14 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": [ - "config:base", - ":dependencyDashboardApproval" - ], - "labels": [ "Dependencies" ], + "extends": ["config:recommended", ":dependencyDashboardApproval"], + "labels": ["Dependencies"], "packageRules": [ { - "matchUpdateTypes": [ "lockFileMaintenance" ] + "matchUpdateTypes": ["lockFileMaintenance"] } ], - "lockFileMaintenance": { "enabled": true }, + "lockFileMaintenance": { + "enabled": true + }, "dependencyDashboard": true -} \ No newline at end of file +} diff --git a/.github/workflows/build-pull-request.yml b/.github/workflows/build-pull-request.yml index 60822231..cd07e0c2 100644 --- a/.github/workflows/build-pull-request.yml +++ b/.github/workflows/build-pull-request.yml @@ -25,7 +25,7 @@ jobs: NODE_OPTIONS: '--max_old_space_size=4096' run: npm run build - name: Upload artifact - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: preview path: dist @@ -33,7 +33,7 @@ jobs: - name: Save pr number run: echo ${PR_NUMBER} > ./pr.txt - name: Upload pr number - uses: actions/upload-artifact@v4.6.0 + uses: actions/upload-artifact@v4.6.1 with: name: pr path: ./pr.txt diff --git a/.github/workflows/docker-pr.yml b/.github/workflows/docker-pr.yml index 7e8e99f3..4e88c78d 100644 --- a/.github/workflows/docker-pr.yml +++ b/.github/workflows/docker-pr.yml @@ -13,7 +13,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v4.2.0 - name: Build Docker image - uses: docker/build-push-action@v6.13.0 + uses: docker/build-push-action@v6.15.0 with: context: . push: false diff --git a/.github/workflows/prod-deploy.yml b/.github/workflows/prod-deploy.yml index ee0561da..9a9dd7c5 100644 --- a/.github/workflows/prod-deploy.yml +++ b/.github/workflows/prod-deploy.yml @@ -68,9 +68,9 @@ jobs: - name: Checkout repository uses: actions/checkout@v4.2.0 - name: Set up QEMU - uses: docker/setup-qemu-action@v3.4.0 + uses: docker/setup-qemu-action@v3.6.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3.9.0 + uses: docker/setup-buildx-action@v3.10.0 - name: Login to Docker Hub uses: docker/login-action@v3.3.0 with: @@ -84,13 +84,13 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata (tags, labels) for Docker id: meta - uses: docker/metadata-action@v5.6.1 + uses: docker/metadata-action@v5.7.0 with: images: | ${{ secrets.DOCKER_USERNAME }}/cinny ghcr.io/${{ github.repository }} - name: Build and push Docker image - uses: docker/build-push-action@v6.13.0 + uses: docker/build-push-action@v6.15.0 with: context: . platforms: linux/amd64,linux/arm64 diff --git a/README.md b/README.md index 5cee6fa2..427898f9 100644 --- a/README.md +++ b/README.md @@ -19,27 +19,22 @@ A Matrix client focusing primarily on simple, elegant and secure interface. The ## Getting started -* Web app is available at https://app.cinny.in and gets updated on each new release. The `dev` branch is continuously deployed at https://dev.cinny.in but keep in mind that it could have things broken. +The web app is available at [app.cinny.in](https://app.cinny.in/) and gets updated on each new release. The `dev` branch is continuously deployed at [dev.cinny.in](https://dev.cinny.in) but keep in mind that it could have things broken. -* You can also download our desktop app from [cinny-desktop repository](https://github.com/cinnyapp/cinny-desktop). +You can also download our desktop app from the [cinny-desktop repository](https://github.com/cinnyapp/cinny-desktop). -* To host Cinny on your own, download tarball of the app from [GitHub release](https://github.com/cinnyapp/cinny/releases/latest). -You can serve the application with a webserver of your choice by simply copying `dist/` directory to the webroot. -To set default Homeserver on login, register and Explore Community page, place a customized [`config.json`](config.json) in webroot of your choice. -You will also need to setup redirects to serve the assests. An example setting of redirects for netlify is done in [`netlify.toml`](netlify.toml). You can also set `hashRouter.enabled = true` in [`config.json`](config.json) if you have trouble setting redirects. -To deploy on subdirectory, you need to rebuild the app youself after updating the `base` path in [`build.config.ts`](build.config.ts). For example, if you want to deploy on `https://cinny.in/app`, then change `base: '/app'`. +## Self-hosting +To host Cinny on your own, simply download the tarball from [GitHub releases](https://github.com/cinnyapp/cinny/releases/latest), and serve the files from `dist/` using your preferred webserver. Alternatively, you can just pull the docker image from [DockerHub](https://hub.docker.com/r/ajbura/cinny) or [GitHub Container Registry](https://github.com/cinnyapp/cinny/pkgs/container/cinny). -* Alternatively you can just pull the [DockerHub image](https://hub.docker.com/r/ajbura/cinny) by: - ``` - docker pull ajbura/cinny - ``` - or [ghcr image](https://github.com/cinnyapp/cinny/pkgs/container/cinny) by: - ``` - docker pull ghcr.io/cinnyapp/cinny:latest - ``` +* The default homeservers and explore pages are defined in [`config.json`](config.json). -
-PGP Public Key to verify tarball +* You need to set up redirects to serve the assests. Example configurations; [netlify](netlify.toml), [nginx](contrib/nginx/cinny.domain.tld.conf), [caddy](contrib/caddy/caddyfile). + * If you have trouble configuring redirects you can [enable hash routing](config.json#L35) — the url in the browser will have a `/#/` between the domain and open channel (ie. `app.cinny.in/#/home/` instead of `app.cinny.in/home/`) but you won't have to configure your webserver. + +* To deploy on subdirectory, you need to rebuild the app youself after updating the `base` path in [`build.config.ts`](build.config.ts). + * For example, if you want to deploy on `https://cinny.in/app`, then set `base: '/app'`. + +
PGP Public Key to verify tarball ``` -----BEGIN PGP PUBLIC KEY BLOCK----- @@ -87,8 +82,8 @@ mxFo+ioe/ABCufSmyqFye0psX3Sp
## Local development -> We recommend using a version manager as versions change very quickly. You will likely need to switch -between multiple Node.js versions based on the needs of different projects you're working on. [NVM on windows](https://github.com/coreybutler/nvm-windows#installation--upgrades) on Windows and [nvm](https://github.com/nvm-sh/nvm) on Linux/macOS are pretty good choices. Recommended nodejs version is Iron LTS (v20). +> [!TIP] +> We recommend using a version manager as versions change very quickly. You will likely need to switch between multiple Node.js versions based on the needs of different projects you're working on. [NVM on windows](https://github.com/coreybutler/nvm-windows#installation--upgrades) on Windows and [nvm](https://github.com/nvm-sh/nvm) on Linux/macOS are pretty good choices. Recommended nodejs version is Iron LTS (v20). Execute the following commands to start a development server: ```sh diff --git a/contrib/caddy/caddyfile b/contrib/caddy/caddyfile new file mode 100644 index 00000000..e55efe92 --- /dev/null +++ b/contrib/caddy/caddyfile @@ -0,0 +1,6 @@ +cinny.domain.tld { + @nativeRouter not file {path} / + rewrite @nativeRouter {http.matchers.file.relative} + root * /path/to/caddy/dist + file_server +} diff --git a/contrib/nginx/cinny.domain.tld.conf b/contrib/nginx/cinny.domain.tld.conf index 0ba70f7e..0b6c8aad 100644 --- a/contrib/nginx/cinny.domain.tld.conf +++ b/contrib/nginx/cinny.domain.tld.conf @@ -1,35 +1,35 @@ server { - listen 80; - listen [::]:80; - server_name cinny.domain.tld; + listen 80; + listen [::]:80; + server_name cinny.domain.tld; - location / { - return 301 https://$host$request_uri; - } + location / { + return 301 https://$host$request_uri; + } - location /.well-known/acme-challenge/ { - alias /var/lib/letsencrypt/.well-known/acme-challenge/; - } + location /.well-known/acme-challenge/ { + alias /var/lib/letsencrypt/.well-known/acme-challenge/; + } } server { - listen 443 ssl http2; - listen [::]:443 ssl; - server_name cinny.domain.tld; + listen 443 ssl http2; + listen [::]:443 ssl; + server_name cinny.domain.tld; - location / { - root /opt/cinny/dist/; + location / { + root /opt/cinny/dist/; - rewrite ^/config.json$ /config.json break; - rewrite ^/manifest.json$ /manifest.json break; + rewrite ^/config.json$ /config.json break; + rewrite ^/manifest.json$ /manifest.json break; - rewrite ^.*/olm.wasm$ /olm.wasm break; - rewrite ^/sw.js$ /sw.js break; - rewrite ^/pdf.worker.min.js$ /pdf.worker.min.js break; + rewrite ^.*/olm.wasm$ /olm.wasm break; + rewrite ^/sw.js$ /sw.js break; + rewrite ^/pdf.worker.min.js$ /pdf.worker.min.js break; - rewrite ^/public/(.*)$ /public/$1 break; - rewrite ^/assets/(.*)$ /assets/$1 break; + rewrite ^/public/(.*)$ /public/$1 break; + rewrite ^/assets/(.*)$ /assets/$1 break; - rewrite ^(.+)$ /index.html break; - } + rewrite ^(.+)$ /index.html break; + } } diff --git a/package-lock.json b/package-lock.json index 7bed23c1..b173bc70 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cinny", - "version": "4.3.2", + "version": "4.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cinny", - "version": "4.3.2", + "version": "4.5.1", "license": "AGPL-3.0-only", "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "1.1.6", @@ -62,9 +62,10 @@ "react-range": "1.8.14", "react-router-dom": "6.20.0", "sanitize-html": "2.12.1", - "slate": "0.94.1", - "slate-history": "0.93.0", - "slate-react": "0.98.4", + "slate": "0.112.0", + "slate-dom": "0.112.2", + "slate-history": "0.110.3", + "slate-react": "0.112.1", "tippy.js": "6.3.7", "ua-parser-js": "1.0.35" }, @@ -73,6 +74,7 @@ "@rollup/plugin-inject": "5.0.3", "@rollup/plugin-wasm": "6.1.1", "@types/file-saver": "2.0.5", + "@types/is-hotkey": "0.1.10", "@types/node": "18.11.18", "@types/prismjs": "1.26.0", "@types/react": "18.2.39", @@ -4598,7 +4600,9 @@ "node_modules/@types/is-hotkey": { "version": "0.1.10", "resolved": "https://registry.npmjs.org/@types/is-hotkey/-/is-hotkey-0.1.10.tgz", - "integrity": "sha512-RvC8KMw5BCac1NvRRyaHgMMEtBaZ6wh0pyPTBu7izn4Sj/AX9Y4aXU5c7rX8PnM/knsuUpC1IeoBkANtxBypsQ==" + "integrity": "sha512-RvC8KMw5BCac1NvRRyaHgMMEtBaZ6wh0pyPTBu7izn4Sj/AX9Y4aXU5c7rX8PnM/knsuUpC1IeoBkANtxBypsQ==", + "dev": true, + "license": "MIT" }, "node_modules/@types/json-schema": { "version": "7.0.15", @@ -4612,11 +4616,6 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, - "node_modules/@types/lodash": { - "version": "4.17.14", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.14.tgz", - "integrity": "sha512-jsxagdikDiDBeIRaPYtArcT8my4tN1og7MtMRquFT3XNA6axxyHDRUemqDz/taRDdOUn0GnGHRCuff4q48sW9A==" - }, "node_modules/@types/node": { "version": "18.11.18", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.18.tgz", @@ -5797,9 +5796,10 @@ } }, "node_modules/compute-scroll-into-view": { - "version": "1.0.20", - "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz", - "integrity": "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==" + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", + "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==", + "license": "MIT" }, "node_modules/computed-style": { "version": "0.1.4", @@ -8145,7 +8145,8 @@ "node_modules/is-hotkey": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/is-hotkey/-/is-hotkey-0.2.0.tgz", - "integrity": "sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw==" + "integrity": "sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw==", + "license": "MIT" }, "node_modules/is-map": { "version": "2.0.3", @@ -10225,11 +10226,12 @@ } }, "node_modules/scroll-into-view-if-needed": { - "version": "2.2.31", - "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.31.tgz", - "integrity": "sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz", + "integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==", + "license": "MIT", "dependencies": { - "compute-scroll-into-view": "^1.0.20" + "compute-scroll-into-view": "^3.0.2" } }, "node_modules/sdp-transform": { @@ -10458,19 +10460,39 @@ } }, "node_modules/slate": { - "version": "0.94.1", - "resolved": "https://registry.npmjs.org/slate/-/slate-0.94.1.tgz", - "integrity": "sha512-GH/yizXr1ceBoZ9P9uebIaHe3dC/g6Plpf9nlUwnvoyf6V1UOYrRwkabtOCd3ZfIGxomY4P7lfgLr7FPH8/BKA==", + "version": "0.112.0", + "resolved": "https://registry.npmjs.org/slate/-/slate-0.112.0.tgz", + "integrity": "sha512-PRnfFgDA3tSop4OH47zu4M1R4Uuhm/AmASu29Qp7sGghVFb713kPBKEnSf1op7Lx/nCHkRlCa3ThfHtCBy+5Yw==", + "license": "MIT", "dependencies": { - "immer": "^9.0.6", + "immer": "^10.0.3", "is-plain-object": "^5.0.0", "tiny-warning": "^1.0.3" } }, + "node_modules/slate-dom": { + "version": "0.112.2", + "resolved": "https://registry.npmjs.org/slate-dom/-/slate-dom-0.112.2.tgz", + "integrity": "sha512-cozITMlpcBxrov854reM6+TooiHiqpfM/nZPrnjpN1wSiDsAQmYbWUyftC+jlwcpFj80vywfDHzlG6hXIc5h6A==", + "license": "MIT", + "dependencies": { + "@juggle/resize-observer": "^3.4.0", + "direction": "^1.0.4", + "is-hotkey": "^0.2.0", + "is-plain-object": "^5.0.0", + "lodash": "^4.17.21", + "scroll-into-view-if-needed": "^3.1.0", + "tiny-invariant": "1.3.1" + }, + "peerDependencies": { + "slate": ">=0.99.0" + } + }, "node_modules/slate-history": { - "version": "0.93.0", - "resolved": "https://registry.npmjs.org/slate-history/-/slate-history-0.93.0.tgz", - "integrity": "sha512-Gr1GMGPipRuxIz41jD2/rbvzPj8eyar56TVMyJBvBeIpQSSjNISssvGNDYfJlSWM8eaRqf6DAcxMKzsLCYeX6g==", + "version": "0.110.3", + "resolved": "https://registry.npmjs.org/slate-history/-/slate-history-0.110.3.tgz", + "integrity": "sha512-sgdff4Usdflmw5ZUbhDkxFwCBQ2qlDKMMkF93w66KdV48vHOgN2BmLrf+2H8SdX8PYIpP/cTB0w8qWC2GwhDVA==", + "license": "MIT", "dependencies": { "is-plain-object": "^5.0.0" }, @@ -10479,30 +10501,35 @@ } }, "node_modules/slate-react": { - "version": "0.98.4", - "resolved": "https://registry.npmjs.org/slate-react/-/slate-react-0.98.4.tgz", - "integrity": "sha512-8Of3v9hFuX8rIRc86LuuBhU9t8ps+9ARKL4yyhCrKQYZ93Ep/LFA3GvPGvtf3zYuVadZ8tkhRH8tbHOGNAndLw==", + "version": "0.112.1", + "resolved": "https://registry.npmjs.org/slate-react/-/slate-react-0.112.1.tgz", + "integrity": "sha512-V9b+waxPweXqAkSQmKQ1afG4Me6nVQACPpxQtHPIX02N7MXa5f5WilYv+bKt7vKKw+IZC2F0Gjzhv5BekVgP/A==", + "license": "MIT", "dependencies": { "@juggle/resize-observer": "^3.4.0", - "@types/is-hotkey": "^0.1.1", - "@types/lodash": "^4.14.149", - "direction": "^1.0.3", - "is-hotkey": "^0.1.6", + "direction": "^1.0.4", + "is-hotkey": "^0.2.0", "is-plain-object": "^5.0.0", - "lodash": "^4.17.4", - "scroll-into-view-if-needed": "^2.2.20", - "tiny-invariant": "1.0.6" + "lodash": "^4.17.21", + "scroll-into-view-if-needed": "^3.1.0", + "tiny-invariant": "1.3.1" }, "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0", - "slate": ">=0.65.3" + "react": ">=18.2.0", + "react-dom": ">=18.2.0", + "slate": ">=0.99.0", + "slate-dom": ">=0.110.2" } }, - "node_modules/slate-react/node_modules/is-hotkey": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/is-hotkey/-/is-hotkey-0.1.8.tgz", - "integrity": "sha512-qs3NZ1INIS+H+yeo7cD9pDfwYV/jqRh1JG9S9zYrNudkoUQg7OL7ziXqRKu+InFjUIDoP2o6HIkLYMh1pcWgyQ==" + "node_modules/slate/node_modules/immer": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz", + "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } }, "node_modules/smob": { "version": "1.5.0", @@ -10866,9 +10893,10 @@ "dev": true }, "node_modules/tiny-invariant": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.0.6.tgz", - "integrity": "sha512-FOyLWWVjG+aC0UqG76V53yAWdXfH8bO6FNmyZOuUrzDzK8DI3/JRY25UD7+g49JWM1LXwymsKERB+DzI0dTEQA==" + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.1.tgz", + "integrity": "sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==", + "license": "MIT" }, "node_modules/tiny-warning": { "version": "1.0.3", diff --git a/package.json b/package.json index c785d8fb..aea713ed 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cinny", - "version": "4.3.2", + "version": "4.5.1", "description": "Yet another matrix client", "main": "index.js", "type": "module", @@ -73,9 +73,10 @@ "react-range": "1.8.14", "react-router-dom": "6.20.0", "sanitize-html": "2.12.1", - "slate": "0.94.1", - "slate-history": "0.93.0", - "slate-react": "0.98.4", + "slate": "0.112.0", + "slate-dom": "0.112.2", + "slate-history": "0.110.3", + "slate-react": "0.112.1", "tippy.js": "6.3.7", "ua-parser-js": "1.0.35" }, @@ -84,6 +85,7 @@ "@rollup/plugin-inject": "5.0.3", "@rollup/plugin-wasm": "6.1.1", "@types/file-saver": "2.0.5", + "@types/is-hotkey": "0.1.10", "@types/node": "18.11.18", "@types/prismjs": "1.26.0", "@types/react": "18.2.39", diff --git a/src/app/components/editor/autocomplete/AutocompleteMenu.tsx b/src/app/components/editor/autocomplete/AutocompleteMenu.tsx index 5d2d917c..452fa1b4 100644 --- a/src/app/components/editor/autocomplete/AutocompleteMenu.tsx +++ b/src/app/components/editor/autocomplete/AutocompleteMenu.tsx @@ -5,6 +5,7 @@ import { Header, Menu, Scroll, config } from 'folds'; import * as css from './AutocompleteMenu.css'; import { preventScrollWithArrowKey, stopPropagation } from '../../../utils/keyboard'; +import { useAlive } from '../../../hooks/useAlive'; type AutocompleteMenuProps = { requestClose: () => void; @@ -12,13 +13,22 @@ type AutocompleteMenuProps = { children: ReactNode; }; export function AutocompleteMenu({ headerContent, requestClose, children }: AutocompleteMenuProps) { + const alive = useAlive(); + + const handleDeactivate = () => { + if (alive()) { + // The component is unmounted so we will not call for `requestClose` + requestClose(); + } + }; + return (
requestClose(), + onPostDeactivate: handleDeactivate, returnFocusOnDeactivate: false, clickOutsideDeactivates: true, allowOutsideClick: true, diff --git a/src/app/components/editor/autocomplete/EmoticonAutocomplete.tsx b/src/app/components/editor/autocomplete/EmoticonAutocomplete.tsx index 9479a698..cc0dff19 100644 --- a/src/app/components/editor/autocomplete/EmoticonAutocomplete.tsx +++ b/src/app/components/editor/autocomplete/EmoticonAutocomplete.tsx @@ -6,11 +6,7 @@ import { Room } from 'matrix-js-sdk'; import { AutocompleteQuery } from './autocompleteQuery'; import { AutocompleteMenu } from './AutocompleteMenu'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { - SearchItemStrGetter, - UseAsyncSearchOptions, - useAsyncSearch, -} from '../../../hooks/useAsyncSearch'; +import { UseAsyncSearchOptions, useAsyncSearch } from '../../../hooks/useAsyncSearch'; import { onTabPress } from '../../../utils/keyboard'; import { createEmoticonElement, moveCursor, replaceWithElement } from '../utils'; import { useRecentEmoji } from '../../../hooks/useRecentEmoji'; @@ -20,6 +16,7 @@ import { useKeyDown } from '../../../hooks/useKeyDown'; import { mxcUrlToHttp } from '../../../utils/matrix'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { ImageUsage, PackImageReader } from '../../../plugins/custom-emoji'; +import { getEmoticonSearchStr } from '../../../plugins/utils'; type EmoticonCompleteHandler = (key: string, shortcode: string) => void; @@ -33,16 +30,11 @@ type EmoticonAutocompleteProps = { }; const SEARCH_OPTIONS: UseAsyncSearchOptions = { - limit: 20, matchOptions: { contain: true, }, }; -const getEmoticonStr: SearchItemStrGetter = (emoticon) => [ - `:${emoticon.shortcode}:`, -]; - export function EmoticonAutocomplete({ imagePackRooms, editor, @@ -63,8 +55,12 @@ export function EmoticonAutocomplete({ ); }, [imagePacks]); - const [result, search, resetSearch] = useAsyncSearch(searchList, getEmoticonStr, SEARCH_OPTIONS); - const autoCompleteEmoticon = result ? result.items : recentEmoji; + const [result, search, resetSearch] = useAsyncSearch( + searchList, + getEmoticonSearchStr, + SEARCH_OPTIONS + ); + const autoCompleteEmoticon = result ? result.items.slice(0, 20) : recentEmoji; useEffect(() => { if (query.text) search(query.text); diff --git a/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx b/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx index 049be94a..cc431f58 100644 --- a/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx +++ b/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx @@ -65,7 +65,6 @@ type RoomMentionAutocompleteProps = { }; const SEARCH_OPTIONS: UseAsyncSearchOptions = { - limit: 20, matchOptions: { contain: true, }, @@ -97,7 +96,7 @@ export function RoomMentionAutocomplete({ SEARCH_OPTIONS ); - const autoCompleteRoomIds = result ? result.items : allRooms.slice(0, 20); + const autoCompleteRoomIds = result ? result.items.slice(0, 20) : allRooms.slice(0, 20); useEffect(() => { if (query.text) search(query.text); diff --git a/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx b/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx index 88ac9f39..d6c0f302 100644 --- a/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx +++ b/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx @@ -74,7 +74,7 @@ const withAllowedMembership = (member: RoomMember): boolean => member.membership === Membership.Knock; const SEARCH_OPTIONS: UseAsyncSearchOptions = { - limit: 20, + limit: 1000, matchOptions: { contain: true, }, @@ -97,7 +97,7 @@ export function UserMentionAutocomplete({ const members = useRoomMembers(mx, roomId); const [result, search, resetSearch] = useAsyncSearch(members, getRoomMemberStr, SEARCH_OPTIONS); - const autoCompleteMembers = (result ? result.items : members.slice(0, 20)).filter( + const autoCompleteMembers = (result ? result.items.slice(0, 20) : members.slice(0, 20)).filter( withAllowedMembership ); diff --git a/src/app/components/editor/input.ts b/src/app/components/editor/input.ts index 20c56ed3..ad314add 100644 --- a/src/app/components/editor/input.ts +++ b/src/app/components/editor/input.ts @@ -26,48 +26,75 @@ import { testMatrixTo, } from '../../plugins/matrix-to'; import { tryDecodeURIComponent } from '../../utils/dom'; +import { + escapeMarkdownInlineSequences, + escapeMarkdownBlockSequences, +} from '../../plugins/markdown'; -const markNodeToType: Record = { - b: MarkType.Bold, - strong: MarkType.Bold, - i: MarkType.Italic, - em: MarkType.Italic, - u: MarkType.Underline, - s: MarkType.StrikeThrough, - del: MarkType.StrikeThrough, - code: MarkType.Code, - span: MarkType.Spoiler, -}; +type ProcessTextCallback = (text: string) => string; -const elementToTextMark = (node: Element): MarkType | undefined => { - const markType = markNodeToType[node.name]; - if (!markType) return undefined; - - if (markType === MarkType.Spoiler && node.attribs['data-mx-spoiler'] === undefined) { - return undefined; - } - if ( - markType === MarkType.Code && - node.parent && - 'name' in node.parent && - node.parent.name === 'pre' - ) { - return undefined; - } - return markType; -}; - -const parseNodeText = (node: ChildNode): string => { +const getText = (node: ChildNode): string => { if (isText(node)) { return node.data; } if (isTag(node)) { - return node.children.map((child) => parseNodeText(child)).join(''); + return node.children.map((child) => getText(child)).join(''); } return ''; }; -const elementToInlineNode = (node: Element): MentionElement | EmoticonElement | undefined => { +const getInlineNodeMarkType = (node: Element): MarkType | undefined => { + if (node.name === 'b' || node.name === 'strong') { + return MarkType.Bold; + } + + if (node.name === 'i' || node.name === 'em') { + return MarkType.Italic; + } + + if (node.name === 'u') { + return MarkType.Underline; + } + + if (node.name === 's' || node.name === 'del') { + return MarkType.StrikeThrough; + } + + if (node.name === 'code') { + if (node.parent && 'name' in node.parent && node.parent.name === 'pre') { + return undefined; // Don't apply `Code` mark inside a
 tag
+    }
+    return MarkType.Code;
+  }
+
+  if (node.name === 'span' && node.attribs['data-mx-spoiler'] !== undefined) {
+    return MarkType.Spoiler;
+  }
+
+  return undefined;
+};
+
+const getInlineMarkElement = (
+  markType: MarkType,
+  node: Element,
+  getChild: (child: ChildNode) => InlineElement[]
+): InlineElement[] => {
+  const children = node.children.flatMap(getChild);
+  const mdSequence = node.attribs['data-md'];
+  if (mdSequence !== undefined) {
+    children.unshift({ text: mdSequence });
+    children.push({ text: mdSequence });
+    return children;
+  }
+  children.forEach((child) => {
+    if (Text.isText(child)) {
+      child[markType] = true;
+    }
+  });
+  return children;
+};
+
+const getInlineNonMarkElement = (node: Element): MentionElement | EmoticonElement | undefined => {
   if (node.name === 'img' && node.attribs['data-mx-emoticon'] !== undefined) {
     const { src, alt } = node.attribs;
     if (!src) return undefined;
@@ -79,13 +106,13 @@ const elementToInlineNode = (node: Element): MentionElement | EmoticonElement |
     if (testMatrixTo(href)) {
       const userMention = parseMatrixToUser(href);
       if (userMention) {
-        return createMentionElement(userMention, parseNodeText(node) || userMention, false);
+        return createMentionElement(userMention, getText(node) || userMention, false);
       }
       const roomMention = parseMatrixToRoom(href);
       if (roomMention) {
         return createMentionElement(
           roomMention.roomIdOrAlias,
-          parseNodeText(node) || roomMention.roomIdOrAlias,
+          getText(node) || roomMention.roomIdOrAlias,
           false,
           undefined,
           roomMention.viaServers
@@ -95,7 +122,7 @@ const elementToInlineNode = (node: Element): MentionElement | EmoticonElement |
       if (eventMention) {
         return createMentionElement(
           eventMention.roomIdOrAlias,
-          parseNodeText(node) || eventMention.roomIdOrAlias,
+          getText(node) || eventMention.roomIdOrAlias,
           false,
           eventMention.eventId,
           eventMention.viaServers
@@ -106,44 +133,40 @@ const elementToInlineNode = (node: Element): MentionElement | EmoticonElement |
   return undefined;
 };
 
-const parseInlineNodes = (node: ChildNode): InlineElement[] => {
+const getInlineElement = (node: ChildNode, processText: ProcessTextCallback): InlineElement[] => {
   if (isText(node)) {
-    return [{ text: node.data }];
+    return [{ text: processText(node.data) }];
   }
+
   if (isTag(node)) {
-    const markType = elementToTextMark(node);
+    const markType = getInlineNodeMarkType(node);
     if (markType) {
-      const children = node.children.flatMap(parseInlineNodes);
-      if (node.attribs['data-md'] !== undefined) {
-        children.unshift({ text: node.attribs['data-md'] });
-        children.push({ text: node.attribs['data-md'] });
-      } else {
-        children.forEach((child) => {
-          if (Text.isText(child)) {
-            child[markType] = true;
-          }
-        });
-      }
-      return children;
+      return getInlineMarkElement(markType, node, (child) => {
+        if (markType === MarkType.Code) return [{ text: getText(child) }];
+        return getInlineElement(child, processText);
+      });
     }
 
-    const inlineNode = elementToInlineNode(node);
+    const inlineNode = getInlineNonMarkElement(node);
     if (inlineNode) return [inlineNode];
 
     if (node.name === 'a') {
-      const children = node.childNodes.flatMap(parseInlineNodes);
+      const children = node.childNodes.flatMap((child) => getInlineElement(child, processText));
       children.unshift({ text: '[' });
       children.push({ text: `](${node.attribs.href})` });
       return children;
     }
 
-    return node.childNodes.flatMap(parseInlineNodes);
+    return node.childNodes.flatMap((child) => getInlineElement(child, processText));
   }
 
   return [];
 };
 
-const parseBlockquoteNode = (node: Element): BlockQuoteElement[] | ParagraphElement[] => {
+const parseBlockquoteNode = (
+  node: Element,
+  processText: ProcessTextCallback
+): BlockQuoteElement[] | ParagraphElement[] => {
   const quoteLines: Array = [];
   let lineHolder: InlineElement[] = [];
 
@@ -156,7 +179,7 @@ const parseBlockquoteNode = (node: Element): BlockQuoteElement[] | ParagraphElem
 
   node.children.forEach((child) => {
     if (isText(child)) {
-      lineHolder.push({ text: child.data });
+      lineHolder.push({ text: processText(child.data) });
       return;
     }
     if (isTag(child)) {
@@ -168,19 +191,20 @@ const parseBlockquoteNode = (node: Element): BlockQuoteElement[] | ParagraphElem
 
       if (child.name === 'p') {
         appendLine();
-        quoteLines.push(child.children.flatMap((c) => parseInlineNodes(c)));
+        quoteLines.push(child.children.flatMap((c) => getInlineElement(c, processText)));
         return;
       }
 
-      parseInlineNodes(child).forEach((inlineNode) => lineHolder.push(inlineNode));
+      lineHolder.push(...getInlineElement(child, processText));
     }
   });
   appendLine();
 
-  if (node.attribs['data-md'] !== undefined) {
+  const mdSequence = node.attribs['data-md'];
+  if (mdSequence !== undefined) {
     return quoteLines.map((lineChildren) => ({
       type: BlockType.Paragraph,
-      children: [{ text: `${node.attribs['data-md']} ` }, ...lineChildren],
+      children: [{ text: `${mdSequence} ` }, ...lineChildren],
     }));
   }
 
@@ -195,22 +219,19 @@ const parseBlockquoteNode = (node: Element): BlockQuoteElement[] | ParagraphElem
   ];
 };
 const parseCodeBlockNode = (node: Element): CodeBlockElement[] | ParagraphElement[] => {
-  const codeLines = parseNodeText(node).trim().split('\n');
+  const codeLines = getText(node).trim().split('\n');
 
-  if (node.attribs['data-md'] !== undefined) {
-    const pLines = codeLines.map((lineText) => ({
+  const mdSequence = node.attribs['data-md'];
+  if (mdSequence !== undefined) {
+    const pLines = codeLines.map((text) => ({
       type: BlockType.Paragraph,
-      children: [
-        {
-          text: lineText,
-        },
-      ],
+      children: [{ text }],
     }));
     const childCode = node.children[0];
     const className =
       isTag(childCode) && childCode.tagName === 'code' ? childCode.attribs.class ?? '' : '';
-    const prefix = { text: `${node.attribs['data-md']}${className.replace('language-', '')}` };
-    const suffix = { text: node.attribs['data-md'] };
+    const prefix = { text: `${mdSequence}${className.replace('language-', '')}` };
+    const suffix = { text: mdSequence };
     return [
       { type: BlockType.Paragraph, children: [prefix] },
       ...pLines,
@@ -221,19 +242,16 @@ const parseCodeBlockNode = (node: Element): CodeBlockElement[] | ParagraphElemen
   return [
     {
       type: BlockType.CodeBlock,
-      children: codeLines.map((lineTxt) => ({
+      children: codeLines.map((text) => ({
         type: BlockType.CodeLine,
-        children: [
-          {
-            text: lineTxt,
-          },
-        ],
+        children: [{ text }],
       })),
     },
   ];
 };
 const parseListNode = (
-  node: Element
+  node: Element,
+  processText: ProcessTextCallback
 ): OrderedListElement[] | UnorderedListElement[] | ParagraphElement[] => {
   const listLines: Array = [];
   let lineHolder: InlineElement[] = [];
@@ -247,7 +265,7 @@ const parseListNode = (
 
   node.children.forEach((child) => {
     if (isText(child)) {
-      lineHolder.push({ text: child.data });
+      lineHolder.push({ text: processText(child.data) });
       return;
     }
     if (isTag(child)) {
@@ -259,17 +277,18 @@ const parseListNode = (
 
       if (child.name === 'li') {
         appendLine();
-        listLines.push(child.children.flatMap((c) => parseInlineNodes(c)));
+        listLines.push(child.children.flatMap((c) => getInlineElement(c, processText)));
         return;
       }
 
-      parseInlineNodes(child).forEach((inlineNode) => lineHolder.push(inlineNode));
+      lineHolder.push(...getInlineElement(child, processText));
     }
   });
   appendLine();
 
-  if (node.attribs['data-md'] !== undefined) {
-    const prefix = node.attribs['data-md'] || '-';
+  const mdSequence = node.attribs['data-md'];
+  if (mdSequence !== undefined) {
+    const prefix = mdSequence || '-';
     const [starOrHyphen] = prefix.match(/^\*|-$/) ?? [];
     return listLines.map((lineChildren) => ({
       type: BlockType.Paragraph,
@@ -302,17 +321,21 @@ const parseListNode = (
     },
   ];
 };
-const parseHeadingNode = (node: Element): HeadingElement | ParagraphElement => {
-  const children = node.children.flatMap((child) => parseInlineNodes(child));
+const parseHeadingNode = (
+  node: Element,
+  processText: ProcessTextCallback
+): HeadingElement | ParagraphElement => {
+  const children = node.children.flatMap((child) => getInlineElement(child, processText));
 
   const headingMatch = node.name.match(/^h([123456])$/);
   const [, g1AsLevel] = headingMatch ?? ['h3', '3'];
   const level = parseInt(g1AsLevel, 10);
 
-  if (node.attribs['data-md'] !== undefined) {
+  const mdSequence = node.attribs['data-md'];
+  if (mdSequence !== undefined) {
     return {
       type: BlockType.Paragraph,
-      children: [{ text: `${node.attribs['data-md']} ` }, ...children],
+      children: [{ text: `${mdSequence} ` }, ...children],
     };
   }
 
@@ -323,7 +346,11 @@ const parseHeadingNode = (node: Element): HeadingElement | ParagraphElement => {
   };
 };
 
-export const domToEditorInput = (domNodes: ChildNode[]): Descendant[] => {
+export const domToEditorInput = (
+  domNodes: ChildNode[],
+  processText: ProcessTextCallback,
+  processLineStartText: ProcessTextCallback
+): Descendant[] => {
   const children: Descendant[] = [];
 
   let lineHolder: InlineElement[] = [];
@@ -340,7 +367,14 @@ export const domToEditorInput = (domNodes: ChildNode[]): Descendant[] => {
 
   domNodes.forEach((node) => {
     if (isText(node)) {
-      lineHolder.push({ text: node.data });
+      if (lineHolder.length === 0) {
+        // we are inserting first part of line
+        // it may contain block markdown starting data
+        // that we may need to escape.
+        lineHolder.push({ text: processLineStartText(node.data) });
+        return;
+      }
+      lineHolder.push({ text: processText(node.data) });
       return;
     }
     if (isTag(node)) {
@@ -354,14 +388,14 @@ export const domToEditorInput = (domNodes: ChildNode[]): Descendant[] => {
         appendLine();
         children.push({
           type: BlockType.Paragraph,
-          children: node.children.flatMap((child) => parseInlineNodes(child)),
+          children: node.children.flatMap((child) => getInlineElement(child, processText)),
         });
         return;
       }
 
       if (node.name === 'blockquote') {
         appendLine();
-        children.push(...parseBlockquoteNode(node));
+        children.push(...parseBlockquoteNode(node, processText));
         return;
       }
       if (node.name === 'pre') {
@@ -371,17 +405,17 @@ export const domToEditorInput = (domNodes: ChildNode[]): Descendant[] => {
       }
       if (node.name === 'ol' || node.name === 'ul') {
         appendLine();
-        children.push(...parseListNode(node));
+        children.push(...parseListNode(node, processText));
         return;
       }
 
       if (node.name.match(/^h[123456]$/)) {
         appendLine();
-        children.push(parseHeadingNode(node));
+        children.push(parseHeadingNode(node, processText));
         return;
       }
 
-      parseInlineNodes(node).forEach((inlineNode) => lineHolder.push(inlineNode));
+      lineHolder.push(...getInlineElement(node, processText));
     }
   });
   appendLine();
@@ -389,21 +423,31 @@ export const domToEditorInput = (domNodes: ChildNode[]): Descendant[] => {
   return children;
 };
 
-export const htmlToEditorInput = (unsafeHtml: string): Descendant[] => {
+export const htmlToEditorInput = (unsafeHtml: string, markdown?: boolean): Descendant[] => {
   const sanitizedHtml = sanitizeCustomHtml(unsafeHtml);
 
+  const processText = (partText: string) => {
+    if (!markdown) return partText;
+    return escapeMarkdownInlineSequences(partText);
+  };
+
   const domNodes = parse(sanitizedHtml);
-  const editorNodes = domToEditorInput(domNodes);
+  const editorNodes = domToEditorInput(domNodes, processText, (lineStartText: string) => {
+    if (!markdown) return lineStartText;
+    return escapeMarkdownBlockSequences(lineStartText, processText);
+  });
   return editorNodes;
 };
 
-export const plainToEditorInput = (text: string): Descendant[] => {
+export const plainToEditorInput = (text: string, markdown?: boolean): Descendant[] => {
   const editorNodes: Descendant[] = text.split('\n').map((lineText) => {
     const paragraphNode: ParagraphElement = {
       type: BlockType.Paragraph,
       children: [
         {
-          text: lineText,
+          text: markdown
+            ? escapeMarkdownBlockSequences(lineText, escapeMarkdownInlineSequences)
+            : lineText,
         },
       ],
     };
diff --git a/src/app/components/editor/output.ts b/src/app/components/editor/output.ts
index d6136d99..dbdd51f3 100644
--- a/src/app/components/editor/output.ts
+++ b/src/app/components/editor/output.ts
@@ -1,10 +1,17 @@
-import { Descendant, Text } from 'slate';
-
+import { Descendant, Editor, Text } from 'slate';
+import { MatrixClient } from 'matrix-js-sdk';
 import { sanitizeText } from '../../utils/sanitize';
 import { BlockType } from './types';
 import { CustomElement } from './slate';
-import { parseBlockMD, parseInlineMD } from '../../plugins/markdown';
+import {
+  parseBlockMD,
+  parseInlineMD,
+  unescapeMarkdownBlockSequences,
+  unescapeMarkdownInlineSequences,
+} from '../../plugins/markdown';
 import { findAndReplace } from '../../utils/findAndReplace';
+import { sanitizeForRegex } from '../../utils/regex';
+import { getCanonicalAliasOrRoomId, isUserId } from '../../utils/matrix';
 
 export type OutputOptions = {
   allowTextFormatting?: boolean;
@@ -18,7 +25,7 @@ const textToCustomHtml = (node: Text, opts: OutputOptions): string => {
     if (node.bold) string = `${string}`;
     if (node.italic) string = `${string}`;
     if (node.underline) string = `${string}`;
-    if (node.strikeThrough) string = `${string}`;
+    if (node.strikeThrough) string = `${string}`;
     if (node.code) string = `${string}`;
     if (node.spoiler) string = `${string}`;
   }
@@ -101,7 +108,8 @@ export const toMatrixCustomHTML = (
         allowBlockMarkdown: false,
       })
         .replace(/$/, '\n')
-        .replace(/^>/, '>');
+        .replace(/^(\\*)>/, '$1>');
+
       markdownLines += line;
       if (index === targetNodes.length - 1) {
         return parseBlockMD(markdownLines, ignoreHTMLParseInlineMD);
@@ -156,11 +164,14 @@ const elementToPlainText = (node: CustomElement, children: string): string => {
   }
 };
 
-export const toPlainText = (node: Descendant | Descendant[]): string => {
-  if (Array.isArray(node)) return node.map((n) => toPlainText(n)).join('');
-  if (Text.isText(node)) return node.text;
+export const toPlainText = (node: Descendant | Descendant[], isMarkdown: boolean): string => {
+  if (Array.isArray(node)) return node.map((n) => toPlainText(n, isMarkdown)).join('');
+  if (Text.isText(node))
+    return isMarkdown
+      ? unescapeMarkdownBlockSequences(node.text, unescapeMarkdownInlineSequences)
+      : node.text;
 
-  const children = node.children.map((n) => toPlainText(n)).join('');
+  const children = node.children.map((n) => toPlainText(n, isMarkdown)).join('');
   return elementToPlainText(node, children);
 };
 
@@ -179,9 +190,42 @@ export const customHtmlEqualsPlainText = (customHtml: string, plain: string): bo
 export const trimCustomHtml = (customHtml: string) => customHtml.replace(/$/g, '').trim();
 
 export const trimCommand = (cmdName: string, str: string) => {
-  const cmdRegX = new RegExp(`^(\\s+)?(\\/${cmdName})([^\\S\n]+)?`);
+  const cmdRegX = new RegExp(`^(\\s+)?(\\/${sanitizeForRegex(cmdName)})([^\\S\n]+)?`);
 
   const match = str.match(cmdRegX);
   if (!match) return str;
   return str.slice(match[0].length);
 };
+
+export type MentionsData = {
+  room: boolean;
+  users: Set;
+};
+export const getMentions = (mx: MatrixClient, roomId: string, editor: Editor): MentionsData => {
+  const mentionData: MentionsData = {
+    room: false,
+    users: new Set(),
+  };
+
+  const parseMentions = (node: Descendant): void => {
+    if (Text.isText(node)) return;
+    if (node.type === BlockType.CodeBlock) return;
+
+    if (node.type === BlockType.Mention) {
+      if (node.id === getCanonicalAliasOrRoomId(mx, roomId)) {
+        mentionData.room = true;
+      }
+      if (isUserId(node.id) && node.id !== mx.getUserId()) {
+        mentionData.users.add(node.id);
+      }
+
+      return;
+    }
+
+    node.children.forEach(parseMentions);
+  };
+
+  editor.children.forEach(parseMentions);
+
+  return mentionData;
+};
diff --git a/src/app/components/emoji-board/EmojiBoard.tsx b/src/app/components/emoji-board/EmojiBoard.tsx
index 77e56a91..28735081 100644
--- a/src/app/components/emoji-board/EmojiBoard.tsx
+++ b/src/app/components/emoji-board/EmojiBoard.tsx
@@ -50,6 +50,7 @@ import { addRecentEmoji } from '../../plugins/recent-emoji';
 import { mobileOrTablet } from '../../utils/user-agent';
 import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
 import { ImagePack, ImageUsage, PackImageReader } from '../../plugins/custom-emoji';
+import { getEmoticonSearchStr } from '../../plugins/utils';
 
 const RECENT_GROUP_ID = 'recent_group';
 const SEARCH_GROUP_ID = 'search_group';
@@ -636,15 +637,8 @@ export const NativeEmojiGroups = memo(
   )
 );
 
-const getSearchListItemStr = (item: PackImageReader | IEmoji) => {
-  const shortcode = `:${item.shortcode}:`;
-  if ('body' in item) {
-    return [shortcode, item.body ?? ''];
-  }
-  return shortcode;
-};
 const SEARCH_OPTIONS: UseAsyncSearchOptions = {
-  limit: 26,
+  limit: 1000,
   matchOptions: {
     contain: true,
   },
@@ -696,10 +690,12 @@ export function EmojiBoard({
 
   const [result, search, resetSearch] = useAsyncSearch(
     searchList,
-    getSearchListItemStr,
+    getEmoticonSearchStr,
     SEARCH_OPTIONS
   );
 
+  const searchedItems = result?.items.slice(0, 100);
+
   const handleOnChange: ChangeEventHandler = useDebounce(
     useCallback(
       (evt) => {
@@ -920,13 +916,13 @@ export function EmojiBoard({
               direction="Column"
               gap="200"
             >
-              {result && (
+              {searchedItems && (
                 
               )}
diff --git a/src/app/components/message/FileHeader.tsx b/src/app/components/message/FileHeader.tsx
index 947be90e..0248862d 100644
--- a/src/app/components/message/FileHeader.tsx
+++ b/src/app/components/message/FileHeader.tsx
@@ -1,22 +1,81 @@
-import { Badge, Box, Text, as, toRem } from 'folds';
-import React from 'react';
+import { Badge, Box, Icon, IconButton, Icons, Spinner, Text, as, toRem } from 'folds';
+import React, { ReactNode, useCallback } from 'react';
+import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
+import FileSaver from 'file-saver';
 import { mimeTypeToExt } from '../../utils/mimeTypes';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
+import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
+import {
+  decryptFile,
+  downloadEncryptedMedia,
+  downloadMedia,
+  mxcUrlToHttp,
+} from '../../utils/matrix';
 
 const badgeStyles = { maxWidth: toRem(100) };
 
+type FileDownloadButtonProps = {
+  filename: string;
+  url: string;
+  mimeType: string;
+  encInfo?: EncryptedAttachmentInfo;
+};
+export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDownloadButtonProps) {
+  const mx = useMatrixClient();
+  const useAuthentication = useMediaAuthentication();
+
+  const [downloadState, download] = useAsyncCallback(
+    useCallback(async () => {
+      const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication) ?? url;
+      const fileContent = encInfo
+        ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
+        : await downloadMedia(mediaUrl);
+
+      const fileURL = URL.createObjectURL(fileContent);
+      FileSaver.saveAs(fileURL, filename);
+      return fileURL;
+    }, [mx, url, useAuthentication, mimeType, encInfo, filename])
+  );
+
+  const downloading = downloadState.status === AsyncStatus.Loading;
+  const hasError = downloadState.status === AsyncStatus.Error;
+  return (
+    
+      {downloading ? (
+        
+      ) : (
+        
+      )}
+    
+  );
+}
+
 export type FileHeaderProps = {
   body: string;
   mimeType: string;
+  after?: ReactNode;
 };
-export const FileHeader = as<'div', FileHeaderProps>(({ body, mimeType, ...props }, ref) => (
+export const FileHeader = as<'div', FileHeaderProps>(({ body, mimeType, after, ...props }, ref) => (
   
-    
-      
-        {mimeTypeToExt(mimeType)}
+    
+      
+        
+          {mimeTypeToExt(mimeType)}
+        
+      
+    
+    
+      
+        {body}
       
-    
-    
-      {body}
-    
+    
+    {after}
   
 ));
diff --git a/src/app/components/message/MsgTypeRenderers.tsx b/src/app/components/message/MsgTypeRenderers.tsx
index 07ad3a74..446cc513 100644
--- a/src/app/components/message/MsgTypeRenderers.tsx
+++ b/src/app/components/message/MsgTypeRenderers.tsx
@@ -28,7 +28,7 @@ import {
 import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '../../utils/mimeTypes';
 import { parseGeoUri, scaleYDimension } from '../../utils/common';
 import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment';
-import { FileHeader } from './FileHeader';
+import { FileHeader, FileDownloadButton } from './FileHeader';
 
 export function MBadEncrypted() {
   return (
@@ -245,8 +245,24 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }:
 
   const height = scaleYDimension(videoInfo.w || 400, 400, videoInfo.h || 400);
 
+  const filename = content.filename ?? content.body ?? 'Video';
+
   return (
     
+      
+        
+          }
+        />
+      
       ;
   }
 
+  const filename = content.filename ?? content.body ?? 'Audio';
   return (
     
       
-        
+        
+          }
+        />
       
       
         
diff --git a/src/app/components/text-viewer/TextViewer.css.ts b/src/app/components/text-viewer/TextViewer.css.ts
index 2b79fa64..83ee6058 100644
--- a/src/app/components/text-viewer/TextViewer.css.ts
+++ b/src/app/components/text-viewer/TextViewer.css.ts
@@ -31,8 +31,11 @@ export const TextViewerContent = style([
 export const TextViewerPre = style([
   DefaultReset,
   {
-    padding: config.space.S600,
     whiteSpace: 'pre-wrap',
     wordBreak: 'break-word',
   },
 ]);
+
+export const TextViewerPrePadding = style({
+  padding: config.space.S600,
+});
diff --git a/src/app/components/text-viewer/TextViewer.tsx b/src/app/components/text-viewer/TextViewer.tsx
index 7829fb35..f39ef953 100644
--- a/src/app/components/text-viewer/TextViewer.tsx
+++ b/src/app/components/text-viewer/TextViewer.tsx
@@ -1,5 +1,5 @@
 /* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
-import React, { Suspense, lazy } from 'react';
+import React, { ComponentProps, HTMLAttributes, Suspense, forwardRef, lazy } from 'react';
 import classNames from 'classnames';
 import { Box, Chip, Header, Icon, IconButton, Icons, Scroll, Text, as } from 'folds';
 import { ErrorBoundary } from 'react-error-boundary';
@@ -8,6 +8,29 @@ import { copyToClipboard } from '../../utils/dom';
 
 const ReactPrism = lazy(() => import('../../plugins/react-prism/ReactPrism'));
 
+type TextViewerContentProps = {
+  text: string;
+  langName: string;
+  size?: ComponentProps['size'];
+} & HTMLAttributes;
+export const TextViewerContent = forwardRef(
+  ({ text, langName, size, className, ...props }, ref) => (
+    
+      {text}}>
+        {text}}>
+          {(codeRef) => {text}}
+        
+      
+    
+  )
+);
+
 export type TextViewerProps = {
   name: string;
   text: string;
@@ -43,6 +66,7 @@ export const TextViewer = as<'div', TextViewerProps>(
             
           
         
+
         (
           alignItems="Center"
         >
           
-            
-              {text}}>
-                {text}}>
-                  {(codeRef) => {text}}
-                
-              
-            
+            
           
         
       
diff --git a/src/app/components/upload-card/UploadCardRenderer.tsx b/src/app/components/upload-card/UploadCardRenderer.tsx
index bad13561..4383e204 100644
--- a/src/app/components/upload-card/UploadCardRenderer.tsx
+++ b/src/app/components/upload-card/UploadCardRenderer.tsx
@@ -1,5 +1,5 @@
-import React, { useCallback, useEffect } from 'react';
-import { Chip, Icon, IconButton, Icons, Text, Tooltip, TooltipProvider, color } from 'folds';
+import React, { useEffect } from 'react';
+import { Box, Chip, Icon, IconButton, Icons, Text, color, config, toRem } from 'folds';
 import { UploadCard, UploadCardError, UploadCardProgress } from './UploadCard';
 import { UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload';
 import { useMatrixClient } from '../../hooks/useMatrixClient';
@@ -10,11 +10,60 @@ import {
   TUploadItem,
   TUploadMetadata,
 } from '../../state/room/roomInputDrafts';
+import { useObjectURL } from '../../hooks/useObjectURL';
+
+type ImagePreviewProps = { fileItem: TUploadItem; onSpoiler: (marked: boolean) => void };
+function ImagePreview({ fileItem, onSpoiler }: ImagePreviewProps) {
+  const { originalFile, metadata } = fileItem;
+  const fileUrl = useObjectURL(originalFile);
+
+  return fileUrl ? (
+    
+      {originalFile.name}
+      
+        }
+          onClick={() => onSpoiler(!metadata.markedAsSpoiler)}
+        >
+          Spoiler
+        
+      
+    
+  ) : null;
+}
 
 type UploadCardRendererProps = {
   isEncrypted?: boolean;
   fileItem: TUploadItem;
-  setMetadata: (metadata: TUploadMetadata) => void;
+  setMetadata: (fileItem: TUploadItem, metadata: TUploadMetadata) => void;
   onRemove: (file: TUploadContent) => void;
   onComplete?: (upload: UploadSuccess) => void;
 };
@@ -33,9 +82,9 @@ export function UploadCardRenderer({
 
   if (upload.status === UploadStatus.Idle) startUpload();
 
-  const toggleSpoiler = useCallback(() => {
-    setMetadata({ ...metadata, markedAsSpoiler: !metadata.markedAsSpoiler });
-  }, [setMetadata, metadata]);
+  const handleSpoiler = (marked: boolean) => {
+    setMetadata(fileItem, { ...metadata, markedAsSpoiler: marked });
+  };
 
   const removeUpload = () => {
     cancelUpload();
@@ -66,31 +115,6 @@ export function UploadCardRenderer({
               Retry
             
           )}
-          {(file.type.startsWith('image') || file.type.startsWith('video')) && (
-            
-                  Mark as Spoiler
-                
-              }
-              position="Top"
-              align="Center"
-            >
-              {(triggerRef) => (
-                
-                  
-                
-              )}
-            
-          )}
           
+          {fileItem.originalFile.type.startsWith('image') && (
+            
+          )}
           {upload.status === UploadStatus.Idle && (
             
           )}
diff --git a/src/app/features/lobby/HierarchyItemMenu.tsx b/src/app/features/lobby/HierarchyItemMenu.tsx
index 30a4f632..d1a7ec6b 100644
--- a/src/app/features/lobby/HierarchyItemMenu.tsx
+++ b/src/app/features/lobby/HierarchyItemMenu.tsx
@@ -155,7 +155,7 @@ function SettingsMenuItem({
   disabled?: boolean;
 }) {
   const handleSettings = () => {
-    if (item.space) {
+    if ('space' in item) {
       openSpaceSettings(item.roomId);
     } else {
       toggleRoomSettings(item.roomId);
@@ -271,7 +271,7 @@ export function HierarchyItemMenu({
                             
                           
                           {promptLeave &&
-                            (item.space ? (
+                            ('space' in item ? (
                               >(() => new Map());
+
   useElementSizeObserver(
     useCallback(() => heroSectionRef.current, []),
     useCallback((w, height) => setHeroSectionHeight(height), [])
@@ -107,19 +113,20 @@ export function Lobby() {
   );
 
   const [draggingItem, setDraggingItem] = useState();
-  const flattenHierarchy = useSpaceHierarchy(
+  const hierarchy = useSpaceHierarchy(
     space.roomId,
     spaceRooms,
     getRoom,
     useCallback(
       (childId) =>
-        closedCategories.has(makeLobbyCategoryId(space.roomId, childId)) || !!draggingItem?.space,
+        closedCategories.has(makeLobbyCategoryId(space.roomId, childId)) ||
+        (draggingItem ? 'space' in draggingItem : false),
       [closedCategories, space.roomId, draggingItem]
     )
   );
 
   const virtualizer = useVirtualizer({
-    count: flattenHierarchy.length,
+    count: hierarchy.length,
     getScrollElement: () => scrollRef.current,
     estimateSize: () => 1,
     overscan: 2,
@@ -129,8 +136,17 @@ export function Lobby() {
 
   const roomsPowerLevels = useRoomsPowerLevels(
     useMemo(
-      () => flattenHierarchy.map((i) => mx.getRoom(i.roomId)).filter((r) => !!r) as Room[],
-      [mx, flattenHierarchy]
+      () =>
+        hierarchy
+          .flatMap((i) => {
+            const childRooms = Array.isArray(i.rooms)
+              ? i.rooms.map((r) => mx.getRoom(r.roomId))
+              : [];
+
+            return [mx.getRoom(i.space.roomId), ...childRooms];
+          })
+          .filter((r) => !!r) as Room[],
+      [mx, hierarchy]
     )
   );
 
@@ -142,8 +158,8 @@ export function Lobby() {
         return false;
       }
 
-      if (item.space) {
-        if (!container.item.space) return false;
+      if ('space' in item) {
+        if (!('space' in container.item)) return false;
         const containerSpaceId = space.roomId;
 
         if (
@@ -156,9 +172,8 @@ export function Lobby() {
         return true;
       }
 
-      const containerSpaceId = container.item.space
-        ? container.item.roomId
-        : container.item.parentId;
+      const containerSpaceId =
+        'space' in container.item ? container.item.roomId : container.item.parentId;
 
       const dropOutsideSpace = item.parentId !== containerSpaceId;
 
@@ -192,22 +207,22 @@ export function Lobby() {
   );
 
   const reorderSpace = useCallback(
-    (item: HierarchyItem, containerItem: HierarchyItem) => {
+    (item: HierarchyItemSpace, containerItem: HierarchyItem) => {
       if (!item.parentId) return;
 
-      const childItems = flattenHierarchy
-        .filter((i) => i.parentId && i.space)
+      const itemSpaces: HierarchyItemSpace[] = hierarchy
+        .map((i) => i.space)
         .filter((i) => i.roomId !== item.roomId);
 
-      const beforeIndex = childItems.findIndex((i) => i.roomId === containerItem.roomId);
+      const beforeIndex = itemSpaces.findIndex((i) => i.roomId === containerItem.roomId);
       const insertIndex = beforeIndex + 1;
 
-      childItems.splice(insertIndex, 0, {
+      itemSpaces.splice(insertIndex, 0, {
         ...item,
         content: { ...item.content, order: undefined },
       });
 
-      const currentOrders = childItems.map((i) => {
+      const currentOrders = itemSpaces.map((i) => {
         if (typeof i.content.order === 'string' && lex.has(i.content.order)) {
           return i.content.order;
         }
@@ -217,21 +232,21 @@ export function Lobby() {
       const newOrders = orderKeys(lex, currentOrders);
 
       newOrders?.forEach((orderKey, index) => {
-        const itm = childItems[index];
+        const itm = itemSpaces[index];
         if (!itm || !itm.parentId) return;
         const parentPL = roomsPowerLevels.get(itm.parentId);
         const canEdit = parentPL && canEditSpaceChild(parentPL);
         if (canEdit && orderKey !== currentOrders[index]) {
           mx.sendStateEvent(
             itm.parentId,
-            StateEvent.SpaceChild,
+            StateEvent.SpaceChild as any,
             { ...itm.content, order: orderKey },
             itm.roomId
           );
         }
       });
     },
-    [mx, flattenHierarchy, lex, roomsPowerLevels, canEditSpaceChild]
+    [mx, hierarchy, lex, roomsPowerLevels, canEditSpaceChild]
   );
 
   const reorderRoom = useCallback(
@@ -240,13 +255,12 @@ export function Lobby() {
       if (!item.parentId) {
         return;
       }
-      const containerParentId: string = containerItem.space
-        ? containerItem.roomId
-        : containerItem.parentId;
+      const containerParentId: string =
+        'space' in containerItem ? containerItem.roomId : containerItem.parentId;
       const itemContent = item.content;
 
       if (item.parentId !== containerParentId) {
-        mx.sendStateEvent(item.parentId, StateEvent.SpaceChild, {}, item.roomId);
+        mx.sendStateEvent(item.parentId, StateEvent.SpaceChild as any, {}, item.roomId);
       }
 
       if (
@@ -265,28 +279,29 @@ export function Lobby() {
           const allow =
             joinRuleContent.allow?.filter((allowRule) => allowRule.room_id !== item.parentId) ?? [];
           allow.push({ type: RestrictedAllowType.RoomMembership, room_id: containerParentId });
-          mx.sendStateEvent(itemRoom.roomId, StateEvent.RoomJoinRules, {
+          mx.sendStateEvent(itemRoom.roomId, StateEvent.RoomJoinRules as any, {
             ...joinRuleContent,
             allow,
           });
         }
       }
 
-      const childItems = flattenHierarchy
-        .filter((i) => i.parentId === containerParentId && !i.space)
-        .filter((i) => i.roomId !== item.roomId);
+      const itemSpaces = Array.from(
+        hierarchy?.find((i) => i.space.roomId === containerParentId)?.rooms ?? []
+      );
 
-      const beforeItem: HierarchyItem | undefined = containerItem.space ? undefined : containerItem;
-      const beforeIndex = childItems.findIndex((i) => i.roomId === beforeItem?.roomId);
+      const beforeItem: HierarchyItem | undefined =
+        'space' in containerItem ? undefined : containerItem;
+      const beforeIndex = itemSpaces.findIndex((i) => i.roomId === beforeItem?.roomId);
       const insertIndex = beforeIndex + 1;
 
-      childItems.splice(insertIndex, 0, {
+      itemSpaces.splice(insertIndex, 0, {
         ...item,
         parentId: containerParentId,
         content: { ...itemContent, order: undefined },
       });
 
-      const currentOrders = childItems.map((i) => {
+      const currentOrders = itemSpaces.map((i) => {
         if (typeof i.content.order === 'string' && lex.has(i.content.order)) {
           return i.content.order;
         }
@@ -296,18 +311,18 @@ export function Lobby() {
       const newOrders = orderKeys(lex, currentOrders);
 
       newOrders?.forEach((orderKey, index) => {
-        const itm = childItems[index];
+        const itm = itemSpaces[index];
         if (itm && orderKey !== currentOrders[index]) {
           mx.sendStateEvent(
             containerParentId,
-            StateEvent.SpaceChild,
+            StateEvent.SpaceChild as any,
             { ...itm.content, order: orderKey },
             itm.roomId
           );
         }
       });
     },
-    [mx, flattenHierarchy, lex]
+    [mx, hierarchy, lex]
   );
 
   useDnDMonitor(
@@ -318,7 +333,7 @@ export function Lobby() {
         if (!canDrop(item, container)) {
           return;
         }
-        if (item.space) {
+        if ('space' in item) {
           reorderSpace(item, container.item);
         } else {
           reorderRoom(item, container.item);
@@ -328,8 +343,16 @@ export function Lobby() {
     )
   );
 
-  const addSpaceRoom = useCallback(
-    (roomId: string) => setSpaceRooms({ type: 'PUT', roomId }),
+  const handleSpacesFound = useCallback(
+    (sItems: IHierarchyRoom[]) => {
+      setSpaceRooms({ type: 'PUT', roomIds: sItems.map((i) => i.room_id) });
+      setSpacesItem((current) => {
+        const newItems = produce(current, (draft) => {
+          sItems.forEach((item) => draft.set(item.room_id, item));
+        });
+        return current.size === newItems.size ? current : newItems;
+      });
+    },
     [setSpaceRooms]
   );
 
@@ -394,121 +417,44 @@ export function Lobby() {
                       
                     
                     {vItems.map((vItem) => {
-                      const item = flattenHierarchy[vItem.index];
+                      const item = hierarchy[vItem.index];
                       if (!item) return null;
-                      const itemPowerLevel = roomsPowerLevels.get(item.roomId) ?? {};
-                      const userPLInItem = powerLevelAPI.getPowerLevel(
-                        itemPowerLevel,
-                        mx.getUserId() ?? undefined
-                      );
-                      const canInvite = powerLevelAPI.canDoAction(
-                        itemPowerLevel,
-                        'invite',
-                        userPLInItem
-                      );
-                      const isJoined = allJoinedRooms.has(item.roomId);
+                      const nextSpaceId = hierarchy[vItem.index + 1]?.space.roomId;
 
-                      const nextRoomId: string | undefined =
-                        flattenHierarchy[vItem.index + 1]?.roomId;
+                      const categoryId = makeLobbyCategoryId(space.roomId, item.space.roomId);
 
-                      const dragging =
-                        draggingItem?.roomId === item.roomId &&
-                        draggingItem.parentId === item.parentId;
-
-                      if (item.space) {
-                        const categoryId = makeLobbyCategoryId(space.roomId, item.roomId);
-                        const { parentId } = item;
-                        const parentPowerLevels = parentId
-                          ? roomsPowerLevels.get(parentId) ?? {}
-                          : undefined;
-
-                        return (
-                          
-                            
-                                )
-                              }
-                              before={item.parentId ? undefined : undefined}
-                              after={
-                                
-                              }
-                              onDragging={setDraggingItem}
-                              data-dragging={dragging}
-                            />
-                          
-                        );
-                      }
-
-                      const parentPowerLevels = roomsPowerLevels.get(item.parentId) ?? {};
-                      const prevItem: HierarchyItem | undefined = flattenHierarchy[vItem.index - 1];
-                      const nextItem: HierarchyItem | undefined = flattenHierarchy[vItem.index + 1];
                       return (
                         
-                          
+                          
-                            }
-                            data-dragging={dragging}
+                            handleClose={handleCategoryClick}
+                            draggingItem={draggingItem}
                             onDragging={setDraggingItem}
+                            canDrop={canDrop}
+                            nextSpaceId={nextSpaceId}
+                            getRoom={getRoom}
+                            pinned={sidebarSpaces.has(item.space.roomId)}
+                            togglePinToSidebar={togglePinToSidebar}
+                            onSpacesFound={handleSpacesFound}
+                            onOpenRoom={handleOpenRoom}
                           />
                         
                       );
diff --git a/src/app/features/lobby/RoomItem.tsx b/src/app/features/lobby/RoomItem.tsx
index f8db3991..994cda05 100644
--- a/src/app/features/lobby/RoomItem.tsx
+++ b/src/app/features/lobby/RoomItem.tsx
@@ -1,4 +1,4 @@
-import React, { MouseEventHandler, ReactNode, useCallback, useEffect, useRef } from 'react';
+import React, { MouseEventHandler, ReactNode, useCallback, useRef } from 'react';
 import {
   Avatar,
   Badge,
@@ -20,23 +20,20 @@ import {
 } from 'folds';
 import FocusTrap from 'focus-trap-react';
 import { JoinRule, MatrixError, Room } from 'matrix-js-sdk';
+import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
 import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
 import { SequenceCard } from '../../components/sequence-card';
 import { useMatrixClient } from '../../hooks/useMatrixClient';
 import { HierarchyItem } from '../../hooks/useSpaceHierarchy';
 import { millify } from '../../plugins/millify';
-import {
-  HierarchyRoomSummaryLoader,
-  LocalRoomSummaryLoader,
-} from '../../components/RoomSummaryLoader';
+import { LocalRoomSummaryLoader } from '../../components/RoomSummaryLoader';
 import { UseStateProvider } from '../../components/UseStateProvider';
 import { RoomTopicViewer } from '../../components/room-topic-viewer';
 import { onEnterOrSpace, stopPropagation } from '../../utils/keyboard';
-import { Membership, RoomType } from '../../../types/matrix/room';
+import { Membership } from '../../../types/matrix/room';
 import * as css from './RoomItem.css';
 import * as styleCss from './style.css';
 import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
-import { ErrorCode } from '../../cs-errorcode';
 import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room';
 import { ItemDraggableTarget, useDraggableItem } from './DnD';
 import { mxcUrlToHttp } from '../../utils/matrix';
@@ -125,13 +122,11 @@ function RoomProfileLoading() {
 
 type RoomProfileErrorProps = {
   roomId: string;
-  error: Error;
+  inaccessibleRoom: boolean;
   suggested?: boolean;
   via?: string[];
 };
-function RoomProfileError({ roomId, suggested, error, via }: RoomProfileErrorProps) {
-  const privateRoom = error.name === ErrorCode.M_FORBIDDEN;
-
+function RoomProfileError({ roomId, suggested, inaccessibleRoom, via }: RoomProfileErrorProps) {
   return (
     
       
@@ -142,7 +137,7 @@ function RoomProfileError({ roomId, suggested, error, via }: RoomProfileErrorPro
           renderFallback={() => (
             
           )}
@@ -162,25 +157,18 @@ function RoomProfileError({ roomId, suggested, error, via }: RoomProfileErrorPro
           )}
         
         
-          {privateRoom && (
-            <>
-              
-                Private Room
-              
-              
-            
+          {inaccessibleRoom ? (
+            
+              Inaccessible
+            
+          ) : (
+            
+              {roomId}
+            
           )}
-          
-            {roomId}
-          
         
       
-      {!privateRoom && }
+      {!inaccessibleRoom && }
     
   );
 }
@@ -288,23 +276,11 @@ function RoomProfile({
   );
 }
 
-function CallbackOnFoundSpace({
-  roomId,
-  onSpaceFound,
-}: {
-  roomId: string;
-  onSpaceFound: (roomId: string) => void;
-}) {
-  useEffect(() => {
-    onSpaceFound(roomId);
-  }, [roomId, onSpaceFound]);
-
-  return null;
-}
-
 type RoomItemCardProps = {
   item: HierarchyItem;
-  onSpaceFound: (roomId: string) => void;
+  loading: boolean;
+  error: Error | null;
+  summary: IHierarchyRoom | undefined;
   dm?: boolean;
   firstChild?: boolean;
   lastChild?: boolean;
@@ -320,10 +296,10 @@ export const RoomItemCard = as<'div', RoomItemCardProps>(
   (
     {
       item,
-      onSpaceFound,
+      loading,
+      error,
+      summary,
       dm,
-      firstChild,
-      lastChild,
       onOpen,
       options,
       before,
@@ -348,8 +324,6 @@ export const RoomItemCard = as<'div', RoomItemCardProps>(
     return (
       (
                   name={localSummary.name}
                   topic={localSummary.topic}
                   avatarUrl={
-                    dm ? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication) : getRoomAvatarUrl(mx, room, 96, useAuthentication)
+                    dm
+                      ? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
+                      : getRoomAvatarUrl(mx, room, 96, useAuthentication)
                   }
                   memberCount={localSummary.memberCount}
                   suggested={content.suggested}
@@ -395,46 +371,46 @@ export const RoomItemCard = as<'div', RoomItemCardProps>(
               )}
             
           ) : (
-            
-              {(summaryState) => (
-                <>
-                  {summaryState.status === AsyncStatus.Loading && }
-                  {summaryState.status === AsyncStatus.Error && (
-                    
-                  )}
-                  {summaryState.status === AsyncStatus.Success && (
-                    <>
-                      {summaryState.data.room_type === RoomType.Space && (
-                        
-                      )}
-                      
+              {!summary &&
+                (error ? (
+                  
+                ) : (
+                  <>
+                    {loading && }
+                    {!loading && (
+                      }
+                        via={content.via}
                       />
-                    
-                  )}
-                
+                    )}
+                  
+                ))}
+              {summary && (
+                }
+                />
               )}
-            
+            
           )}
         
         {options}
diff --git a/src/app/features/lobby/SpaceHierarchy.tsx b/src/app/features/lobby/SpaceHierarchy.tsx
new file mode 100644
index 00000000..2c43282f
--- /dev/null
+++ b/src/app/features/lobby/SpaceHierarchy.tsx
@@ -0,0 +1,225 @@
+import React, { forwardRef, MouseEventHandler, useEffect, useMemo } from 'react';
+import { MatrixError, Room } from 'matrix-js-sdk';
+import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
+import { Box, config, Text } from 'folds';
+import {
+  HierarchyItem,
+  HierarchyItemRoom,
+  HierarchyItemSpace,
+  useFetchSpaceHierarchyLevel,
+} from '../../hooks/useSpaceHierarchy';
+import { IPowerLevels, powerLevelAPI } from '../../hooks/usePowerLevels';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { SpaceItemCard } from './SpaceItem';
+import { AfterItemDropTarget, CanDropCallback } from './DnD';
+import { HierarchyItemMenu } from './HierarchyItemMenu';
+import { RoomItemCard } from './RoomItem';
+import { RoomType } from '../../../types/matrix/room';
+import { SequenceCard } from '../../components/sequence-card';
+
+type SpaceHierarchyProps = {
+  summary: IHierarchyRoom | undefined;
+  spaceItem: HierarchyItemSpace;
+  roomItems?: HierarchyItemRoom[];
+  allJoinedRooms: Set;
+  mDirects: Set;
+  roomsPowerLevels: Map;
+  canEditSpaceChild: (powerLevels: IPowerLevels) => boolean;
+  categoryId: string;
+  closed: boolean;
+  handleClose: MouseEventHandler;
+  draggingItem?: HierarchyItem;
+  onDragging: (item?: HierarchyItem) => void;
+  canDrop: CanDropCallback;
+  nextSpaceId?: string;
+  getRoom: (roomId: string) => Room | undefined;
+  pinned: boolean;
+  togglePinToSidebar: (roomId: string) => void;
+  onSpacesFound: (spaceItems: IHierarchyRoom[]) => void;
+  onOpenRoom: MouseEventHandler;
+};
+export const SpaceHierarchy = forwardRef(
+  (
+    {
+      summary,
+      spaceItem,
+      roomItems,
+      allJoinedRooms,
+      mDirects,
+      roomsPowerLevels,
+      canEditSpaceChild,
+      categoryId,
+      closed,
+      handleClose,
+      draggingItem,
+      onDragging,
+      canDrop,
+      nextSpaceId,
+      getRoom,
+      pinned,
+      togglePinToSidebar,
+      onOpenRoom,
+      onSpacesFound,
+    },
+    ref
+  ) => {
+    const mx = useMatrixClient();
+
+    const { fetching, error, rooms } = useFetchSpaceHierarchyLevel(spaceItem.roomId, true);
+
+    const subspaces = useMemo(() => {
+      const s: Map = new Map();
+      rooms.forEach((r) => {
+        if (r.room_type === RoomType.Space) {
+          s.set(r.room_id, r);
+        }
+      });
+      return s;
+    }, [rooms]);
+
+    const spacePowerLevels = roomsPowerLevels.get(spaceItem.roomId) ?? {};
+    const userPLInSpace = powerLevelAPI.getPowerLevel(
+      spacePowerLevels,
+      mx.getUserId() ?? undefined
+    );
+    const canInviteInSpace = powerLevelAPI.canDoAction(spacePowerLevels, 'invite', userPLInSpace);
+
+    const draggingSpace =
+      draggingItem?.roomId === spaceItem.roomId && draggingItem.parentId === spaceItem.parentId;
+
+    const { parentId } = spaceItem;
+    const parentPowerLevels = parentId ? roomsPowerLevels.get(parentId) ?? {} : undefined;
+
+    useEffect(() => {
+      onSpacesFound(Array.from(subspaces.values()));
+    }, [subspaces, onSpacesFound]);
+
+    let childItems = roomItems?.filter((i) => !subspaces.has(i.roomId));
+    if (!canEditSpaceChild(spacePowerLevels)) {
+      // hide unknown rooms for normal user
+      childItems = childItems?.filter((i) => {
+        const forbidden = error instanceof MatrixError ? error.errcode === 'M_FORBIDDEN' : false;
+        const inaccessibleRoom = !rooms.get(i.roomId) && !fetching && (error ? forbidden : true);
+        return !inaccessibleRoom;
+      });
+    }
+
+    return (
+      
+        
+            )
+          }
+          after={
+            
+          }
+          onDragging={onDragging}
+          data-dragging={draggingSpace}
+        />
+        {childItems && childItems.length > 0 ? (
+          
+            {childItems.map((roomItem, index) => {
+              const roomSummary = rooms.get(roomItem.roomId);
+
+              const roomPowerLevels = roomsPowerLevels.get(roomItem.roomId) ?? {};
+              const userPLInRoom = powerLevelAPI.getPowerLevel(
+                roomPowerLevels,
+                mx.getUserId() ?? undefined
+              );
+              const canInviteInRoom = powerLevelAPI.canDoAction(
+                roomPowerLevels,
+                'invite',
+                userPLInRoom
+              );
+
+              const lastItem = index === childItems.length;
+              const nextRoomId = lastItem ? nextSpaceId : childItems[index + 1]?.roomId;
+
+              const roomDragging =
+                draggingItem?.roomId === roomItem.roomId &&
+                draggingItem.parentId === roomItem.parentId;
+
+              return (
+                
+                  }
+                  after={
+                    
+                  }
+                  data-dragging={roomDragging}
+                  onDragging={onDragging}
+                />
+              );
+            })}
+          
+        ) : (
+          childItems && (
+            
+              
+                
+                  No Rooms
+                
+                
+                  This space does not contains rooms yet.
+                
+              
+            
+          )
+        )}
+      
+    );
+  }
+);
diff --git a/src/app/features/lobby/SpaceItem.tsx b/src/app/features/lobby/SpaceItem.tsx
index deaf9ba5..0a4d9de5 100644
--- a/src/app/features/lobby/SpaceItem.tsx
+++ b/src/app/features/lobby/SpaceItem.tsx
@@ -19,19 +19,16 @@ import {
 import FocusTrap from 'focus-trap-react';
 import classNames from 'classnames';
 import { MatrixError, Room } from 'matrix-js-sdk';
+import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
 import { HierarchyItem } from '../../hooks/useSpaceHierarchy';
 import { useMatrixClient } from '../../hooks/useMatrixClient';
 import { RoomAvatar } from '../../components/room-avatar';
 import { nameInitials } from '../../utils/common';
-import {
-  HierarchyRoomSummaryLoader,
-  LocalRoomSummaryLoader,
-} from '../../components/RoomSummaryLoader';
+import { LocalRoomSummaryLoader } from '../../components/RoomSummaryLoader';
 import { getRoomAvatarUrl } from '../../utils/room';
 import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
 import * as css from './SpaceItem.css';
 import * as styleCss from './style.css';
-import { ErrorCode } from '../../cs-errorcode';
 import { useDraggableItem } from './DnD';
 import { openCreateRoom, openSpaceAddExisting } from '../../../client/action/navigation';
 import { stopPropagation } from '../../utils/keyboard';
@@ -53,18 +50,11 @@ function SpaceProfileLoading() {
   );
 }
 
-type UnknownPrivateSpaceProfileProps = {
+type InaccessibleSpaceProfileProps = {
   roomId: string;
-  name?: string;
-  avatarUrl?: string;
   suggested?: boolean;
 };
-function UnknownPrivateSpaceProfile({
-  roomId,
-  name,
-  avatarUrl,
-  suggested,
-}: UnknownPrivateSpaceProfileProps) {
+function InaccessibleSpaceProfile({ roomId, suggested }: InaccessibleSpaceProfileProps) {
   return (
     
            (
               
-                {nameInitials(name)}
+                U
               
             )}
           />
@@ -88,11 +76,11 @@ function UnknownPrivateSpaceProfile({
     >
       
         
-          {name || 'Unknown'}
+          Unknown
         
 
         
-          Private Space
+          Inaccessible
         
         {suggested && (
           
@@ -104,20 +92,20 @@ function UnknownPrivateSpaceProfile({
   );
 }
 
-type UnknownSpaceProfileProps = {
+type UnjoinedSpaceProfileProps = {
   roomId: string;
   via?: string[];
   name?: string;
   avatarUrl?: string;
   suggested?: boolean;
 };
-function UnknownSpaceProfile({
+function UnjoinedSpaceProfile({
   roomId,
   via,
   name,
   avatarUrl,
   suggested,
-}: UnknownSpaceProfileProps) {
+}: UnjoinedSpaceProfileProps) {
   const mx = useMatrixClient();
 
   const [joinState, join] = useAsyncCallback(
@@ -376,6 +364,8 @@ function AddSpaceButton({ item }: { item: HierarchyItem }) {
 }
 
 type SpaceItemCardProps = {
+  summary: IHierarchyRoom | undefined;
+  loading?: boolean;
   item: HierarchyItem;
   joined?: boolean;
   categoryId: string;
@@ -393,6 +383,8 @@ export const SpaceItemCard = as<'div', SpaceItemCardProps>(
   (
     {
       className,
+      summary,
+      loading,
       joined,
       closed,
       categoryId,
@@ -451,37 +443,31 @@ export const SpaceItemCard = as<'div', SpaceItemCardProps>(
                 }
               
             ) : (
-              
-                {(summaryState) => (
-                  <>
-                    {summaryState.status === AsyncStatus.Loading && }
-                    {summaryState.status === AsyncStatus.Error &&
-                      (summaryState.error.name === ErrorCode.M_FORBIDDEN ? (
-                        
-                      ) : (
-                        
-                      ))}
-                    {summaryState.status === AsyncStatus.Success && (
-                      
-                    )}
-                  
+              <>
+                {!summary &&
+                  (loading ? (
+                    
+                  ) : (
+                    
+                  ))}
+                {summary && (
+                  
                 )}
-              
+              
             )}
           
           {canEditChild && (
diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx
index ef59bf98..ffff0f45 100644
--- a/src/app/features/room-nav/RoomNavItem.tsx
+++ b/src/app/features/room-nav/RoomNavItem.tsx
@@ -39,6 +39,8 @@ import { getMatrixToRoom } from '../../plugins/matrix-to';
 import { getCanonicalAliasOrRoomId, isRoomAlias } from '../../utils/matrix';
 import { getViaServers } from '../../plugins/via-servers';
 import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
+import { useSetting } from '../../state/hooks/settings';
+import { settingsAtom } from '../../state/settings';
 
 type RoomNavItemMenuProps = {
   room: Room;
@@ -47,13 +49,14 @@ type RoomNavItemMenuProps = {
 const RoomNavItemMenu = forwardRef(
   ({ room, requestClose }, ref) => {
     const mx = useMatrixClient();
+    const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
     const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
     const powerLevels = usePowerLevels(room);
     const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels);
     const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? ''));
 
     const handleMarkAsRead = () => {
-      markAsRead(mx, room.roomId);
+      markAsRead(mx, room.roomId, hideActivity);
       requestClose();
     };
 
diff --git a/src/app/features/room/MembersDrawer.tsx b/src/app/features/room/MembersDrawer.tsx
index a4305e45..df8008ca 100644
--- a/src/app/features/room/MembersDrawer.tsx
+++ b/src/app/features/room/MembersDrawer.tsx
@@ -156,7 +156,7 @@ export type MembersFilterOptions = {
 };
 
 const SEARCH_OPTIONS: UseAsyncSearchOptions = {
-  limit: 100,
+  limit: 1000,
   matchOptions: {
     contain: true,
   },
@@ -428,8 +428,9 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
                         }}
                         after={}
                       >
-                        {`${result.items.length || 'No'} ${result.items.length === 1 ? 'Result' : 'Results'
-                          }`}
+                        {`${result.items.length || 'No'} ${
+                          result.items.length === 1 ? 'Result' : 'Results'
+                        }`}
                       
                     )
                   }
@@ -485,15 +486,17 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
                   const member = tagOrMember;
                   const name = getName(member);
                   const avatarMxcUrl = member.getMxcAvatarUrl();
-                  const avatarUrl = avatarMxcUrl ? mx.mxcUrlToHttp(
-                    avatarMxcUrl,
-                    100,
-                    100,
-                    'crop',
-                    undefined,
-                    false,
-                    useAuthentication
-                  ) : undefined;
+                  const avatarUrl = avatarMxcUrl
+                    ? mx.mxcUrlToHttp(
+                        avatarMxcUrl,
+                        100,
+                        100,
+                        'crop',
+                        undefined,
+                        false,
+                        useAuthentication
+                      )
+                    : undefined;
 
                   return (
                      {
         if (isKeyHotkey('escape', evt)) {
-          markAsRead(mx, room.roomId);
+          markAsRead(mx, room.roomId, hideActivity);
         }
       },
-      [mx, room.roomId]
+      [mx, room.roomId, hideActivity]
     )
   );
 
diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx
index a5af7f07..eb214f62 100644
--- a/src/app/features/room/RoomInput.tsx
+++ b/src/app/features/room/RoomInput.tsx
@@ -53,6 +53,7 @@ import {
   isEmptyEditor,
   getBeginCommand,
   trimCommand,
+  getMentions,
 } from '../../components/editor';
 import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
 import { UseStateProvider } from '../../components/UseStateProvider';
@@ -69,6 +70,7 @@ import { useFilePasteHandler } from '../../hooks/useFilePasteHandler';
 import { useFileDropZone } from '../../hooks/useFileDrop';
 import {
   TUploadItem,
+  TUploadMetadata,
   roomIdToMsgDraftAtomFamily,
   roomIdToReplyDraftAtomFamily,
   roomIdToUploadItemsAtomFamily,
@@ -102,12 +104,9 @@ import colorMXID from '../../../util/colorMXID';
 import {
   getAllParents,
   getMemberDisplayName,
-  parseReplyBody,
-  parseReplyFormattedBody,
+  getMentionContent,
   trimReplyFromBody,
-  trimReplyFromFormattedBody,
 } from '../../utils/room';
-import { sanitizeText } from '../../utils/sanitize';
 import { CommandAutocomplete } from './CommandAutocomplete';
 import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '../../hooks/useCommands';
 import { mobileOrTablet } from '../../utils/user-agent';
@@ -128,6 +127,7 @@ export const RoomInput = forwardRef(
     const useAuthentication = useMediaAuthentication();
     const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
     const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
+    const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
     const commands = useCommands(mx, room);
     const emojiBtnRef = useRef(null);
     const roomToParents = useAtomValue(roomToParentsAtom);
@@ -222,6 +222,17 @@ export const RoomInput = forwardRef(
       [roomId, editor, setMsgDraft]
     );
 
+    const handleFileMetadata = useCallback(
+      (fileItem: TUploadItem, metadata: TUploadMetadata) => {
+        setSelectedFiles({
+          type: 'REPLACE',
+          item: fileItem,
+          replacement: { ...fileItem, metadata },
+        });
+      },
+      [setSelectedFiles]
+    );
+
     const handleRemoveUpload = useCallback(
       (upload: TUploadContent | TUploadContent[]) => {
         const uploads = Array.isArray(upload) ? upload : [upload];
@@ -268,8 +279,7 @@ export const RoomInput = forwardRef(
       uploadBoardHandlers.current?.handleSend();
 
       const commandName = getBeginCommand(editor);
-
-      let plainText = toPlainText(editor.children).trim();
+      let plainText = toPlainText(editor.children, isMarkdown).trim();
       let customHtml = trimCustomHtml(
         toMatrixCustomHTML(editor.children, {
           allowTextFormatting: true,
@@ -309,25 +319,22 @@ export const RoomInput = forwardRef(
 
       if (plainText === '') return;
 
-      let body = plainText;
-      let formattedBody = customHtml;
-      if (replyDraft) {
-        body = parseReplyBody(replyDraft.userId, trimReplyFromBody(replyDraft.body)) + body;
-        formattedBody =
-          parseReplyFormattedBody(
-            roomId,
-            replyDraft.userId,
-            replyDraft.eventId,
-            replyDraft.formattedBody
-              ? trimReplyFromFormattedBody(replyDraft.formattedBody)
-              : sanitizeText(replyDraft.body)
-          ) + formattedBody;
-      }
+      const body = plainText;
+      const formattedBody = customHtml;
+      const mentionData = getMentions(mx, roomId, editor);
 
       const content: IContent = {
         msgtype: msgType,
         body,
       };
+
+      if (replyDraft && replyDraft.userId !== mx.getUserId()) {
+        mentionData.users.add(replyDraft.userId);
+      }
+
+      const mMentions = getMentionContent(Array.from(mentionData.users), mentionData.room);
+      content['m.mentions'] = mMentions;
+
       if (replyDraft || !customHtmlEqualsPlainText(formattedBody, body)) {
         content.format = 'org.matrix.custom.html';
         content.formatted_body = formattedBody;
@@ -359,10 +366,14 @@ export const RoomInput = forwardRef(
         }
         if (isKeyHotkey('escape', evt)) {
           evt.preventDefault();
+          if (autocompleteQuery) {
+            setAutocompleteQuery(undefined);
+            return;
+          }
           setReplyDraft(undefined);
         }
       },
-      [submit, setReplyDraft, enterForNewline]
+      [submit, setReplyDraft, enterForNewline, autocompleteQuery]
     );
 
     const handleKeyUp: KeyboardEventHandler = useCallback(
@@ -372,7 +383,9 @@ export const RoomInput = forwardRef(
           return;
         }
 
-        sendTypingStatus(!isEmptyEditor(editor));
+        if (!hideActivity) {
+          sendTypingStatus(!isEmptyEditor(editor));
+        }
 
         const prevWordRange = getPrevWorldRange(editor);
         const query = prevWordRange
@@ -380,7 +393,7 @@ export const RoomInput = forwardRef(
           : undefined;
         setAutocompleteQuery(query);
       },
-      [editor, sendTypingStatus]
+      [editor, sendTypingStatus, hideActivity]
     );
 
     const handleCloseAutocomplete = useCallback(() => {
@@ -435,13 +448,7 @@ export const RoomInput = forwardRef(
                         key={index}
                         isEncrypted={!!fileItem.encInfo}
                         fileItem={fileItem}
-                        setMetadata={(metadata) =>
-                          setSelectedFiles({
-                            type: 'REPLACE',
-                            item: fileItem,
-                            replacement: { ...fileItem, metadata },
-                          })
-                        }
+                        setMetadata={handleFileMetadata}
                         onRemove={handleRemoveUpload}
                       />
                     ))}
@@ -593,8 +600,13 @@ export const RoomInput = forwardRef(
                         onCustomEmojiSelect={handleEmoticonSelect}
                         onStickerSelect={handleStickerSelect}
                         requestClose={() => {
-                          setEmojiBoardTab(undefined);
-                          if (!mobileOrTablet()) ReactEditor.focus(editor);
+                          setEmojiBoardTab((t) => {
+                            if (t) {
+                              if (!mobileOrTablet()) ReactEditor.focus(editor);
+                              return undefined;
+                            }
+                            return t;
+                          });
                         }}
                       />
                     }
diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx
index 38b67baa..b0a76505 100644
--- a/src/app/features/room/RoomTimeline.tsx
+++ b/src/app/features/room/RoomTimeline.tsx
@@ -117,6 +117,7 @@ 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';
 
 const TimelineFloat = as<'div', css.TimelineFloatVariants>(
   ({ position, className, ...props }, ref) => (
@@ -424,6 +425,7 @@ const getRoomUnreadInfo = (room: Room, scrollTo = false) => {
 export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimelineProps) {
   const mx = useMatrixClient();
   const useAuthentication = useMediaAuthentication();
+  const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
   const [messageLayout] = useSetting(settingsAtom, 'messageLayout');
   const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing');
   const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
@@ -433,6 +435,10 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
   const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
   const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
   const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
+
+  const ignoredUsersList = useIgnoredUsers();
+  const ignoredUsersSet = useMemo(() => new Set(ignoredUsersList), [ignoredUsersList]);
+
   const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
   const powerLevels = usePowerLevelsContext();
   const { canDoAction, canSendEvent, canSendStateEvent, getPowerLevel } =
@@ -586,15 +592,19 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
         // so timeline can be updated with evt like: edits, reactions etc
         if (atBottomRef.current) {
           if (document.hasFocus() && (!unreadInfo || mEvt.getSender() === mx.getUserId())) {
-            requestAnimationFrame(() => markAsRead(mx, mEvt.getRoomId()!));
+            // Check if the document is in focus (user is actively viewing the app),
+            // and either there are no unread messages or the latest message is from the current user.
+            // If either condition is met, trigger the markAsRead function to send a read receipt.
+            requestAnimationFrame(() => markAsRead(mx, mEvt.getRoomId()!, hideActivity));
           }
 
-          if (document.hasFocus()) {
-            scrollToBottomRef.current.count += 1;
-            scrollToBottomRef.current.smooth = true;
-          } else if (!unreadInfo) {
+          if (!document.hasFocus() && !unreadInfo) {
             setUnreadInfo(getRoomUnreadInfo(room));
           }
+
+          scrollToBottomRef.current.count += 1;
+          scrollToBottomRef.current.smooth = true;
+
           setTimeline((ct) => ({
             ...ct,
             range: {
@@ -609,10 +619,40 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
           setUnreadInfo(getRoomUnreadInfo(room));
         }
       },
-      [mx, room, unreadInfo]
+      [mx, room, unreadInfo, hideActivity]
     )
   );
 
+  const handleOpenEvent = useCallback(
+    async (
+      evtId: string,
+      highlight = true,
+      onScroll: ((scrolled: boolean) => void) | undefined = undefined
+    ) => {
+      const evtTimeline = getEventTimeline(room, evtId);
+      const absoluteIndex =
+        evtTimeline && getEventIdAbsoluteIndex(timeline.linkedTimelines, evtTimeline, evtId);
+
+      if (typeof absoluteIndex === 'number') {
+        const scrolled = scrollToItem(absoluteIndex, {
+          behavior: 'smooth',
+          align: 'center',
+          stopInView: true,
+        });
+        if (onScroll) onScroll(scrolled);
+        setFocusItem({
+          index: absoluteIndex,
+          scrollTo: false,
+          highlight,
+        });
+      } else {
+        setTimeline(getEmptyTimeline());
+        loadEventTimeline(evtId);
+      }
+    },
+    [room, timeline, scrollToItem, loadEventTimeline]
+  );
+
   useLiveTimelineRefresh(
     room,
     useCallback(() => {
@@ -646,16 +686,17 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
   );
 
   const tryAutoMarkAsRead = useCallback(() => {
-    if (!unreadInfo) {
-      requestAnimationFrame(() => markAsRead(mx, room.roomId));
+    const readUptoEventId = readUptoEventIdRef.current;
+    if (!readUptoEventId) {
+      requestAnimationFrame(() => markAsRead(mx, room.roomId, hideActivity));
       return;
     }
-    const evtTimeline = getEventTimeline(room, unreadInfo.readUptoEventId);
+    const evtTimeline = getEventTimeline(room, readUptoEventId);
     const latestTimeline = evtTimeline && getFirstLinkedTimeline(evtTimeline, Direction.Forward);
     if (latestTimeline === room.getLiveTimeline()) {
-      requestAnimationFrame(() => markAsRead(mx, room.roomId));
+      requestAnimationFrame(() => markAsRead(mx, room.roomId, hideActivity));
     }
-  }, [mx, room, unreadInfo]);
+  }, [mx, room, hideActivity]);
 
   const debounceSetAtBottom = useDebounce(
     useCallback((entry: IntersectionObserverEntry) => {
@@ -672,7 +713,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
         if (targetEntry) debounceSetAtBottom(targetEntry);
         if (targetEntry?.isIntersecting && atLiveEndRef.current) {
           setAtBottom(true);
-          tryAutoMarkAsRead();
+          if (document.hasFocus()) {
+            tryAutoMarkAsRead();
+          }
         }
       },
       [debounceSetAtBottom, tryAutoMarkAsRead]
@@ -691,10 +734,20 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
     useCallback(
       (inFocus) => {
         if (inFocus && atBottomRef.current) {
+          if (unreadInfo?.inLiveTimeline) {
+            handleOpenEvent(unreadInfo.readUptoEventId, false, (scrolled) => {
+              // the unread event is already in view
+              // so, try mark as read;
+              if (!scrolled) {
+                tryAutoMarkAsRead();
+              }
+            });
+            return;
+          }
           tryAutoMarkAsRead();
         }
       },
-      [tryAutoMarkAsRead]
+      [tryAutoMarkAsRead, unreadInfo, handleOpenEvent]
     )
   );
 
@@ -825,34 +878,16 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
   };
 
   const handleMarkAsRead = () => {
-    markAsRead(mx, room.roomId);
+    markAsRead(mx, room.roomId, hideActivity);
   };
 
   const handleOpenReply: MouseEventHandler = useCallback(
     async (evt) => {
       const targetId = evt.currentTarget.getAttribute('data-event-id');
       if (!targetId) return;
-      const replyTimeline = getEventTimeline(room, targetId);
-      const absoluteIndex =
-        replyTimeline && getEventIdAbsoluteIndex(timeline.linkedTimelines, replyTimeline, targetId);
-
-      if (typeof absoluteIndex === 'number') {
-        scrollToItem(absoluteIndex, {
-          behavior: 'smooth',
-          align: 'center',
-          stopInView: true,
-        });
-        setFocusItem({
-          index: absoluteIndex,
-          scrollTo: false,
-          highlight: true,
-        });
-      } else {
-        setTimeline(getEmptyTimeline());
-        loadEventTimeline(targetId);
-      }
+      handleOpenEvent(targetId);
     },
-    [room, timeline, scrollToItem, loadEventTimeline]
+    [handleOpenEvent]
   );
 
   const handleUserClick: MouseEventHandler = useCallback(
@@ -1018,6 +1053,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
                 />
               )
             }
+            hideReadReceipts={hideActivity}
           >
             {mEvent.isRedacted() ? (
               
@@ -1090,6 +1126,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
                 />
               )
             }
+            hideReadReceipts={hideActivity}
           >
             
               {() => {
@@ -1186,6 +1223,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
                 />
               )
             }
+            hideReadReceipts={hideActivity}
           >
             {mEvent.isRedacted() ? (
               
@@ -1227,6 +1265,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
             highlight={highlighted}
             messageSpacing={messageSpacing}
             canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
+            hideReadReceipts={hideActivity}
           >
             
             
             
             
           
           
           
             
diff --git a/src/app/features/room/RoomView.tsx b/src/app/features/room/RoomView.tsx
index 250afc93..0eb6bff1 100644
--- a/src/app/features/room/RoomView.tsx
+++ b/src/app/features/room/RoomView.tsx
@@ -13,13 +13,16 @@ import { RoomTimeline } from './RoomTimeline';
 import { RoomViewTyping } from './RoomViewTyping';
 import { RoomTombstone } from './RoomTombstone';
 import { RoomInput } from './RoomInput';
-import { RoomViewFollowing } from './RoomViewFollowing';
+import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing';
 import { Page } from '../../components/page';
 import { RoomViewHeader } from './RoomViewHeader';
 import { useKeyDown } from '../../hooks/useKeyDown';
 import { editableActiveElement } from '../../utils/dom';
 import navigation from '../../../client/state/navigation';
+import { settingsAtom } from '../../state/settings';
+import { useSetting } from '../../state/hooks/settings';
 
+const FN_KEYS_REGEX = /^F\d+$/;
 const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
   const { code } = evt;
   if (evt.metaKey || evt.altKey || evt.ctrlKey) {
@@ -27,7 +30,7 @@ const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
   }
 
   // do not focus on F keys
-  if (/^F\d+$/.test(code)) return false;
+  if (FN_KEYS_REGEX.test(code)) return false;
 
   // do not focus on numlock/scroll lock
   if (
@@ -56,6 +59,8 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
   const roomInputRef = useRef(null);
   const roomViewRef = useRef(null);
 
+  const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
+
   const { roomId } = room;
   const editor = useEditor();
 
@@ -132,7 +137,7 @@ export function RoomView({ room, eventId }: { room: Room; eventId?: string }) {
             
           )}
         
- + {hideActivity ? : } ); diff --git a/src/app/features/room/RoomViewFollowing.css.ts b/src/app/features/room/RoomViewFollowing.css.ts index 0a0358e0..18b53ac9 100644 --- a/src/app/features/room/RoomViewFollowing.css.ts +++ b/src/app/features/room/RoomViewFollowing.css.ts @@ -1,6 +1,14 @@ +import { style } from '@vanilla-extract/css'; import { recipe } from '@vanilla-extract/recipes'; import { DefaultReset, color, config, toRem } from 'folds'; +export const RoomViewFollowingPlaceholder = style([ + DefaultReset, + { + height: toRem(28), + }, +]); + export const RoomViewFollowing = recipe({ base: [ DefaultReset, diff --git a/src/app/features/room/RoomViewFollowing.tsx b/src/app/features/room/RoomViewFollowing.tsx index 58d3f64f..5a96e6ad 100644 --- a/src/app/features/room/RoomViewFollowing.tsx +++ b/src/app/features/room/RoomViewFollowing.tsx @@ -24,6 +24,10 @@ import { useRoomEventReaders } from '../../hooks/useRoomEventReaders'; import { EventReaders } from '../../components/event-readers'; import { stopPropagation } from '../../utils/keyboard'; +export function RoomViewFollowingPlaceholder() { + return
; +} + export type RoomViewFollowingProps = { room: Room; }; diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index 7ee1d302..deac935e 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -33,7 +33,7 @@ import { RoomTopicViewer } from '../../components/room-topic-viewer'; import { StateEvent } from '../../../types/matrix/room'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useRoom } from '../../hooks/useRoom'; -import { useSetSetting } from '../../state/hooks/settings'; +import { useSetSetting, useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; import { useSpaceOptionally } from '../../hooks/useSpace'; import { getHomeSearchPath, getSpaceSearchPath, withSearchParam } from '../../pages/pathUtils'; @@ -64,13 +64,14 @@ type RoomMenuProps = { }; const RoomMenu = forwardRef(({ room, requestClose }, ref) => { const mx = useMatrixClient(); + const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const unread = useRoomUnread(room.roomId, roomToUnreadAtom); const powerLevels = usePowerLevelsContext(); const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels); const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? '')); const handleMarkAsRead = () => { - markAsRead(mx, room.roomId); + markAsRead(mx, room.roomId, hideActivity); requestClose(); }; diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index bdf52059..d6709a97 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -35,7 +35,7 @@ import { useHover, useFocusWithin } from 'react-aria'; import { MatrixEvent, Room } from 'matrix-js-sdk'; import { Relations } from 'matrix-js-sdk/lib/models/relations'; import classNames from 'classnames'; -import { EventType, RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types'; +import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types'; import { AvatarBase, BubbleLayout, @@ -671,6 +671,7 @@ export type MessageProps = { onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void; reply?: ReactNode; reactions?: ReactNode; + hideReadReceipts?: boolean; }; export const Message = as<'div', MessageProps>( ( @@ -695,6 +696,7 @@ export const Message = as<'div', MessageProps>( onEditId, reply, reactions, + hideReadReceipts, children, ...props }, @@ -992,11 +994,13 @@ export const Message = as<'div', MessageProps>( )} - + {!hideReadReceipts && ( + + )} {canPinEvent && ( @@ -1071,9 +1075,23 @@ export type EventProps = { highlight: boolean; canDelete?: boolean; messageSpacing: MessageSpacing; + hideReadReceipts?: boolean; }; export const Event = as<'div', EventProps>( - ({ className, room, mEvent, highlight, canDelete, messageSpacing, children, ...props }, ref) => { + ( + { + className, + room, + mEvent, + highlight, + canDelete, + messageSpacing, + hideReadReceipts, + children, + ...props + }, + ref + ) => { const mx = useMatrixClient(); const [hover, setHover] = useState(false); const { hoverProps } = useHover({ onHoverChange: setHover }); @@ -1138,11 +1156,13 @@ export const Event = as<'div', EventProps>( > - + {!hideReadReceipts && ( + + )} diff --git a/src/app/features/room/message/MessageEditor.tsx b/src/app/features/room/message/MessageEditor.tsx index 0c995030..ac97e2aa 100644 --- a/src/app/features/room/message/MessageEditor.tsx +++ b/src/app/features/room/message/MessageEditor.tsx @@ -21,7 +21,7 @@ import { } from 'folds'; import { Editor, Transforms } from 'slate'; import { ReactEditor } from 'slate-react'; -import { IContent, MatrixEvent, RelationType, Room } from 'matrix-js-sdk'; +import { IContent, IMentions, MatrixEvent, RelationType, Room } from 'matrix-js-sdk'; import { isKeyHotkey } from 'is-hotkey'; import { AUTOCOMPLETE_PREFIXES, @@ -43,6 +43,7 @@ import { toPlainText, trimCustomHtml, useEditor, + getMentions, } from '../../../components/editor'; import { useSetting } from '../../../state/hooks/settings'; import { settingsAtom } from '../../../state/settings'; @@ -50,7 +51,7 @@ import { UseStateProvider } from '../../../components/UseStateProvider'; import { EmojiBoard } from '../../../components/emoji-board'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { getEditedEvent, trimReplyFromFormattedBody } from '../../../utils/room'; +import { getEditedEvent, getMentionContent, trimReplyFromFormattedBody } from '../../../utils/room'; import { mobileOrTablet } from '../../../utils/user-agent'; type MessageEditorProps = { @@ -74,25 +75,29 @@ export const MessageEditor = as<'div', MessageEditorProps>( const getPrevBodyAndFormattedBody = useCallback((): [ string | undefined, - string | undefined + string | undefined, + IMentions | undefined ] => { const evtId = mEvent.getId()!; const evtTimeline = room.getTimelineForEvent(evtId); const editedEvent = evtTimeline && getEditedEvent(evtId, mEvent, evtTimeline.getTimelineSet()); - const { body, formatted_body: customHtml }: Record = - editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent(); + const content: IContent = editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent(); + const { body, formatted_body: customHtml }: Record = content; + + const mMentions: IMentions | undefined = content['m.mentions']; return [ typeof body === 'string' ? body : undefined, typeof customHtml === 'string' ? customHtml : undefined, + mMentions, ]; }, [room, mEvent]); const [saveState, save] = useAsyncCallback( useCallback(async () => { - const plainText = toPlainText(editor.children).trim(); + const plainText = toPlainText(editor.children, isMarkdown).trim(); const customHtml = trimCustomHtml( toMatrixCustomHTML(editor.children, { allowTextFormatting: true, @@ -101,7 +106,7 @@ export const MessageEditor = as<'div', MessageEditorProps>( }) ); - const [prevBody, prevCustomHtml] = getPrevBodyAndFormattedBody(); + const [prevBody, prevCustomHtml, prevMentions] = getPrevBodyAndFormattedBody(); if (plainText === '') return undefined; if (prevBody) { @@ -122,6 +127,15 @@ export const MessageEditor = as<'div', MessageEditorProps>( body: plainText, }; + const mentionData = getMentions(mx, roomId, editor); + + prevMentions?.user_ids?.forEach((prevMentionId) => { + mentionData.users.add(prevMentionId); + }); + + const mMentions = getMentionContent(Array.from(mentionData.users), mentionData.room); + newContent['m.mentions'] = mMentions; + if (!customHtmlEqualsPlainText(customHtml, plainText)) { newContent.format = 'org.matrix.custom.html'; newContent.formatted_body = customHtml; @@ -192,8 +206,8 @@ export const MessageEditor = as<'div', MessageEditorProps>( const initialValue = typeof customHtml === 'string' - ? htmlToEditorInput(customHtml) - : plainToEditorInput(typeof body === 'string' ? body : ''); + ? htmlToEditorInput(customHtml, isMarkdown) + : plainToEditorInput(typeof body === 'string' ? body : '', isMarkdown); Transforms.select(editor, { anchor: Editor.start(editor, []), @@ -202,7 +216,7 @@ export const MessageEditor = as<'div', MessageEditorProps>( editor.insertFragment(initialValue); if (!mobileOrTablet()) ReactEditor.focus(editor); - }, [editor, getPrevBodyAndFormattedBody]); + }, [editor, getPrevBodyAndFormattedBody, isMarkdown]); useEffect(() => { if (saveState.status === AsyncStatus.Success) { @@ -291,8 +305,13 @@ export const MessageEditor = as<'div', MessageEditorProps>( onEmojiSelect={handleEmoticonSelect} onCustomEmojiSelect={handleEmoticonSelect} requestClose={() => { - setAnchor(undefined); - if (!mobileOrTablet()) ReactEditor.focus(editor); + setAnchor((v) => { + if (v) { + if (!mobileOrTablet()) ReactEditor.focus(editor); + return undefined; + } + return v; + }); }} /> } diff --git a/src/app/features/settings/developer-tools/AccountData.tsx b/src/app/features/settings/developer-tools/AccountData.tsx new file mode 100644 index 00000000..743c28b7 --- /dev/null +++ b/src/app/features/settings/developer-tools/AccountData.tsx @@ -0,0 +1,90 @@ +import React, { useCallback, useState } from 'react'; +import { Box, Text, Icon, Icons, Chip, Button } from 'folds'; +import { SequenceCard } from '../../../components/sequence-card'; +import { SequenceCardStyle } from '../styles.css'; +import { SettingTile } from '../../../components/setting-tile'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { useAccountDataCallback } from '../../../hooks/useAccountDataCallback'; + +type AccountDataProps = { + expand: boolean; + onExpandToggle: (expand: boolean) => void; + onSelect: (type: string | null) => void; +}; +export function AccountData({ expand, onExpandToggle, onSelect }: AccountDataProps) { + const mx = useMatrixClient(); + const [accountData, setAccountData] = useState(() => Array.from(mx.store.accountData.values())); + + useAccountDataCallback( + mx, + useCallback( + () => setAccountData(Array.from(mx.store.accountData.values())), + [mx, setAccountData] + ) + ); + + return ( + + Account Data + + onExpandToggle(!expand)} + variant="Secondary" + fill="Soft" + size="300" + radii="300" + outlined + before={ + + } + > + {expand ? 'Collapse' : 'Expand'} + + } + /> + {expand && ( + + + Types + + } + onClick={() => onSelect(null)} + > + + Add New + + + {accountData.map((mEvent) => ( + onSelect(mEvent.getType())} + > + + {mEvent.getType()} + + + ))} + + + + )} + + + ); +} diff --git a/src/app/features/settings/developer-tools/AccountDataEditor.tsx b/src/app/features/settings/developer-tools/AccountDataEditor.tsx index 52e9870e..b5ac0f8a 100644 --- a/src/app/features/settings/developer-tools/AccountDataEditor.tsx +++ b/src/app/features/settings/developer-tools/AccountDataEditor.tsx @@ -8,9 +8,7 @@ import React, { useState, } from 'react'; import { - as, Box, - Header, Text, Icon, Icons, @@ -20,6 +18,9 @@ import { TextArea as TextAreaComponent, color, Spinner, + Chip, + Scroll, + config, } from 'folds'; import { isKeyHotkey } from 'is-hotkey'; import { MatrixError } from 'matrix-js-sdk'; @@ -30,182 +31,302 @@ import { GetTarget } from '../../../plugins/text-area/type'; import { syntaxErrorPosition } from '../../../utils/dom'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { Page, PageHeader } from '../../../components/page'; +import { useAlive } from '../../../hooks/useAlive'; +import { SequenceCard } from '../../../components/sequence-card'; +import { TextViewerContent } from '../../../components/text-viewer'; const EDITOR_INTENT_SPACE_COUNT = 2; +type AccountDataInfo = { + type: string; + content: object; +}; + +type AccountDataEditProps = { + type: string; + defaultContent: string; + onCancel: () => void; + onSave: (info: AccountDataInfo) => void; +}; +function AccountDataEdit({ type, defaultContent, onCancel, onSave }: AccountDataEditProps) { + const mx = useMatrixClient(); + const alive = useAlive(); + + const textAreaRef = useRef(null); + const [jsonError, setJSONError] = useState(); + + const getTarget: GetTarget = useCallback(() => { + const target = textAreaRef.current; + if (!target) throw new Error('TextArea element not found!'); + return target; + }, []); + + const { textArea, operations, intent } = useMemo(() => { + const ta = new TextArea(getTarget); + const op = new TextAreaOperations(getTarget); + return { + textArea: ta, + operations: op, + intent: new Intent(EDITOR_INTENT_SPACE_COUNT, ta, op), + }; + }, [getTarget]); + + const intentHandler = useTextAreaIntentHandler(textArea, operations, intent); + + const handleKeyDown: KeyboardEventHandler = (evt) => { + intentHandler(evt); + if (isKeyHotkey('escape', evt)) { + const cursor = Cursor.fromTextAreaElement(getTarget()); + operations.deselect(cursor); + } + }; + + const [submitState, submit] = useAsyncCallback( + useCallback((dataType, data) => mx.setAccountData(dataType, data), [mx]) + ); + const submitting = submitState.status === AsyncStatus.Loading; + + const handleSubmit: FormEventHandler = (evt) => { + evt.preventDefault(); + if (submitting) return; + + const target = evt.target as HTMLFormElement | undefined; + const typeInput = target?.typeInput as HTMLInputElement | undefined; + const contentTextArea = target?.contentTextArea as HTMLTextAreaElement | undefined; + if (!typeInput || !contentTextArea) return; + + const typeStr = typeInput.value.trim(); + const contentStr = contentTextArea.value.trim(); + + let parsedContent: object; + try { + parsedContent = JSON.parse(contentStr); + } catch (e) { + setJSONError(e as SyntaxError); + return; + } + setJSONError(undefined); + + if ( + !typeStr || + parsedContent === null || + defaultContent === JSON.stringify(parsedContent, null, EDITOR_INTENT_SPACE_COUNT) + ) { + return; + } + + submit(typeStr, parsedContent).then(() => { + if (alive()) { + onSave({ + type: typeStr, + content: parsedContent, + }); + } + }); + }; + + useEffect(() => { + if (jsonError) { + const errorPosition = syntaxErrorPosition(jsonError) ?? 0; + const cursor = new Cursor(errorPosition, errorPosition, 'none'); + operations.select(cursor); + getTarget()?.focus(); + } + }, [jsonError, operations, getTarget]); + + return ( + + + Account Data + + + 0 || submitting ? 'SurfaceVariant' : 'Background'} + name="typeInput" + size="400" + radii="300" + readOnly={type.length > 0 || submitting} + defaultValue={type} + required + /> + + + + + + {submitState.status === AsyncStatus.Error && ( + + {submitState.error.message} + + )} + + + + JSON Content + + + {jsonError && ( + + + {jsonError.name}: {jsonError.message} + + + )} + + + ); +} + +type AccountDataViewProps = { + type: string; + defaultContent: string; + onEdit: () => void; +}; +function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps) { + return ( + + + + Account Data + + + + + + JSON Content + + + + + + + + ); +} + export type AccountDataEditorProps = { type?: string; - content?: object; requestClose: () => void; }; -export const AccountDataEditor = as<'div', AccountDataEditorProps>( - ({ type, content, requestClose, ...props }, ref) => { - const mx = useMatrixClient(); - const defaultContent = useMemo( - () => JSON.stringify(content, null, EDITOR_INTENT_SPACE_COUNT), - [content] - ); - const textAreaRef = useRef(null); - const [jsonError, setJSONError] = useState(); +export function AccountDataEditor({ type, requestClose }: AccountDataEditorProps) { + const mx = useMatrixClient(); - const getTarget: GetTarget = useCallback(() => { - const target = textAreaRef.current; - if (!target) throw new Error('TextArea element not found!'); - return target; - }, []); + const [data, setData] = useState({ + type: type ?? '', + content: mx.getAccountData(type ?? '')?.getContent() ?? {}, + }); - const { textArea, operations, intent } = useMemo(() => { - const ta = new TextArea(getTarget); - const op = new TextAreaOperations(getTarget); - return { - textArea: ta, - operations: op, - intent: new Intent(EDITOR_INTENT_SPACE_COUNT, ta, op), - }; - }, [getTarget]); + const [edit, setEdit] = useState(!type); - const intentHandler = useTextAreaIntentHandler(textArea, operations, intent); + const closeEdit = useCallback(() => { + if (!type) { + requestClose(); + return; + } + setEdit(false); + }, [type, requestClose]); - const handleKeyDown: KeyboardEventHandler = (evt) => { - intentHandler(evt); - if (isKeyHotkey('escape', evt)) { - const cursor = Cursor.fromTextAreaElement(getTarget()); - operations.deselect(cursor); - } - }; + const handleSave = useCallback((info: AccountDataInfo) => { + setData(info); + setEdit(false); + }, []); - const [submitState, submit] = useAsyncCallback( - useCallback((dataType, data) => mx.setAccountData(dataType, data), [mx]) - ); - const submitting = submitState.status === AsyncStatus.Loading; + const contentJSONStr = useMemo( + () => JSON.stringify(data.content, null, EDITOR_INTENT_SPACE_COUNT), + [data.content] + ); - const handleSubmit: FormEventHandler = (evt) => { - evt.preventDefault(); - if (submitting) return; - - const target = evt.target as HTMLFormElement | undefined; - const typeInput = target?.typeInput as HTMLInputElement | undefined; - const contentTextArea = target?.contentTextArea as HTMLTextAreaElement | undefined; - if (!typeInput || !contentTextArea) return; - - const typeStr = typeInput.value.trim(); - const contentStr = contentTextArea.value.trim(); - - let parsedContent: object; - try { - parsedContent = JSON.parse(contentStr); - } catch (e) { - setJSONError(e as SyntaxError); - return; - } - setJSONError(undefined); - - if ( - !typeStr || - parsedContent === null || - defaultContent === JSON.stringify(parsedContent, null, EDITOR_INTENT_SPACE_COUNT) - ) { - return; - } - - submit(typeStr, parsedContent); - }; - - useEffect(() => { - if (jsonError) { - const errorPosition = syntaxErrorPosition(jsonError) ?? 0; - const cursor = new Cursor(errorPosition, errorPosition, 'none'); - operations.select(cursor); - getTarget()?.focus(); - } - }, [jsonError, operations, getTarget]); - - useEffect(() => { - if (submitState.status === AsyncStatus.Success) { - requestClose(); - } - }, [submitState, requestClose]); - - return ( - -
- - - - Account Data - - - - - - - + return ( + + + + + } + > + Developer Tools + -
- - - Type - - - - - - - - {submitState.status === AsyncStatus.Error && ( - - {submitState.error.message} - - )} - - - - JSON Content - - - {jsonError && ( - - - {jsonError.name}: {jsonError.message} - - - )} + + + + + + + {edit ? ( + + ) : ( + setEdit(true)} + /> + )} - ); - } -); + + ); +} diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx index 081a26e3..b66452f5 100644 --- a/src/app/features/settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/settings/developer-tools/DevelopTools.tsx @@ -1,26 +1,5 @@ -import React, { MouseEventHandler, useCallback, useState } from 'react'; -import { - Box, - Text, - IconButton, - Icon, - Icons, - Scroll, - Switch, - Overlay, - OverlayBackdrop, - OverlayCenter, - Modal, - Chip, - Button, - PopOut, - RectCords, - Menu, - config, - MenuItem, -} from 'folds'; -import { MatrixEvent } from 'matrix-js-sdk'; -import FocusTrap from 'focus-trap-react'; +import React, { useState } from 'react'; +import { Box, Text, IconButton, Icon, Icons, Scroll, Switch, Button } from 'folds'; import { Page, PageContent, PageHeader } from '../../../components/page'; import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCardStyle } from '../styles.css'; @@ -28,195 +7,9 @@ import { SettingTile } from '../../../components/setting-tile'; import { useSetting } from '../../../state/hooks/settings'; import { settingsAtom } from '../../../state/settings'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { useAccountDataCallback } from '../../../hooks/useAccountDataCallback'; -import { TextViewer } from '../../../components/text-viewer'; -import { stopPropagation } from '../../../utils/keyboard'; import { AccountDataEditor } from './AccountDataEditor'; import { copyToClipboard } from '../../../utils/dom'; - -function AccountData() { - const mx = useMatrixClient(); - const [view, setView] = useState(false); - const [accountData, setAccountData] = useState(() => Array.from(mx.store.accountData.values())); - const [selectedEvent, selectEvent] = useState(); - const [menuCords, setMenuCords] = useState(); - const [selectedOption, selectOption] = useState<'edit' | 'inspect'>(); - - useAccountDataCallback( - mx, - useCallback( - () => setAccountData(Array.from(mx.store.accountData.values())), - [mx, setAccountData] - ) - ); - - const handleMenu: MouseEventHandler = (evt) => { - const target = evt.currentTarget; - const eventType = target.getAttribute('data-event-type'); - if (eventType) { - const mEvent = accountData.find((mEvt) => mEvt.getType() === eventType); - setMenuCords(evt.currentTarget.getBoundingClientRect()); - selectEvent(mEvent); - } - }; - - const handleMenuClose = () => setMenuCords(undefined); - - const handleEdit = () => { - selectOption('edit'); - setMenuCords(undefined); - }; - const handleInspect = () => { - selectOption('inspect'); - setMenuCords(undefined); - }; - const handleClose = useCallback(() => { - selectEvent(undefined); - selectOption(undefined); - }, []); - - return ( - - Account Data - - setView(!view)} - variant="Secondary" - fill="Soft" - size="300" - radii="300" - outlined - before={ - - } - > - {view ? 'Collapse' : 'Expand'} - - } - /> - {view && ( - - - Types - - } - > - - Add New - - - {accountData.map((mEvent) => ( - - - {mEvent.getType()} - - - ))} - - - - )} - - evt.key === 'ArrowDown' || evt.key === 'ArrowRight', - isKeyBackward: (evt: KeyboardEvent) => - evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', - escapeDeactivates: stopPropagation, - }} - > - - - - Inspect - - - Edit - - - - - } - /> - - {selectedEvent && selectedOption === 'inspect' && ( - }> - - - - - - - - - )} - {selectedOption === 'edit' && ( - }> - - - - - - - - - )} - - ); -} +import { AccountData } from './AccountData'; type DeveloperToolsProps = { requestClose: () => void; @@ -224,6 +17,17 @@ type DeveloperToolsProps = { export function DeveloperTools({ requestClose }: DeveloperToolsProps) { const mx = useMatrixClient(); const [developerTools, setDeveloperTools] = useSetting(settingsAtom, 'developerTools'); + const [expand, setExpend] = useState(false); + const [accountDataType, setAccountDataType] = useState(); + + if (accountDataType !== undefined) { + return ( + setAccountDataType(undefined)} + /> + ); + } return ( @@ -292,7 +96,13 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) { )} - {developerTools && } + {developerTools && ( + + )}
diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index d58c99ca..569cd410 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -344,6 +344,7 @@ function Appearance() { function Editor() { const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline'); const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown'); + const [hideActivity, setHideActivity] = useSetting(settingsAtom, 'hideActivity'); return ( @@ -363,6 +364,13 @@ function Editor() { after={} /> + + } + /> + ); } @@ -555,7 +563,13 @@ function Messages() { setMediaAutoLoad(!v)} />} + after={ + setMediaAutoLoad(!v)} + /> + } /> diff --git a/src/app/features/settings/notifications/IgnoredUserList.tsx b/src/app/features/settings/notifications/IgnoredUserList.tsx index 49264e57..0ff3015f 100644 --- a/src/app/features/settings/notifications/IgnoredUserList.tsx +++ b/src/app/features/settings/notifications/IgnoredUserList.tsx @@ -1,17 +1,12 @@ -import React, { ChangeEventHandler, FormEventHandler, useCallback, useMemo, useState } from 'react'; +import React, { ChangeEventHandler, FormEventHandler, useCallback, useState } from 'react'; import { Box, Button, Chip, Icon, IconButton, Icons, Input, Spinner, Text, config } from 'folds'; -import { useAccountData } from '../../../hooks/useAccountData'; -import { AccountDataEvent } from '../../../../types/matrix/accountData'; import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCardStyle } from '../styles.css'; import { SettingTile } from '../../../components/setting-tile'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { isUserId } from '../../../utils/matrix'; - -type IgnoredUserListContent = { - ignored_users?: Record; -}; +import { useIgnoredUsers } from '../../../hooks/useIgnoredUsers'; function IgnoreUserInput({ userList }: { userList: string[] }) { const mx = useMatrixClient(); @@ -129,12 +124,7 @@ function IgnoredUserChip({ userId, userList }: { userId: string; userList: strin } export function IgnoredUserList() { - const ignoredUserListEvt = useAccountData(AccountDataEvent.IgnoredUserList); - const ignoredUsers = useMemo(() => { - const ignoredUsersRecord = - ignoredUserListEvt?.getContent().ignored_users ?? {}; - return Object.keys(ignoredUsersRecord); - }, [ignoredUserListEvt]); + const ignoredUsers = useIgnoredUsers(); return ( diff --git a/src/app/features/settings/notifications/Notifications.tsx b/src/app/features/settings/notifications/Notifications.tsx index 88e16d29..aa339a03 100644 --- a/src/app/features/settings/notifications/Notifications.tsx +++ b/src/app/features/settings/notifications/Notifications.tsx @@ -1,82 +1,12 @@ import React from 'react'; -import { Box, Text, IconButton, Icon, Icons, Scroll, Switch, Button, color } from 'folds'; +import { Box, Text, IconButton, Icon, Icons, Scroll } from 'folds'; import { Page, PageContent, PageHeader } from '../../../components/page'; -import { SequenceCard } from '../../../components/sequence-card'; -import { SequenceCardStyle } from '../styles.css'; -import { SettingTile } from '../../../components/setting-tile'; -import { useSetting } from '../../../state/hooks/settings'; -import { settingsAtom } from '../../../state/settings'; -import { getNotificationState, usePermissionState } from '../../../hooks/usePermission'; +import { SystemNotification } from './SystemNotification'; import { AllMessagesNotifications } from './AllMessages'; import { SpecialMessagesNotifications } from './SpecialMessages'; import { KeywordMessagesNotifications } from './KeywordMessages'; import { IgnoredUserList } from './IgnoredUserList'; -function SystemNotification() { - const notifPermission = usePermissionState('notifications', getNotificationState()); - const [showNotifications, setShowNotifications] = useSetting(settingsAtom, 'showNotifications'); - const [isNotificationSounds, setIsNotificationSounds] = useSetting( - settingsAtom, - 'isNotificationSounds' - ); - - const requestNotificationPermission = () => { - window.Notification.requestPermission(); - }; - - return ( - - System - - - {'Notification' in window - ? 'Notification permission is blocked. Please allow notification permission from browser address bar.' - : 'Notifications are not supported by the system.'} - - ) : ( - Show desktop notifications when message arrive. - ) - } - after={ - notifPermission === 'prompt' ? ( - - ) : ( - - ) - } - /> - - - } - /> - - - ); -} - type NotificationsProps = { requestClose: () => void; }; diff --git a/src/app/features/settings/notifications/SystemNotification.tsx b/src/app/features/settings/notifications/SystemNotification.tsx new file mode 100644 index 00000000..e0df06df --- /dev/null +++ b/src/app/features/settings/notifications/SystemNotification.tsx @@ -0,0 +1,158 @@ +import React, { useCallback } from 'react'; +import { Box, Text, Switch, Button, color, Spinner } from 'folds'; +import { IPusherRequest } from 'matrix-js-sdk'; +import { SequenceCard } from '../../../components/sequence-card'; +import { SequenceCardStyle } from '../styles.css'; +import { SettingTile } from '../../../components/setting-tile'; +import { useSetting } from '../../../state/hooks/settings'; +import { settingsAtom } from '../../../state/settings'; +import { getNotificationState, usePermissionState } from '../../../hooks/usePermission'; +import { useEmailNotifications } from '../../../hooks/useEmailNotifications'; +import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; + +function EmailNotification() { + const mx = useMatrixClient(); + const [result, refreshResult] = useEmailNotifications(); + + const [setState, setEnable] = useAsyncCallback( + useCallback( + async (email: string, enable: boolean) => { + if (enable) { + await mx.setPusher({ + kind: 'email', + app_id: 'm.email', + pushkey: email, + app_display_name: 'Email Notifications', + device_display_name: email, + lang: 'en', + data: { + brand: 'Cinny', + }, + append: true, + }); + return; + } + await mx.setPusher({ + pushkey: email, + app_id: 'm.email', + kind: null, + } as unknown as IPusherRequest); + }, + [mx] + ) + ); + + const handleChange = (value: boolean) => { + if (result && result.email) { + setEnable(result.email, value).then(() => { + refreshResult(); + }); + } + }; + + return ( + + {result && !result.email && ( + + Your account does not have any email attached. + + )} + {result && result.email && <>Send notification to your email. {`("${result.email}")`}} + {result === null && ( + + Unexpected Error! + + )} + {result === undefined && 'Send notification to your email.'} + + } + after={ + <> + {setState.status !== AsyncStatus.Loading && + typeof result === 'object' && + result?.email && } + {(setState.status === AsyncStatus.Loading || result === undefined) && ( + + )} + + } + /> + ); +} + +export function SystemNotification() { + const notifPermission = usePermissionState('notifications', getNotificationState()); + const [showNotifications, setShowNotifications] = useSetting(settingsAtom, 'showNotifications'); + const [isNotificationSounds, setIsNotificationSounds] = useSetting( + settingsAtom, + 'isNotificationSounds' + ); + + const requestNotificationPermission = () => { + window.Notification.requestPermission(); + }; + + return ( + + System + + + {'Notification' in window + ? 'Notification permission is blocked. Please allow notification permission from browser address bar.' + : 'Notifications are not supported by the system.'} + + ) : ( + Show desktop notifications when message arrive. + ) + } + after={ + notifPermission === 'prompt' ? ( + + ) : ( + + ) + } + /> + + + } + /> + + + + + + ); +} diff --git a/src/app/hooks/useAsyncSearch.ts b/src/app/hooks/useAsyncSearch.ts index 3fe7ee58..3852d3b9 100644 --- a/src/app/hooks/useAsyncSearch.ts +++ b/src/app/hooks/useAsyncSearch.ts @@ -10,6 +10,7 @@ import { matchQuery, ResultHandler, } from '../utils/AsyncSearch'; +import { sanitizeForRegex } from '../utils/regex'; export type UseAsyncSearchOptions = AsyncSearchOption & { matchOptions?: MatchQueryOption; @@ -55,8 +56,8 @@ export const orderSearchItems = ( // we will consider "_" as word boundary char. // because in more use-cases it is used. (like: emojishortcode) - const boundaryRegex = new RegExp(`(\\b|_)${query}`); - const perfectBoundaryRegex = new RegExp(`(\\b|_)${query}(\\b|_)`); + const boundaryRegex = new RegExp(`(\\b|_)${sanitizeForRegex(query)}`); + const perfectBoundaryRegex = new RegExp(`(\\b|_)${sanitizeForRegex(query)}(\\b|_)`); orderedItems.sort((i1, i2) => { const str1 = performMatch(getItemStr(i1, query), query, options); diff --git a/src/app/hooks/useEmailNotifications.ts b/src/app/hooks/useEmailNotifications.ts new file mode 100644 index 00000000..58639394 --- /dev/null +++ b/src/app/hooks/useEmailNotifications.ts @@ -0,0 +1,55 @@ +import { useCallback } from 'react'; +import { AsyncStatus, useAsyncCallbackValue } from './useAsyncCallback'; +import { useMatrixClient } from './useMatrixClient'; + +type RefreshHandler = () => void; + +type EmailNotificationResult = { + enabled: boolean; + email?: string; +}; + +export const useEmailNotifications = (): [ + EmailNotificationResult | undefined | null, + RefreshHandler +] => { + const mx = useMatrixClient(); + + const [emailState, refresh] = useAsyncCallbackValue( + useCallback(async () => { + const tpIDs = (await mx.getThreePids())?.threepids; + const emailAddresses = tpIDs.filter((id) => id.medium === 'email').map((id) => id.address); + if (emailAddresses.length === 0) + return { + enabled: false, + }; + + const pushers = (await mx.getPushers())?.pushers; + const emailPusher = pushers.find( + (pusher) => pusher.app_id === 'm.email' && emailAddresses.includes(pusher.pushkey) + ); + + if (emailPusher?.pushkey) { + return { + enabled: true, + email: emailPusher.pushkey, + }; + } + + return { + enabled: false, + email: emailAddresses[0], + }; + }, [mx]) + ); + + if (emailState.status === AsyncStatus.Success) { + return [emailState.data, refresh]; + } + + if (emailState.status === AsyncStatus.Error) { + return [null, refresh]; + } + + return [undefined, refresh]; +}; diff --git a/src/app/hooks/useIgnoredUsers.ts b/src/app/hooks/useIgnoredUsers.ts new file mode 100644 index 00000000..baf2327a --- /dev/null +++ b/src/app/hooks/useIgnoredUsers.ts @@ -0,0 +1,18 @@ +import { useMemo } from 'react'; +import { useAccountData } from './useAccountData'; +import { AccountDataEvent } from '../../types/matrix/accountData'; + +export type IgnoredUserListContent = { + ignored_users?: Record; +}; + +export const useIgnoredUsers = (): string[] => { + const ignoredUserListEvt = useAccountData(AccountDataEvent.IgnoredUserList); + const ignoredUsers = useMemo(() => { + const ignoredUsersRecord = + ignoredUserListEvt?.getContent().ignored_users ?? {}; + return Object.keys(ignoredUsersRecord); + }, [ignoredUserListEvt]); + + return ignoredUsers; +}; diff --git a/src/app/hooks/useSpaceHierarchy.ts b/src/app/hooks/useSpaceHierarchy.ts index c109cc21..ad34e3f4 100644 --- a/src/app/hooks/useSpaceHierarchy.ts +++ b/src/app/hooks/useSpaceHierarchy.ts @@ -1,6 +1,8 @@ import { atom, useAtom, useAtomValue } from 'jotai'; -import { useCallback, useEffect, useState } from 'react'; -import { Room } from 'matrix-js-sdk'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { MatrixError, Room } from 'matrix-js-sdk'; +import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces'; +import { QueryFunction, useInfiniteQuery } from '@tanstack/react-query'; import { useMatrixClient } from './useMatrixClient'; import { roomToParentsAtom } from '../state/room/roomToParents'; import { MSpaceChildContent, StateEvent } from '../../types/matrix/room'; @@ -8,22 +10,24 @@ import { getAllParents, getStateEvents, isValidChild } from '../utils/room'; import { isRoomId } from '../utils/matrix'; import { SortFunc, byOrderKey, byTsOldToNew, factoryRoomIdByActivity } from '../utils/sort'; import { useStateEventCallback } from './useStateEventCallback'; +import { ErrorCode } from '../cs-errorcode'; -export type HierarchyItem = - | { - roomId: string; - content: MSpaceChildContent; - ts: number; - space: true; - parentId?: string; - } - | { - roomId: string; - content: MSpaceChildContent; - ts: number; - space?: false; - parentId: string; - }; +export type HierarchyItemSpace = { + roomId: string; + content: MSpaceChildContent; + ts: number; + space: true; + parentId?: string; +}; + +export type HierarchyItemRoom = { + roomId: string; + content: MSpaceChildContent; + ts: number; + parentId: string; +}; + +export type HierarchyItem = HierarchyItemSpace | HierarchyItemRoom; type GetRoomCallback = (roomId: string) => Room | undefined; @@ -35,16 +39,16 @@ const getHierarchySpaces = ( rootSpaceId: string, getRoom: GetRoomCallback, spaceRooms: Set -): HierarchyItem[] => { - const rootSpaceItem: HierarchyItem = { +): HierarchyItemSpace[] => { + const rootSpaceItem: HierarchyItemSpace = { roomId: rootSpaceId, content: { via: [] }, ts: 0, space: true, }; - let spaceItems: HierarchyItem[] = []; + let spaceItems: HierarchyItemSpace[] = []; - const findAndCollectHierarchySpaces = (spaceItem: HierarchyItem) => { + const findAndCollectHierarchySpaces = (spaceItem: HierarchyItemSpace) => { if (spaceItems.find((item) => item.roomId === spaceItem.roomId)) return; const space = getRoom(spaceItem.roomId); spaceItems.push(spaceItem); @@ -61,7 +65,7 @@ const getHierarchySpaces = ( // or requesting room summary, we will look it into spaceRooms local // cache which we maintain as we load summary in UI. if (getRoom(childId)?.isSpaceRoom() || spaceRooms.has(childId)) { - const childItem: HierarchyItem = { + const childItem: HierarchyItemSpace = { roomId: childId, content: childEvent.getContent(), ts: childEvent.getTs(), @@ -85,28 +89,34 @@ const getHierarchySpaces = ( return spaceItems; }; +export type SpaceHierarchy = { + space: HierarchyItemSpace; + rooms?: HierarchyItemRoom[]; +}; const getSpaceHierarchy = ( rootSpaceId: string, spaceRooms: Set, getRoom: (roomId: string) => Room | undefined, closedCategory: (spaceId: string) => boolean -): HierarchyItem[] => { - const spaceItems: HierarchyItem[] = getHierarchySpaces(rootSpaceId, getRoom, spaceRooms); +): SpaceHierarchy[] => { + const spaceItems: HierarchyItemSpace[] = getHierarchySpaces(rootSpaceId, getRoom, spaceRooms); - const hierarchy: HierarchyItem[] = spaceItems.flatMap((spaceItem) => { + const hierarchy: SpaceHierarchy[] = spaceItems.map((spaceItem) => { const space = getRoom(spaceItem.roomId); if (!space || closedCategory(spaceItem.roomId)) { - return [spaceItem]; + return { + space: spaceItem, + }; } const childEvents = getStateEvents(space, StateEvent.SpaceChild); - const childItems: HierarchyItem[] = []; + const childItems: HierarchyItemRoom[] = []; childEvents.forEach((childEvent) => { if (!isValidChild(childEvent)) return; const childId = childEvent.getStateKey(); if (!childId || !isRoomId(childId)) return; if (getRoom(childId)?.isSpaceRoom() || spaceRooms.has(childId)) return; - const childItem: HierarchyItem = { + const childItem: HierarchyItemRoom = { roomId: childId, content: childEvent.getContent(), ts: childEvent.getTs(), @@ -114,7 +124,11 @@ const getSpaceHierarchy = ( }; childItems.push(childItem); }); - return [spaceItem, ...childItems.sort(hierarchyItemTs).sort(hierarchyItemByOrder)]; + + return { + space: spaceItem, + rooms: childItems.sort(hierarchyItemTs).sort(hierarchyItemByOrder), + }; }); return hierarchy; @@ -125,7 +139,7 @@ export const useSpaceHierarchy = ( spaceRooms: Set, getRoom: (roomId: string) => Room | undefined, closedCategory: (spaceId: string) => boolean -): HierarchyItem[] => { +): SpaceHierarchy[] => { const mx = useMatrixClient(); const roomToParents = useAtomValue(roomToParentsAtom); @@ -163,7 +177,7 @@ const getSpaceJoinedHierarchy = ( excludeRoom: (parentId: string, roomId: string) => boolean, sortRoomItems: (parentId: string, items: HierarchyItem[]) => HierarchyItem[] ): HierarchyItem[] => { - const spaceItems: HierarchyItem[] = getHierarchySpaces(rootSpaceId, getRoom, new Set()); + const spaceItems: HierarchyItemSpace[] = getHierarchySpaces(rootSpaceId, getRoom, new Set()); const hierarchy: HierarchyItem[] = spaceItems.flatMap((spaceItem) => { const space = getRoom(spaceItem.roomId); @@ -182,14 +196,14 @@ const getSpaceJoinedHierarchy = ( if (joinedRoomEvents.length === 0) return []; - const childItems: HierarchyItem[] = []; + const childItems: HierarchyItemRoom[] = []; joinedRoomEvents.forEach((childEvent) => { const childId = childEvent.getStateKey(); if (!childId) return; if (excludeRoom(space.roomId, childId)) return; - const childItem: HierarchyItem = { + const childItem: HierarchyItemRoom = { roomId: childId, content: childEvent.getContent(), ts: childEvent.getTs(), @@ -251,3 +265,85 @@ export const useSpaceJoinedHierarchy = ( return hierarchy; }; + +// we will paginate until 5000 items +const PER_PAGE_COUNT = 100; +const MAX_AUTO_PAGE_COUNT = 50; +export type FetchSpaceHierarchyLevelData = { + fetching: boolean; + error: Error | null; + rooms: Map; +}; +export const useFetchSpaceHierarchyLevel = ( + roomId: string, + enable: boolean +): FetchSpaceHierarchyLevelData => { + const mx = useMatrixClient(); + const pageNoRef = useRef(0); + + const fetchLevel: QueryFunction< + Awaited>, + string[], + string | undefined + > = useCallback( + ({ pageParam }) => mx.getRoomHierarchy(roomId, PER_PAGE_COUNT, 1, false, pageParam), + [roomId, mx] + ); + + const queryResponse = useInfiniteQuery({ + refetchOnMount: enable, + queryKey: [roomId, 'hierarchy_level'], + initialPageParam: undefined, + queryFn: fetchLevel, + getNextPageParam: (result) => { + if (result.next_batch) return result.next_batch; + return undefined; + }, + retry: 5, + retryDelay: (failureCount, error) => { + if (error instanceof MatrixError && error.errcode === ErrorCode.M_LIMIT_EXCEEDED) { + const { retry_after_ms: delay } = error.data; + if (typeof delay === 'number') { + return delay; + } + } + + return 500 * failureCount; + }, + }); + + const { data, isLoading, isFetchingNextPage, error, fetchNextPage, hasNextPage } = queryResponse; + + useEffect(() => { + if ( + hasNextPage && + pageNoRef.current <= MAX_AUTO_PAGE_COUNT && + !error && + data && + data.pages.length > 0 + ) { + pageNoRef.current += 1; + fetchNextPage(); + } + }, [fetchNextPage, hasNextPage, data, error]); + + const rooms: Map = useMemo(() => { + const roomsMap: Map = new Map(); + if (!data) return roomsMap; + + const rms = data.pages.flatMap((result) => result.rooms); + rms.forEach((r) => { + roomsMap.set(r.room_id, r); + }); + + return roomsMap; + }, [data]); + + const fetching = isLoading || isFetchingNextPage; + + return { + fetching, + error, + rooms, + }; +}; diff --git a/src/app/hooks/useVirtualPaginator.ts b/src/app/hooks/useVirtualPaginator.ts index 9ffc7f91..5ad056a6 100644 --- a/src/app/hooks/useVirtualPaginator.ts +++ b/src/app/hooks/useVirtualPaginator.ts @@ -26,8 +26,23 @@ export type ScrollToOptions = { stopInView?: boolean; }; -export type ScrollToElement = (element: HTMLElement, opts?: ScrollToOptions) => void; -export type ScrollToItem = (index: number, opts?: ScrollToOptions) => void; +/** + * Scrolls the page to a specified element in the DOM. + * + * @param {HTMLElement} element - The DOM element to scroll to. + * @param {ScrollToOptions} [opts] - Optional configuration for the scroll behavior (e.g., smooth scrolling, alignment). + * @returns {boolean} - Returns `true` if the scroll was successful, otherwise returns `false`. + */ +export type ScrollToElement = (element: HTMLElement, opts?: ScrollToOptions) => boolean; + +/** + * Scrolls the page to an item at the specified index within a scrollable container. + * + * @param {number} index - The index of the item to scroll to. + * @param {ScrollToOptions} [opts] - Optional configuration for the scroll behavior (e.g., smooth scrolling, alignment). + * @returns {boolean} - Returns `true` if the scroll was successful, otherwise returns `false`. + */ +export type ScrollToItem = (index: number, opts?: ScrollToOptions) => boolean; type HandleObserveAnchor = (element: HTMLElement | null) => void; @@ -186,10 +201,10 @@ export const useVirtualPaginator = ( const scrollToElement = useCallback( (element, opts) => { const scrollElement = getScrollElement(); - if (!scrollElement) return; + if (!scrollElement) return false; if (opts?.stopInView && isInScrollView(scrollElement, element)) { - return; + return false; } let scrollTo = element.offsetTop; if (opts?.align === 'center' && canFitInScrollView(scrollElement, element)) { @@ -207,6 +222,7 @@ export const useVirtualPaginator = ( top: scrollTo - (opts?.offset ?? 0), behavior: opts?.behavior, }); + return true; }, [getScrollElement] ); @@ -215,7 +231,7 @@ export const useVirtualPaginator = ( (index, opts) => { const { range: currentRange, limit: currentLimit, count: currentCount } = propRef.current; - if (index < 0 || index >= currentCount) return; + if (index < 0 || index >= currentCount) return false; // index is not in range change range // and trigger scrollToItem in layoutEffect hook if (index < currentRange.start || index >= currentRange.end) { @@ -227,7 +243,7 @@ export const useVirtualPaginator = ( index, opts, }; - return; + return true; } // find target or it's previous rendered element to scroll to @@ -241,9 +257,9 @@ export const useVirtualPaginator = ( top: opts?.offset ?? 0, behavior: opts?.behavior, }); - return; + return true; } - scrollToElement(itemElement, opts); + return scrollToElement(itemElement, opts); }, [getScrollElement, scrollToElement, getItemElement, onRangeChange] ); diff --git a/src/app/pages/auth/AuthFooter.tsx b/src/app/pages/auth/AuthFooter.tsx index 77926d11..8cd950a4 100644 --- a/src/app/pages/auth/AuthFooter.tsx +++ b/src/app/pages/auth/AuthFooter.tsx @@ -15,7 +15,7 @@ export function AuthFooter() { target="_blank" rel="noreferrer" > - v4.3.2 + v4.5.1 Twitter diff --git a/src/app/pages/client/WelcomePage.tsx b/src/app/pages/client/WelcomePage.tsx index 1b1e7912..68888a78 100644 --- a/src/app/pages/client/WelcomePage.tsx +++ b/src/app/pages/client/WelcomePage.tsx @@ -24,7 +24,7 @@ export function WelcomePage() { target="_blank" rel="noreferrer noopener" > - v4.3.2 + v4.5.1 } diff --git a/src/app/pages/client/direct/Direct.tsx b/src/app/pages/client/direct/Direct.tsx index cd19e33e..5e799214 100644 --- a/src/app/pages/client/direct/Direct.tsx +++ b/src/app/pages/client/direct/Direct.tsx @@ -45,18 +45,21 @@ import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCatego import { useRoomsUnread } from '../../../state/hooks/unread'; import { markAsRead } from '../../../../client/action/notifications'; import { stopPropagation } from '../../../utils/keyboard'; +import { useSetting } from '../../../state/hooks/settings'; +import { settingsAtom } from '../../../state/settings'; type DirectMenuProps = { requestClose: () => void; }; const DirectMenu = forwardRef(({ requestClose }, ref) => { const mx = useMatrixClient(); + const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const orphanRooms = useDirectRooms(); const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom); const handleMarkAsRead = () => { if (!unread) return; - orphanRooms.forEach((rId) => markAsRead(mx, rId)); + orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity)); requestClose(); }; diff --git a/src/app/pages/client/home/Home.tsx b/src/app/pages/client/home/Home.tsx index f9923f46..fa5e68ab 100644 --- a/src/app/pages/client/home/Home.tsx +++ b/src/app/pages/client/home/Home.tsx @@ -48,18 +48,21 @@ import { useRoomsUnread } from '../../../state/hooks/unread'; import { markAsRead } from '../../../../client/action/notifications'; import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories'; import { stopPropagation } from '../../../utils/keyboard'; +import { useSetting } from '../../../state/hooks/settings'; +import { settingsAtom } from '../../../state/settings'; type HomeMenuProps = { requestClose: () => void; }; const HomeMenu = forwardRef(({ requestClose }, ref) => { const orphanRooms = useHomeRooms(); + const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom); const mx = useMatrixClient(); const handleMarkAsRead = () => { if (!unread) return; - orphanRooms.forEach((rId) => markAsRead(mx, rId)); + orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity)); requestClose(); }; diff --git a/src/app/pages/client/inbox/Notifications.tsx b/src/app/pages/client/inbox/Notifications.tsx index 0c832b09..722ce5d3 100644 --- a/src/app/pages/client/inbox/Notifications.tsx +++ b/src/app/pages/client/inbox/Notifications.tsx @@ -182,6 +182,7 @@ type RoomNotificationsGroupProps = { notifications: INotification[]; mediaAutoLoad?: boolean; urlPreview?: boolean; + hideActivity: boolean; onOpen: (roomId: string, eventId: string) => void; }; function RoomNotificationsGroupComp({ @@ -189,6 +190,7 @@ function RoomNotificationsGroupComp({ notifications, mediaAutoLoad, urlPreview, + hideActivity, onOpen, }: RoomNotificationsGroupProps) { const mx = useMatrixClient(); @@ -362,7 +364,7 @@ function RoomNotificationsGroupComp({ onOpen(room.roomId, eventId); }; const handleMarkAsRead = () => { - markAsRead(mx, room.roomId); + markAsRead(mx, room.roomId, hideActivity); }; return ( @@ -496,6 +498,7 @@ const DEFAULT_REFRESH_MS = 7000; export function Notifications() { const mx = useMatrixClient(); + const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); const [urlPreview] = useSetting(settingsAtom, 'urlPreview'); const screenSize = useScreenSizeContext(); @@ -656,6 +659,7 @@ export function Notifications() { notifications={group.notifications} mediaAutoLoad={mediaAutoLoad} urlPreview={urlPreview} + hideActivity={hideActivity} onOpen={navigateRoom} /> diff --git a/src/app/pages/client/sidebar/DirectTab.tsx b/src/app/pages/client/sidebar/DirectTab.tsx index 849fc365..bd8090d3 100644 --- a/src/app/pages/client/sidebar/DirectTab.tsx +++ b/src/app/pages/client/sidebar/DirectTab.tsx @@ -23,18 +23,21 @@ import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath'; import { useDirectRooms } from '../direct/useDirectRooms'; import { markAsRead } from '../../../../client/action/notifications'; import { stopPropagation } from '../../../utils/keyboard'; +import { settingsAtom } from '../../../state/settings'; +import { useSetting } from '../../../state/hooks/settings'; type DirectMenuProps = { requestClose: () => void; }; const DirectMenu = forwardRef(({ requestClose }, ref) => { const orphanRooms = useDirectRooms(); + const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom); const mx = useMatrixClient(); const handleMarkAsRead = () => { if (!unread) return; - orphanRooms.forEach((rId) => markAsRead(mx, rId)); + orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity)); requestClose(); }; diff --git a/src/app/pages/client/sidebar/HomeTab.tsx b/src/app/pages/client/sidebar/HomeTab.tsx index dcb0a498..c8a80280 100644 --- a/src/app/pages/client/sidebar/HomeTab.tsx +++ b/src/app/pages/client/sidebar/HomeTab.tsx @@ -24,18 +24,21 @@ import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath'; import { useHomeRooms } from '../home/useHomeRooms'; import { markAsRead } from '../../../../client/action/notifications'; import { stopPropagation } from '../../../utils/keyboard'; +import { useSetting } from '../../../state/hooks/settings'; +import { settingsAtom } from '../../../state/settings'; type HomeMenuProps = { requestClose: () => void; }; const HomeMenu = forwardRef(({ requestClose }, ref) => { const orphanRooms = useHomeRooms(); + const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom); const mx = useMatrixClient(); const handleMarkAsRead = () => { if (!unread) return; - orphanRooms.forEach((rId) => markAsRead(mx, rId)); + orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity)); requestClose(); }; diff --git a/src/app/pages/client/sidebar/SpaceTabs.tsx b/src/app/pages/client/sidebar/SpaceTabs.tsx index 343afae4..96e3b9ad 100644 --- a/src/app/pages/client/sidebar/SpaceTabs.tsx +++ b/src/app/pages/client/sidebar/SpaceTabs.tsx @@ -88,6 +88,8 @@ import { getMatrixToRoom } from '../../../plugins/matrix-to'; import { getViaServers } from '../../../plugins/via-servers'; import { getRoomAvatarUrl } from '../../../utils/room'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; +import { useSetting } from '../../../state/hooks/settings'; +import { settingsAtom } from '../../../state/settings'; type SpaceMenuProps = { room: Room; @@ -97,6 +99,7 @@ type SpaceMenuProps = { const SpaceMenu = forwardRef( ({ room, requestClose, onUnpin }, ref) => { const mx = useMatrixClient(); + const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const roomToParents = useAtomValue(roomToParentsAtom); const powerLevels = usePowerLevels(room); const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels); @@ -110,7 +113,7 @@ const SpaceMenu = forwardRef( const unread = useRoomsUnread(allChild, roomToUnreadAtom); const handleMarkAsRead = () => { - allChild.forEach((childRoomId) => markAsRead(mx, childRoomId)); + allChild.forEach((childRoomId) => markAsRead(mx, childRoomId, hideActivity)); requestClose(); }; @@ -227,18 +230,18 @@ const useDraggableItem = ( return !target ? undefined : draggable({ - element: target, - dragHandle, - getInitialData: () => ({ item }), - onDragStart: () => { - setDragging(true); - onDragging?.(item); - }, - onDrop: () => { - setDragging(false); - onDragging?.(undefined); - }, - }); + element: target, + dragHandle, + getInitialData: () => ({ item }), + onDragStart: () => { + setDragging(true); + onDragging?.(item); + }, + onDrop: () => { + setDragging(false); + onDragging?.(undefined); + }, + }); }, [targetRef, dragHandleRef, item, onDragging]); return dragging; @@ -388,9 +391,9 @@ function SpaceTab({ () => folder ? { - folder, - spaceId: space.roomId, - } + folder, + spaceId: space.roomId, + } : space.roomId, [folder, space] ); diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx index c8e2b783..1714d8ee 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -69,6 +69,8 @@ import { StateEvent } from '../../../../types/matrix/room'; import { stopPropagation } from '../../../utils/keyboard'; import { getMatrixToRoom } from '../../../plugins/matrix-to'; import { getViaServers } from '../../../plugins/via-servers'; +import { useSetting } from '../../../state/hooks/settings'; +import { settingsAtom } from '../../../state/settings'; type SpaceMenuProps = { room: Room; @@ -76,6 +78,7 @@ type SpaceMenuProps = { }; const SpaceMenu = forwardRef(({ room, requestClose }, ref) => { const mx = useMatrixClient(); + const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const roomToParents = useAtomValue(roomToParentsAtom); const powerLevels = usePowerLevels(room); const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels); @@ -89,7 +92,7 @@ const SpaceMenu = forwardRef(({ room, requestClo const unread = useRoomsUnread(allChild, roomToUnreadAtom); const handleMarkAsRead = () => { - allChild.forEach((childRoomId) => markAsRead(mx, childRoomId)); + allChild.forEach((childRoomId) => markAsRead(mx, childRoomId, hideActivity)); requestClose(); }; diff --git a/src/app/plugins/markdown.ts b/src/app/plugins/markdown.ts deleted file mode 100644 index 9b3b82f7..00000000 --- a/src/app/plugins/markdown.ts +++ /dev/null @@ -1,368 +0,0 @@ -export type MatchResult = RegExpMatchArray | RegExpExecArray; -export type RuleMatch = (text: string) => MatchResult | null; - -export const beforeMatch = (text: string, match: RegExpMatchArray | RegExpExecArray): string => - text.slice(0, match.index); -export const afterMatch = (text: string, match: RegExpMatchArray | RegExpExecArray): string => - text.slice((match.index ?? 0) + match[0].length); - -export const replaceMatch = ( - convertPart: (txt: string) => Array, - text: string, - match: MatchResult, - content: C -): Array => [ - ...convertPart(beforeMatch(text, match)), - content, - ...convertPart(afterMatch(text, match)), -]; - -/* - ***************** - * INLINE PARSER * - ***************** - */ - -export type InlineMDParser = (text: string) => string; - -export type InlineMatchConverter = (parse: InlineMDParser, match: MatchResult) => string; - -export type InlineMDRule = { - match: RuleMatch; - html: InlineMatchConverter; -}; - -export type InlineRuleRunner = ( - parse: InlineMDParser, - text: string, - rule: InlineMDRule -) => string | undefined; -export type InlineRulesRunner = ( - parse: InlineMDParser, - text: string, - rules: InlineMDRule[] -) => string | undefined; - -const MIN_ANY = '(.+?)'; -const URL_NEG_LB = '(? text.match(BOLD_REG_1), - html: (parse, match) => { - const [, , g2] = match; - return `${parse(g2)}`; - }, -}; - -const ITALIC_MD_1 = '*'; -const ITALIC_PREFIX_1 = '\\*'; -const ITALIC_NEG_LA_1 = '(?!\\*)'; -const ITALIC_REG_1 = new RegExp( - `${URL_NEG_LB}${ITALIC_PREFIX_1}${MIN_ANY}${ITALIC_PREFIX_1}${ITALIC_NEG_LA_1}` -); -const ItalicRule1: InlineMDRule = { - match: (text) => text.match(ITALIC_REG_1), - html: (parse, match) => { - const [, , g2] = match; - return `${parse(g2)}`; - }, -}; - -const ITALIC_MD_2 = '_'; -const ITALIC_PREFIX_2 = '_'; -const ITALIC_NEG_LA_2 = '(?!_)'; -const ITALIC_REG_2 = new RegExp( - `${URL_NEG_LB}${ITALIC_PREFIX_2}${MIN_ANY}${ITALIC_PREFIX_2}${ITALIC_NEG_LA_2}` -); -const ItalicRule2: InlineMDRule = { - match: (text) => text.match(ITALIC_REG_2), - html: (parse, match) => { - const [, , g2] = match; - return `${parse(g2)}`; - }, -}; - -const UNDERLINE_MD_1 = '__'; -const UNDERLINE_PREFIX_1 = '_{2}'; -const UNDERLINE_NEG_LA_1 = '(?!_)'; -const UNDERLINE_REG_1 = new RegExp( - `${URL_NEG_LB}${UNDERLINE_PREFIX_1}${MIN_ANY}${UNDERLINE_PREFIX_1}${UNDERLINE_NEG_LA_1}` -); -const UnderlineRule: InlineMDRule = { - match: (text) => text.match(UNDERLINE_REG_1), - html: (parse, match) => { - const [, , g2] = match; - return `${parse(g2)}`; - }, -}; - -const STRIKE_MD_1 = '~~'; -const STRIKE_PREFIX_1 = '~{2}'; -const STRIKE_NEG_LA_1 = '(?!~)'; -const STRIKE_REG_1 = new RegExp( - `${URL_NEG_LB}${STRIKE_PREFIX_1}${MIN_ANY}${STRIKE_PREFIX_1}${STRIKE_NEG_LA_1}` -); -const StrikeRule: InlineMDRule = { - match: (text) => text.match(STRIKE_REG_1), - html: (parse, match) => { - const [, , g2] = match; - return `${parse(g2)}`; - }, -}; - -const CODE_MD_1 = '`'; -const CODE_PREFIX_1 = '`'; -const CODE_NEG_LA_1 = '(?!`)'; -const CODE_REG_1 = new RegExp(`${URL_NEG_LB}${CODE_PREFIX_1}(.+?)${CODE_PREFIX_1}${CODE_NEG_LA_1}`); -const CodeRule: InlineMDRule = { - match: (text) => text.match(CODE_REG_1), - html: (parse, match) => { - const [, , g2] = match; - return `${g2}`; - }, -}; - -const SPOILER_MD_1 = '||'; -const SPOILER_PREFIX_1 = '\\|{2}'; -const SPOILER_NEG_LA_1 = '(?!\\|)'; -const SPOILER_REG_1 = new RegExp( - `${URL_NEG_LB}${SPOILER_PREFIX_1}${MIN_ANY}${SPOILER_PREFIX_1}${SPOILER_NEG_LA_1}` -); -const SpoilerRule: InlineMDRule = { - match: (text) => text.match(SPOILER_REG_1), - html: (parse, match) => { - const [, , g2] = match; - return `${parse(g2)}`; - }, -}; - -const LINK_ALT = `\\[${MIN_ANY}\\]`; -const LINK_URL = `\\((https?:\\/\\/.+?)\\)`; -const LINK_REG_1 = new RegExp(`${LINK_ALT}${LINK_URL}`); -const LinkRule: InlineMDRule = { - match: (text) => text.match(LINK_REG_1), - html: (parse, match) => { - const [, g1, g2] = match; - return `${parse(g1)}`; - }, -}; - -const runInlineRule: InlineRuleRunner = (parse, text, rule) => { - const matchResult = rule.match(text); - if (matchResult) { - const content = rule.html(parse, matchResult); - return replaceMatch((txt) => [parse(txt)], text, matchResult, content).join(''); - } - return undefined; -}; - -/** - * Runs multiple rules at the same time to better handle nested rules. - * Rules will be run in the order they appear. - */ -const runInlineRules: InlineRulesRunner = (parse, text, rules) => { - const matchResults = rules.map((rule) => rule.match(text)); - - let targetRule: InlineMDRule | undefined; - let targetResult: MatchResult | undefined; - - for (let i = 0; i < matchResults.length; i += 1) { - const currentResult = matchResults[i]; - if (currentResult && typeof currentResult.index === 'number') { - if ( - !targetResult || - (typeof targetResult?.index === 'number' && currentResult.index < targetResult.index) - ) { - targetResult = currentResult; - targetRule = rules[i]; - } - } - } - - if (targetRule && targetResult) { - const content = targetRule.html(parse, targetResult); - return replaceMatch((txt) => [parse(txt)], text, targetResult, content).join(''); - } - return undefined; -}; - -const LeveledRules = [ - BoldRule, - ItalicRule1, - UnderlineRule, - ItalicRule2, - StrikeRule, - SpoilerRule, - LinkRule, -]; - -export const parseInlineMD: InlineMDParser = (text) => { - if (text === '') return text; - let result: string | undefined; - if (!result) result = runInlineRule(parseInlineMD, text, CodeRule); - - if (!result) result = runInlineRules(parseInlineMD, text, LeveledRules); - - return result ?? text; -}; - -/* - **************** - * BLOCK PARSER * - **************** - */ - -export type BlockMDParser = (test: string, parseInline?: (txt: string) => string) => string; - -export type BlockMatchConverter = ( - match: MatchResult, - parseInline?: (txt: string) => string -) => string; - -export type BlockMDRule = { - match: RuleMatch; - html: BlockMatchConverter; -}; - -export type BlockRuleRunner = ( - parse: BlockMDParser, - text: string, - rule: BlockMDRule, - parseInline?: (txt: string) => string -) => string | undefined; - -const HEADING_REG_1 = /^(#{1,6}) +(.+)\n?/m; -const HeadingRule: BlockMDRule = { - match: (text) => text.match(HEADING_REG_1), - html: (match, parseInline) => { - const [, g1, g2] = match; - const level = g1.length; - return `${parseInline ? parseInline(g2) : g2}`; - }, -}; - -const CODEBLOCK_MD_1 = '```'; -const CODEBLOCK_REG_1 = /^`{3}(\S*)\n((?:.*\n)+?)`{3} *(?!.)\n?/m; -const CodeBlockRule: BlockMDRule = { - match: (text) => text.match(CODEBLOCK_REG_1), - html: (match) => { - const [, g1, g2] = match; - const classNameAtt = g1 ? ` class="language-${g1}"` : ''; - return `
${g2}
`; - }, -}; - -const BLOCKQUOTE_MD_1 = '>'; -const QUOTE_LINE_PREFIX = /^> */; -const BLOCKQUOTE_TRAILING_NEWLINE = /\n$/; -const BLOCKQUOTE_REG_1 = /(^>.*\n?)+/m; -const BlockQuoteRule: BlockMDRule = { - match: (text) => text.match(BLOCKQUOTE_REG_1), - html: (match, parseInline) => { - const [blockquoteText] = match; - - const lines = blockquoteText - .replace(BLOCKQUOTE_TRAILING_NEWLINE, '') - .split('\n') - .map((lineText) => { - const line = lineText.replace(QUOTE_LINE_PREFIX, ''); - if (parseInline) return `${parseInline(line)}
`; - return `${line}
`; - }) - .join(''); - return `
${lines}
`; - }, -}; - -const ORDERED_LIST_MD_1 = '-'; -const O_LIST_ITEM_PREFIX = /^(-|[\da-zA-Z]\.) */; -const O_LIST_START = /^([\d])\./; -const O_LIST_TYPE = /^([aAiI])\./; -const O_LIST_TRAILING_NEWLINE = /\n$/; -const ORDERED_LIST_REG_1 = /(^(?:-|[\da-zA-Z]\.) +.+\n?)+/m; -const OrderedListRule: BlockMDRule = { - match: (text) => text.match(ORDERED_LIST_REG_1), - html: (match, parseInline) => { - const [listText] = match; - const [, listStart] = listText.match(O_LIST_START) ?? []; - const [, listType] = listText.match(O_LIST_TYPE) ?? []; - - const lines = listText - .replace(O_LIST_TRAILING_NEWLINE, '') - .split('\n') - .map((lineText) => { - const line = lineText.replace(O_LIST_ITEM_PREFIX, ''); - const txt = parseInline ? parseInline(line) : line; - return `
  • ${txt}

  • `; - }) - .join(''); - - const dataMdAtt = `data-md="${listType || listStart || ORDERED_LIST_MD_1}"`; - const startAtt = listStart ? ` start="${listStart}"` : ''; - const typeAtt = listType ? ` type="${listType}"` : ''; - return `
      ${lines}
    `; - }, -}; - -const UNORDERED_LIST_MD_1 = '*'; -const U_LIST_ITEM_PREFIX = /^\* */; -const U_LIST_TRAILING_NEWLINE = /\n$/; -const UNORDERED_LIST_REG_1 = /(^\* +.+\n?)+/m; -const UnorderedListRule: BlockMDRule = { - match: (text) => text.match(UNORDERED_LIST_REG_1), - html: (match, parseInline) => { - const [listText] = match; - - const lines = listText - .replace(U_LIST_TRAILING_NEWLINE, '') - .split('\n') - .map((lineText) => { - const line = lineText.replace(U_LIST_ITEM_PREFIX, ''); - const txt = parseInline ? parseInline(line) : line; - return `
  • ${txt}

  • `; - }) - .join(''); - - return `
      ${lines}
    `; - }, -}; - -const runBlockRule: BlockRuleRunner = (parse, text, rule, parseInline) => { - const matchResult = rule.match(text); - if (matchResult) { - const content = rule.html(matchResult, parseInline); - return replaceMatch((txt) => [parse(txt, parseInline)], text, matchResult, content).join(''); - } - return undefined; -}; - -export const parseBlockMD: BlockMDParser = (text, parseInline) => { - if (text === '') return text; - let result: string | undefined; - - if (!result) result = runBlockRule(parseBlockMD, text, CodeBlockRule, parseInline); - if (!result) result = runBlockRule(parseBlockMD, text, BlockQuoteRule, parseInline); - if (!result) result = runBlockRule(parseBlockMD, text, OrderedListRule, parseInline); - if (!result) result = runBlockRule(parseBlockMD, text, UnorderedListRule, parseInline); - if (!result) result = runBlockRule(parseBlockMD, text, HeadingRule, parseInline); - - // replace \n with
    because want to preserve empty lines - if (!result) { - if (parseInline) { - result = text - .split('\n') - .map((lineText) => parseInline(lineText)) - .join('
    '); - } else { - result = text.replace(/\n/g, '
    '); - } - } - - return result ?? text; -}; diff --git a/src/app/plugins/markdown/block/index.ts b/src/app/plugins/markdown/block/index.ts new file mode 100644 index 00000000..75aa8b93 --- /dev/null +++ b/src/app/plugins/markdown/block/index.ts @@ -0,0 +1 @@ +export * from './parser'; diff --git a/src/app/plugins/markdown/block/parser.ts b/src/app/plugins/markdown/block/parser.ts new file mode 100644 index 00000000..ed16a327 --- /dev/null +++ b/src/app/plugins/markdown/block/parser.ts @@ -0,0 +1,47 @@ +import { replaceMatch } from '../internal'; +import { + BlockQuoteRule, + CodeBlockRule, + ESC_BLOCK_SEQ, + HeadingRule, + OrderedListRule, + UnorderedListRule, +} from './rules'; +import { runBlockRule } from './runner'; +import { BlockMDParser } from './type'; + +/** + * Parses block-level markdown text into HTML using defined block rules. + * + * @param text - The markdown text to be parsed. + * @param parseInline - Optional function to parse inline elements. + * @returns The parsed HTML or the original text if no block-level markdown was found. + */ +export const parseBlockMD: BlockMDParser = (text, parseInline) => { + if (text === '') return text; + let result: string | undefined; + + if (!result) result = runBlockRule(text, CodeBlockRule, parseBlockMD, parseInline); + if (!result) result = runBlockRule(text, BlockQuoteRule, parseBlockMD, parseInline); + if (!result) result = runBlockRule(text, OrderedListRule, parseBlockMD, parseInline); + if (!result) result = runBlockRule(text, UnorderedListRule, parseBlockMD, parseInline); + if (!result) result = runBlockRule(text, HeadingRule, parseBlockMD, parseInline); + + // replace \n with
    because want to preserve empty lines + if (!result) { + result = text + .split('\n') + .map((lineText) => { + const match = lineText.match(ESC_BLOCK_SEQ); + if (!match) { + return parseInline?.(lineText) ?? lineText; + } + + const [, g1] = match; + return replaceMatch(lineText, match, g1, (t) => [parseInline?.(t) ?? t]).join(''); + }) + .join('
    '); + } + + return result ?? text; +}; diff --git a/src/app/plugins/markdown/block/rules.ts b/src/app/plugins/markdown/block/rules.ts new file mode 100644 index 00000000..f598ee63 --- /dev/null +++ b/src/app/plugins/markdown/block/rules.ts @@ -0,0 +1,100 @@ +import { BlockMDRule } from './type'; + +const HEADING_REG_1 = /^(#{1,6}) +(.+)\n?/m; +export const HeadingRule: BlockMDRule = { + match: (text) => text.match(HEADING_REG_1), + html: (match, parseInline) => { + const [, g1, g2] = match; + const level = g1.length; + return `${parseInline ? parseInline(g2) : g2}`; + }, +}; + +const CODEBLOCK_MD_1 = '```'; +const CODEBLOCK_REG_1 = /^`{3}(\S*)\n((?:.*\n)+?)`{3} *(?!.)\n?/m; +export const CodeBlockRule: BlockMDRule = { + match: (text) => text.match(CODEBLOCK_REG_1), + html: (match) => { + const [, g1, g2] = match; + const classNameAtt = g1 ? ` class="language-${g1}"` : ''; + return `
    ${g2}
    `; + }, +}; + +const BLOCKQUOTE_MD_1 = '>'; +const QUOTE_LINE_PREFIX = /^> */; +const BLOCKQUOTE_TRAILING_NEWLINE = /\n$/; +const BLOCKQUOTE_REG_1 = /(^>.*\n?)+/m; +export const BlockQuoteRule: BlockMDRule = { + match: (text) => text.match(BLOCKQUOTE_REG_1), + html: (match, parseInline) => { + const [blockquoteText] = match; + + const lines = blockquoteText + .replace(BLOCKQUOTE_TRAILING_NEWLINE, '') + .split('\n') + .map((lineText) => { + const line = lineText.replace(QUOTE_LINE_PREFIX, ''); + if (parseInline) return `${parseInline(line)}
    `; + return `${line}
    `; + }) + .join(''); + return `
    ${lines}
    `; + }, +}; + +const ORDERED_LIST_MD_1 = '-'; +const O_LIST_ITEM_PREFIX = /^(-|[\da-zA-Z]\.) */; +const O_LIST_START = /^([\d])\./; +const O_LIST_TYPE = /^([aAiI])\./; +const O_LIST_TRAILING_NEWLINE = /\n$/; +const ORDERED_LIST_REG_1 = /(^(?:-|[\da-zA-Z]\.) +.+\n?)+/m; +export const OrderedListRule: BlockMDRule = { + match: (text) => text.match(ORDERED_LIST_REG_1), + html: (match, parseInline) => { + const [listText] = match; + const [, listStart] = listText.match(O_LIST_START) ?? []; + const [, listType] = listText.match(O_LIST_TYPE) ?? []; + + const lines = listText + .replace(O_LIST_TRAILING_NEWLINE, '') + .split('\n') + .map((lineText) => { + const line = lineText.replace(O_LIST_ITEM_PREFIX, ''); + const txt = parseInline ? parseInline(line) : line; + return `
  • ${txt}

  • `; + }) + .join(''); + + const dataMdAtt = `data-md="${listType || listStart || ORDERED_LIST_MD_1}"`; + const startAtt = listStart ? ` start="${listStart}"` : ''; + const typeAtt = listType ? ` type="${listType}"` : ''; + return `
      ${lines}
    `; + }, +}; + +const UNORDERED_LIST_MD_1 = '*'; +const U_LIST_ITEM_PREFIX = /^\* */; +const U_LIST_TRAILING_NEWLINE = /\n$/; +const UNORDERED_LIST_REG_1 = /(^\* +.+\n?)+/m; +export const UnorderedListRule: BlockMDRule = { + match: (text) => text.match(UNORDERED_LIST_REG_1), + html: (match, parseInline) => { + const [listText] = match; + + const lines = listText + .replace(U_LIST_TRAILING_NEWLINE, '') + .split('\n') + .map((lineText) => { + const line = lineText.replace(U_LIST_ITEM_PREFIX, ''); + const txt = parseInline ? parseInline(line) : line; + return `
  • ${txt}

  • `; + }) + .join(''); + + return `
      ${lines}
    `; + }, +}; + +export const UN_ESC_BLOCK_SEQ = /^\\*(#{1,6} +|```|>|(-|[\da-zA-Z]\.) +|\* +)/; +export const ESC_BLOCK_SEQ = /^\\(\\*(#{1,6} +|```|>|(-|[\da-zA-Z]\.) +|\* +))/; diff --git a/src/app/plugins/markdown/block/runner.ts b/src/app/plugins/markdown/block/runner.ts new file mode 100644 index 00000000..1dc8d8b8 --- /dev/null +++ b/src/app/plugins/markdown/block/runner.ts @@ -0,0 +1,25 @@ +import { replaceMatch } from '../internal'; +import { BlockMDParser, BlockMDRule } from './type'; + +/** + * Parses block-level markdown text into HTML using defined block rules. + * + * @param text - The text to parse. + * @param rule - The markdown rule to run. + * @param parse - A function that run the parser on remaining parts.. + * @param parseInline - Optional function to parse inline elements. + * @returns The text with the markdown rule applied or `undefined` if no match is found. + */ +export const runBlockRule = ( + text: string, + rule: BlockMDRule, + parse: BlockMDParser, + parseInline?: (txt: string) => string +): string | undefined => { + const matchResult = rule.match(text); + if (matchResult) { + const content = rule.html(matchResult, parseInline); + return replaceMatch(text, matchResult, content, (txt) => [parse(txt, parseInline)]).join(''); + } + return undefined; +}; diff --git a/src/app/plugins/markdown/block/type.ts b/src/app/plugins/markdown/block/type.ts new file mode 100644 index 00000000..0949eb70 --- /dev/null +++ b/src/app/plugins/markdown/block/type.ts @@ -0,0 +1,30 @@ +import { MatchResult, MatchRule } from '../internal'; + +/** + * Type for a function that parses block-level markdown into HTML. + * + * @param text - The markdown text to be parsed. + * @param parseInline - Optional function to parse inline elements. + * @returns The parsed HTML. + */ +export type BlockMDParser = (text: string, parseInline?: (txt: string) => string) => string; + +/** + * Type for a function that converts a block match to output. + * + * @param match - The match result. + * @param parseInline - Optional function to parse inline elements. + * @returns The output string after processing the match. + */ +export type BlockMatchConverter = ( + match: MatchResult, + parseInline?: (txt: string) => string +) => string; + +/** + * Type representing a block-level markdown rule that includes a matching pattern and HTML conversion. + */ +export type BlockMDRule = { + match: MatchRule; // A function that matches a specific markdown pattern. + html: BlockMatchConverter; // A function that converts the match to HTML. +}; diff --git a/src/app/plugins/markdown/index.ts b/src/app/plugins/markdown/index.ts new file mode 100644 index 00000000..4c4e4491 --- /dev/null +++ b/src/app/plugins/markdown/index.ts @@ -0,0 +1,3 @@ +export * from './utils'; +export * from './block'; +export * from './inline'; diff --git a/src/app/plugins/markdown/inline/index.ts b/src/app/plugins/markdown/inline/index.ts new file mode 100644 index 00000000..75aa8b93 --- /dev/null +++ b/src/app/plugins/markdown/inline/index.ts @@ -0,0 +1 @@ +export * from './parser'; diff --git a/src/app/plugins/markdown/inline/parser.ts b/src/app/plugins/markdown/inline/parser.ts new file mode 100644 index 00000000..37c71a66 --- /dev/null +++ b/src/app/plugins/markdown/inline/parser.ts @@ -0,0 +1,40 @@ +import { + BoldRule, + CodeRule, + EscapeRule, + ItalicRule1, + ItalicRule2, + LinkRule, + SpoilerRule, + StrikeRule, + UnderlineRule, +} from './rules'; +import { runInlineRule, runInlineRules } from './runner'; +import { InlineMDParser } from './type'; + +const LeveledRules = [ + BoldRule, + ItalicRule1, + UnderlineRule, + ItalicRule2, + StrikeRule, + SpoilerRule, + LinkRule, + EscapeRule, +]; + +/** + * Parses inline markdown text into HTML using defined rules. + * + * @param text - The markdown text to be parsed. + * @returns The parsed HTML or the original text if no markdown was found. + */ +export const parseInlineMD: InlineMDParser = (text) => { + if (text === '') return text; + let result: string | undefined; + if (!result) result = runInlineRule(text, CodeRule, parseInlineMD); + + if (!result) result = runInlineRules(text, LeveledRules, parseInlineMD); + + return result ?? text; +}; diff --git a/src/app/plugins/markdown/inline/rules.ts b/src/app/plugins/markdown/inline/rules.ts new file mode 100644 index 00000000..77bcbd57 --- /dev/null +++ b/src/app/plugins/markdown/inline/rules.ts @@ -0,0 +1,124 @@ +import { InlineMDRule } from './type'; + +const MIN_ANY = '(.+?)'; +const URL_NEG_LB = '(? text.match(BOLD_REG_1), + html: (parse, match) => { + const [, , g2] = match; + return `${parse(g2)}`; + }, +}; + +const ITALIC_MD_1 = '*'; +const ITALIC_PREFIX_1 = `${ESC_NEG_LB}\\*`; +const ITALIC_NEG_LA_1 = '(?!\\*)'; +const ITALIC_REG_1 = new RegExp( + `${URL_NEG_LB}${ITALIC_PREFIX_1}${MIN_ANY}${ITALIC_PREFIX_1}${ITALIC_NEG_LA_1}` +); +export const ItalicRule1: InlineMDRule = { + match: (text) => text.match(ITALIC_REG_1), + html: (parse, match) => { + const [, , g2] = match; + return `${parse(g2)}`; + }, +}; + +const ITALIC_MD_2 = '_'; +const ITALIC_PREFIX_2 = `${ESC_NEG_LB}_`; +const ITALIC_NEG_LA_2 = '(?!_)'; +const ITALIC_REG_2 = new RegExp( + `${URL_NEG_LB}${ITALIC_PREFIX_2}${MIN_ANY}${ITALIC_PREFIX_2}${ITALIC_NEG_LA_2}` +); +export const ItalicRule2: InlineMDRule = { + match: (text) => text.match(ITALIC_REG_2), + html: (parse, match) => { + const [, , g2] = match; + return `${parse(g2)}`; + }, +}; + +const UNDERLINE_MD_1 = '__'; +const UNDERLINE_PREFIX_1 = `${ESC_NEG_LB}_{2}`; +const UNDERLINE_NEG_LA_1 = '(?!_)'; +const UNDERLINE_REG_1 = new RegExp( + `${URL_NEG_LB}${UNDERLINE_PREFIX_1}${MIN_ANY}${UNDERLINE_PREFIX_1}${UNDERLINE_NEG_LA_1}` +); +export const UnderlineRule: InlineMDRule = { + match: (text) => text.match(UNDERLINE_REG_1), + html: (parse, match) => { + const [, , g2] = match; + return `${parse(g2)}`; + }, +}; + +const STRIKE_MD_1 = '~~'; +const STRIKE_PREFIX_1 = `${ESC_NEG_LB}~{2}`; +const STRIKE_NEG_LA_1 = '(?!~)'; +const STRIKE_REG_1 = new RegExp( + `${URL_NEG_LB}${STRIKE_PREFIX_1}${MIN_ANY}${STRIKE_PREFIX_1}${STRIKE_NEG_LA_1}` +); +export const StrikeRule: InlineMDRule = { + match: (text) => text.match(STRIKE_REG_1), + html: (parse, match) => { + const [, , g2] = match; + return `${parse(g2)}`; + }, +}; + +const CODE_MD_1 = '`'; +const CODE_PREFIX_1 = `${ESC_NEG_LB}\``; +const CODE_NEG_LA_1 = '(?!`)'; +const CODE_REG_1 = new RegExp(`${URL_NEG_LB}${CODE_PREFIX_1}(.+?)${CODE_PREFIX_1}${CODE_NEG_LA_1}`); +export const CodeRule: InlineMDRule = { + match: (text) => text.match(CODE_REG_1), + html: (parse, match) => { + const [, , g2] = match; + return `${g2}`; + }, +}; + +const SPOILER_MD_1 = '||'; +const SPOILER_PREFIX_1 = `${ESC_NEG_LB}\\|{2}`; +const SPOILER_NEG_LA_1 = '(?!\\|)'; +const SPOILER_REG_1 = new RegExp( + `${URL_NEG_LB}${SPOILER_PREFIX_1}${MIN_ANY}${SPOILER_PREFIX_1}${SPOILER_NEG_LA_1}` +); +export const SpoilerRule: InlineMDRule = { + match: (text) => text.match(SPOILER_REG_1), + html: (parse, match) => { + const [, , g2] = match; + return `${parse(g2)}`; + }, +}; + +const LINK_ALT = `\\[${MIN_ANY}\\]`; +const LINK_URL = `\\((https?:\\/\\/.+?)\\)`; +const LINK_REG_1 = new RegExp(`${LINK_ALT}${LINK_URL}`); +export const LinkRule: InlineMDRule = { + match: (text) => text.match(LINK_REG_1), + html: (parse, match) => { + const [, g1, g2] = match; + return `${parse(g1)}`; + }, +}; + +export const INLINE_SEQUENCE_SET = '[*_~`|]'; +export const CAP_INLINE_SEQ = `${URL_NEG_LB}${INLINE_SEQUENCE_SET}`; +const ESC_SEQ_1 = `\\\\(${INLINE_SEQUENCE_SET})`; +const ESC_REG_1 = new RegExp(`${URL_NEG_LB}${ESC_SEQ_1}`); +export const EscapeRule: InlineMDRule = { + match: (text) => text.match(ESC_REG_1), + html: (parse, match) => { + const [, , g2] = match; + return g2; + }, +}; diff --git a/src/app/plugins/markdown/inline/runner.ts b/src/app/plugins/markdown/inline/runner.ts new file mode 100644 index 00000000..3a794d25 --- /dev/null +++ b/src/app/plugins/markdown/inline/runner.ts @@ -0,0 +1,62 @@ +import { MatchResult, replaceMatch } from '../internal'; +import { InlineMDParser, InlineMDRule } from './type'; + +/** + * Runs a single markdown rule on the provided text. + * + * @param text - The text to parse. + * @param rule - The markdown rule to run. + * @param parse - A function that run the parser on remaining parts. + * @returns The text with the markdown rule applied or `undefined` if no match is found. + */ +export const runInlineRule = ( + text: string, + rule: InlineMDRule, + parse: InlineMDParser +): string | undefined => { + const matchResult = rule.match(text); + if (matchResult) { + const content = rule.html(parse, matchResult); + return replaceMatch(text, matchResult, content, (txt) => [parse(txt)]).join(''); + } + return undefined; +}; + +/** + * Runs multiple rules at the same time to better handle nested rules. + * Rules will be run in the order they appear. + * + * @param text - The text to parse. + * @param rules - The markdown rules to run. + * @param parse - A function that run the parser on remaining parts. + * @returns The text with the markdown rules applied or `undefined` if no match is found. + */ +export const runInlineRules = ( + text: string, + rules: InlineMDRule[], + parse: InlineMDParser +): string | undefined => { + const matchResults = rules.map((rule) => rule.match(text)); + + let targetRule: InlineMDRule | undefined; + let targetResult: MatchResult | undefined; + + for (let i = 0; i < matchResults.length; i += 1) { + const currentResult = matchResults[i]; + if (currentResult && typeof currentResult.index === 'number') { + if ( + !targetResult || + (typeof targetResult?.index === 'number' && currentResult.index < targetResult.index) + ) { + targetResult = currentResult; + targetRule = rules[i]; + } + } + } + + if (targetRule && targetResult) { + const content = targetRule.html(parse, targetResult); + return replaceMatch(text, targetResult, content, (txt) => [parse(txt)]).join(''); + } + return undefined; +}; diff --git a/src/app/plugins/markdown/inline/type.ts b/src/app/plugins/markdown/inline/type.ts new file mode 100644 index 00000000..a65ad276 --- /dev/null +++ b/src/app/plugins/markdown/inline/type.ts @@ -0,0 +1,26 @@ +import { MatchResult, MatchRule } from '../internal'; + +/** + * Type for a function that parses inline markdown into HTML. + * + * @param text - The markdown text to be parsed. + * @returns The parsed HTML. + */ +export type InlineMDParser = (text: string) => string; + +/** + * Type for a function that converts a match to output. + * + * @param parse - The inline markdown parser function. + * @param match - The match result. + * @returns The output string after processing the match. + */ +export type InlineMatchConverter = (parse: InlineMDParser, match: MatchResult) => string; + +/** + * Type representing a markdown rule that includes a matching pattern and HTML conversion. + */ +export type InlineMDRule = { + match: MatchRule; // A function that matches a specific markdown pattern. + html: InlineMatchConverter; // A function that converts the match to HTML. +}; diff --git a/src/app/plugins/markdown/internal/index.ts b/src/app/plugins/markdown/internal/index.ts new file mode 100644 index 00000000..04bca77e --- /dev/null +++ b/src/app/plugins/markdown/internal/index.ts @@ -0,0 +1 @@ +export * from './utils'; diff --git a/src/app/plugins/markdown/internal/utils.ts b/src/app/plugins/markdown/internal/utils.ts new file mode 100644 index 00000000..86bc9d5c --- /dev/null +++ b/src/app/plugins/markdown/internal/utils.ts @@ -0,0 +1,61 @@ +/** + * @typedef {RegExpMatchArray | RegExpExecArray} MatchResult + * + * Represents the result of a regular expression match. + * This type can be either a `RegExpMatchArray` or a `RegExpExecArray`, + * which are returned when performing a match with a regular expression. + * + * @see {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/exec} + * @see {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/match} + */ +export type MatchResult = RegExpMatchArray | RegExpExecArray; + +/** + * @typedef {function(string): MatchResult | null} MatchRule + * + * A function type that takes a string and returns a `MatchResult` or `null` if no match is found. + * + * @param {string} text The string to match against. + * @returns {MatchResult | null} The result of the regular expression match, or `null` if no match is found. + */ +export type MatchRule = (text: string) => MatchResult | null; + +/** + * Returns the part of the text before a match. + * + * @param text - The input text string. + * @param match - The match result (e.g., `RegExpMatchArray` or `RegExpExecArray`). + * @returns A string containing the part of the text before the match. + */ +export const beforeMatch = (text: string, match: RegExpMatchArray | RegExpExecArray): string => + text.slice(0, match.index); + +/** + * Returns the part of the text after a match. + * + * @param text - The input text string. + * @param match - The match result (e.g., `RegExpMatchArray` or `RegExpExecArray`). + * @returns A string containing the part of the text after the match. + */ +export const afterMatch = (text: string, match: RegExpMatchArray | RegExpExecArray): string => + text.slice((match.index ?? 0) + match[0].length); + +/** + * Replaces a match in the text with a content. + * + * @param text - The input text string. + * @param match - The match result (e.g., `RegExpMatchArray` or `RegExpExecArray`). + * @param content - The content to replace the match with. + * @param processPart - A function to further process remaining parts of the text. + * @returns An array containing the processed parts of the text, including the content. + */ +export const replaceMatch = ( + text: string, + match: MatchResult, + content: C, + processPart: (txt: string) => Array +): Array => [ + ...processPart(beforeMatch(text, match)), + content, + ...processPart(afterMatch(text, match)), +]; diff --git a/src/app/plugins/markdown/utils.ts b/src/app/plugins/markdown/utils.ts new file mode 100644 index 00000000..0038df13 --- /dev/null +++ b/src/app/plugins/markdown/utils.ts @@ -0,0 +1,83 @@ +import { findAndReplace } from '../../utils/findAndReplace'; +import { ESC_BLOCK_SEQ, UN_ESC_BLOCK_SEQ } from './block/rules'; +import { EscapeRule, CAP_INLINE_SEQ } from './inline/rules'; +import { runInlineRule } from './inline/runner'; +import { replaceMatch } from './internal'; + +/** + * Removes escape sequences from markdown inline elements in the given plain-text. + * This function unescapes characters that are escaped with backslashes (e.g., `\*`, `\_`) + * in markdown syntax, returning the original plain-text with markdown characters in effect. + * + * @param text - The input markdown plain-text containing escape characters (e.g., `"some \*italic\*"`) + * @returns The plain-text with markdown escape sequences removed (e.g., `"some *italic*"`) + */ +export const unescapeMarkdownInlineSequences = (text: string): string => + runInlineRule(text, EscapeRule, (t) => { + if (t === '') return t; + return unescapeMarkdownInlineSequences(t); + }) ?? text; + +/** + * Recovers the markdown escape sequences in the given plain-text. + * This function adds backslashes (`\`) before markdown characters that may need escaping + * (e.g., `*`, `_`) to ensure they are treated as literal characters and not part of markdown formatting. + * + * @param text - The input plain-text that may contain markdown sequences (e.g., `"some *italic*"`) + * @returns The plain-text with markdown escape sequences added (e.g., `"some \*italic\*"`) + */ +export const escapeMarkdownInlineSequences = (text: string): string => { + const regex = new RegExp(`(${CAP_INLINE_SEQ})`, 'g'); + const parts = findAndReplace( + text, + regex, + (match) => { + const [, g1] = match; + return `\\${g1}`; + }, + (t) => t + ); + + return parts.join(''); +}; + +/** + * Removes escape sequences from markdown block elements in the given plain-text. + * This function unescapes characters that are escaped with backslashes (e.g., `\>`, `\#`) + * in markdown syntax, returning the original plain-text with markdown characters in effect. + * + * @param {string} text - The input markdown plain-text containing escape characters (e.g., `\> block quote`). + * @param {function} processPart - It takes the plain-text as input and returns a modified version of it. + * @returns {string} The plain-text with markdown escape sequences removed and markdown formatting applied. + */ +export const unescapeMarkdownBlockSequences = ( + text: string, + processPart: (text: string) => string +): string => { + const match = text.match(ESC_BLOCK_SEQ); + + if (!match) return processPart(text); + + const [, g1] = match; + return replaceMatch(text, match, g1, (t) => [processPart(t)]).join(''); +}; + +/** + * Escapes markdown block elements by adding backslashes before markdown characters + * (e.g., `\>`, `\#`) that are normally interpreted as markdown syntax. + * + * @param {string} text - The input markdown plain-text that may contain markdown elements (e.g., `> block quote`). + * @param {function} processPart - It takes the plain-text as input and returns a modified version of it. + * @returns {string} The plain-text with markdown escape sequences added, preventing markdown formatting. + */ +export const escapeMarkdownBlockSequences = ( + text: string, + processPart: (text: string) => string +): string => { + const match = text.match(UN_ESC_BLOCK_SEQ); + + if (!match) return processPart(text); + + const [, g1] = match; + return replaceMatch(text, match, `\\${g1}`, (t) => [processPart(t)]).join(''); +}; diff --git a/src/app/plugins/react-custom-html-parser.tsx b/src/app/plugins/react-custom-html-parser.tsx index 6a4d0530..cd683e36 100644 --- a/src/app/plugins/react-custom-html-parser.tsx +++ b/src/app/plugins/react-custom-html-parser.tsx @@ -21,7 +21,7 @@ import { mxcUrlToHttp, } from '../utils/matrix'; import { getMemberDisplayName } from '../utils/room'; -import { EMOJI_PATTERN, URL_NEG_LB } from '../utils/regex'; +import { EMOJI_PATTERN, sanitizeForRegex, URL_NEG_LB } from '../utils/regex'; import { getHexcodeForEmoji, getShortcodeFor } from './emoji'; import { findAndReplace } from '../utils/findAndReplace'; import { @@ -171,7 +171,7 @@ export const scaleSystemEmoji = (text: string): (string | JSX.Element)[] => ); export const makeHighlightRegex = (highlights: string[]): RegExp | undefined => { - const pattern = highlights.join('|'); + const pattern = highlights.map(sanitizeForRegex).join('|'); if (!pattern) return undefined; return new RegExp(pattern, 'gi'); }; diff --git a/src/app/plugins/utils.ts b/src/app/plugins/utils.ts new file mode 100644 index 00000000..27d27e7a --- /dev/null +++ b/src/app/plugins/utils.ts @@ -0,0 +1,19 @@ +import { SearchItemStrGetter } from '../hooks/useAsyncSearch'; +import { PackImageReader } from './custom-emoji'; +import { IEmoji } from './emoji'; + +export const getEmoticonSearchStr: SearchItemStrGetter = (item) => { + const shortcode = `:${item.shortcode}:`; + if (item instanceof PackImageReader) { + if (item.body) { + return [shortcode, item.body]; + } + return shortcode; + } + + const names = [shortcode, item.label]; + if (Array.isArray(item.shortcodes)) { + return names.concat(item.shortcodes); + } + return names; +}; diff --git a/src/app/state/room/roomToUnread.ts b/src/app/state/room/roomToUnread.ts index 8cb9d958..bf99fe34 100644 --- a/src/app/state/room/roomToUnread.ts +++ b/src/app/state/room/roomToUnread.ts @@ -228,20 +228,18 @@ export const useBindRoomToUnreadAtom = ( useEffect(() => { const handleReceipt = (mEvent: MatrixEvent, room: Room) => { - if (mEvent.getType() === 'm.receipt') { - const myUserId = mx.getUserId(); - if (!myUserId) return; - if (room.isSpaceRoom()) return; - const content = mEvent.getContent(); + const myUserId = mx.getUserId(); + if (!myUserId) return; + if (room.isSpaceRoom()) return; + const content = mEvent.getContent(); - const isMyReceipt = Object.keys(content).find((eventId) => - (Object.keys(content[eventId]) as ReceiptType[]).find( - (receiptType) => content[eventId][receiptType][myUserId] - ) - ); - if (isMyReceipt) { - setUnreadAtom({ type: 'DELETE', roomId: room.roomId }); - } + const isMyReceipt = Object.keys(content).find((eventId) => + (Object.keys(content[eventId]) as ReceiptType[]).find( + (receiptType) => content[eventId][receiptType][myUserId] + ) + ); + if (isMyReceipt) { + setUnreadAtom({ type: 'DELETE', roomId: room.roomId }); } }; mx.on(RoomEvent.Receipt, handleReceipt); diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index ac47e78b..9d979195 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -17,6 +17,7 @@ export interface Settings { editorToolbar: boolean; twitterEmoji: boolean; pageZoom: number; + hideActivity: boolean; isPeopleDrawer: boolean; memberSortFilterIndex: number; @@ -45,6 +46,7 @@ const defaultSettings: Settings = { editorToolbar: false, twitterEmoji: false, pageZoom: 100, + hideActivity: false, isPeopleDrawer: true, memberSortFilterIndex: 0, diff --git a/src/app/state/spaceRooms.ts b/src/app/state/spaceRooms.ts index 8480498d..94abe2bc 100644 --- a/src/app/state/spaceRooms.ts +++ b/src/app/state/spaceRooms.ts @@ -23,32 +23,37 @@ const baseSpaceRoomsAtom = atomWithLocalStorage>( type SpaceRoomsAction = | { type: 'PUT'; - roomId: string; + roomIds: string[]; } | { type: 'DELETE'; - roomId: string; + roomIds: string[]; }; export const spaceRoomsAtom = atom, [SpaceRoomsAction], undefined>( (get) => get(baseSpaceRoomsAtom), (get, set, action) => { - if (action.type === 'DELETE') { + const current = get(baseSpaceRoomsAtom); + const { type, roomIds } = action; + + if (type === 'DELETE' && roomIds.find((roomId) => current.has(roomId))) { set( baseSpaceRoomsAtom, - produce(get(baseSpaceRoomsAtom), (draft) => { - draft.delete(action.roomId); + produce(current, (draft) => { + roomIds.forEach((roomId) => draft.delete(roomId)); }) ); return; } - if (action.type === 'PUT') { - set( - baseSpaceRoomsAtom, - produce(get(baseSpaceRoomsAtom), (draft) => { - draft.add(action.roomId); - }) - ); + if (type === 'PUT') { + const newEntries = roomIds.filter((roomId) => !current.has(roomId)); + if (newEntries.length > 0) + set( + baseSpaceRoomsAtom, + produce(current, (draft) => { + newEntries.forEach((roomId) => draft.add(roomId)); + }) + ); } } ); diff --git a/src/app/state/typingMembers.ts b/src/app/state/typingMembers.ts index 55bf8f62..e94ba972 100644 --- a/src/app/state/typingMembers.ts +++ b/src/app/state/typingMembers.ts @@ -2,6 +2,8 @@ import produce from 'immer'; import { atom, useSetAtom } from 'jotai'; import { MatrixClient, RoomMemberEvent, RoomMemberEventHandlerMap } from 'matrix-js-sdk'; import { useEffect } from 'react'; +import { useSetting } from './hooks/settings'; +import { settingsAtom } from './settings'; export const TYPING_TIMEOUT_MS = 5000; // 5 seconds @@ -127,12 +129,16 @@ export const useBindRoomIdToTypingMembersAtom = ( typingMembersAtom: typeof roomIdToTypingMembersAtom ) => { const setTypingMembers = useSetAtom(typingMembersAtom); + const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); useEffect(() => { const handleTypingEvent: RoomMemberEventHandlerMap[RoomMemberEvent.Typing] = ( event, member ) => { + if (hideActivity) { + return; + } setTypingMembers({ type: member.typing ? 'PUT' : 'DELETE', roomId: member.roomId, @@ -145,5 +151,5 @@ export const useBindRoomIdToTypingMembersAtom = ( return () => { mx.removeListener(RoomMemberEvent.Typing, handleTypingEvent); }; - }, [mx, setTypingMembers]); + }, [mx, setTypingMembers, hideActivity]); }; diff --git a/src/app/utils/regex.ts b/src/app/utils/regex.ts index d7169062..0b98b0e2 100644 --- a/src/app/utils/regex.ts +++ b/src/app/utils/regex.ts @@ -1,3 +1,9 @@ +/** + * https://www.npmjs.com/package/escape-string-regexp + */ +export const sanitizeForRegex = (unsafeText: string): string => + unsafeText.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&').replace(/-/g, '\\x2d'); + export const HTTP_URL_PATTERN = `https?:\\/\\/(?:www\\.)?(?:[^\\s)]*)(? mEvent.getRelation()?.rel_type === RelationType.Annotation || mEvent.getRelation()?.rel_type === RelationType.Replace; + +export const getMentionContent = (userIds: string[], room: boolean): IMentions => { + const mMentions: IMentions = {}; + if (userIds.length > 0) { + mMentions.user_ids = userIds; + } + if (room) { + mMentions.room = true; + } + + return mMentions; +}; diff --git a/src/client/action/notifications.ts b/src/client/action/notifications.ts index 17ea1ed6..a23bd1a4 100644 --- a/src/client/action/notifications.ts +++ b/src/client/action/notifications.ts @@ -1,6 +1,6 @@ -import { MatrixClient } from "matrix-js-sdk"; +import { MatrixClient, ReceiptType } from 'matrix-js-sdk'; -export async function markAsRead(mx: MatrixClient, roomId: string) { +export async function markAsRead(mx: MatrixClient, roomId: string, privateReceipt: boolean) { const room = mx.getRoom(roomId); if (!room) return; @@ -19,5 +19,8 @@ export async function markAsRead(mx: MatrixClient, roomId: string) { const latestEvent = getLatestValidEvent(); if (latestEvent === null) return; - await mx.sendReadReceipt(latestEvent); + await mx.sendReadReceipt( + latestEvent, + privateReceipt ? ReceiptType.ReadPrivate : ReceiptType.Read + ); } diff --git a/src/client/state/cons.js b/src/client/state/cons.js index 419175a1..d214ebb7 100644 --- a/src/client/state/cons.js +++ b/src/client/state/cons.js @@ -1,5 +1,5 @@ const cons = { - version: '4.3.2', + version: '4.5.1', secretKey: { ACCESS_TOKEN: 'cinny_access_token', DEVICE_ID: 'cinny_device_id',