mirror of
				https://github.com/cinnyapp/cinny.git
				synced 2025-11-04 06:20:28 +03:00 
			
		
		
		
	Merge branch 'cinnyapp:dev' into dev
This commit is contained in:
		
						commit
						485530f871
					
				
					 126 changed files with 4150 additions and 1124 deletions
				
			
		
							
								
								
									
										2
									
								
								.github/workflows/build-pull-request.yml
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/build-pull-request.yml
									
										
									
									
										vendored
									
									
								
							| 
						 | 
				
			
			@ -14,7 +14,7 @@ jobs:
 | 
			
		|||
      - name: Checkout repository
 | 
			
		||||
        uses: actions/checkout@v4.2.0
 | 
			
		||||
      - name: Setup node
 | 
			
		||||
        uses: actions/setup-node@v4.3.0
 | 
			
		||||
        uses: actions/setup-node@v4.4.0
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: 20.12.2
 | 
			
		||||
          cache: 'npm'
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										4
									
								
								.github/workflows/deploy-pull-request.yml
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/deploy-pull-request.yml
									
										
									
									
										vendored
									
									
								
							| 
						 | 
				
			
			@ -15,7 +15,7 @@ jobs:
 | 
			
		|||
    if: ${{ github.event.workflow_run.conclusion == 'success' }}
 | 
			
		||||
    steps:
 | 
			
		||||
      - name: Download pr number
 | 
			
		||||
        uses: dawidd6/action-download-artifact@07ab29fd4a977ae4d2b275087cf67563dfdf0295
 | 
			
		||||
        uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5
 | 
			
		||||
        with:
 | 
			
		||||
          workflow: ${{ github.event.workflow.id }}
 | 
			
		||||
          run_id: ${{ github.event.workflow_run.id }}
 | 
			
		||||
| 
						 | 
				
			
			@ -24,7 +24,7 @@ jobs:
 | 
			
		|||
        id: pr
 | 
			
		||||
        run: echo "id=$(<pr.txt)" >> $GITHUB_OUTPUT
 | 
			
		||||
      - name: Download artifact
 | 
			
		||||
        uses: dawidd6/action-download-artifact@07ab29fd4a977ae4d2b275087cf67563dfdf0295
 | 
			
		||||
        uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5
 | 
			
		||||
        with:
 | 
			
		||||
          workflow: ${{ github.event.workflow.id }}
 | 
			
		||||
          run_id: ${{ github.event.workflow_run.id }}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										2
									
								
								.github/workflows/docker-pr.yml
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/docker-pr.yml
									
										
									
									
										vendored
									
									
								
							| 
						 | 
				
			
			@ -13,7 +13,7 @@ jobs:
 | 
			
		|||
      - name: Checkout repository
 | 
			
		||||
        uses: actions/checkout@v4.2.0
 | 
			
		||||
      - name: Build Docker image
 | 
			
		||||
        uses: docker/build-push-action@v6.15.0
 | 
			
		||||
        uses: docker/build-push-action@v6.18.0
 | 
			
		||||
        with:
 | 
			
		||||
          context: .
 | 
			
		||||
          push: false
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										2
									
								
								.github/workflows/netlify-dev.yml
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/netlify-dev.yml
									
										
									
									
										vendored
									
									
								
							| 
						 | 
				
			
			@ -13,7 +13,7 @@ jobs:
 | 
			
		|||
      - name: Checkout repository
 | 
			
		||||
        uses: actions/checkout@v4.2.0
 | 
			
		||||
      - name: Setup node
 | 
			
		||||
        uses: actions/setup-node@v4.3.0
 | 
			
		||||
        uses: actions/setup-node@v4.4.0
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: 20.12.2
 | 
			
		||||
          cache: 'npm'
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										6
									
								
								.github/workflows/prod-deploy.yml
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								.github/workflows/prod-deploy.yml
									
										
									
									
										vendored
									
									
								
							| 
						 | 
				
			
			@ -12,7 +12,7 @@ jobs:
 | 
			
		|||
      - name: Checkout repository
 | 
			
		||||
        uses: actions/checkout@v4.2.0
 | 
			
		||||
      - name: Setup node
 | 
			
		||||
        uses: actions/setup-node@v4.3.0
 | 
			
		||||
        uses: actions/setup-node@v4.4.0
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: 20.12.2
 | 
			
		||||
          cache: 'npm'
 | 
			
		||||
| 
						 | 
				
			
			@ -52,7 +52,7 @@ jobs:
 | 
			
		|||
          gpg --export | xxd -p
 | 
			
		||||
          echo '${{ secrets.GNUPG_PASSPHRASE }}' | gpg --batch --yes --pinentry-mode loopback --passphrase-fd 0 --armor --detach-sign cinny-${{ steps.vars.outputs.tag }}.tar.gz
 | 
			
		||||
      - name: Upload tagged release
 | 
			
		||||
        uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda
 | 
			
		||||
        uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8
 | 
			
		||||
        with:
 | 
			
		||||
          files: |
 | 
			
		||||
            cinny-${{ steps.vars.outputs.tag }}.tar.gz
 | 
			
		||||
| 
						 | 
				
			
			@ -90,7 +90,7 @@ jobs:
 | 
			
		|||
            ${{ secrets.DOCKER_USERNAME }}/cinny
 | 
			
		||||
            ghcr.io/${{ github.repository }}
 | 
			
		||||
      - name: Build and push Docker image
 | 
			
		||||
        uses: docker/build-push-action@v6.15.0
 | 
			
		||||
        uses: docker/build-push-action@v6.18.0
 | 
			
		||||
        with:
 | 
			
		||||
          context: .
 | 
			
		||||
          platforms: linux/amd64,linux/arm64
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -11,7 +11,7 @@ RUN npm run build
 | 
			
		|||
 | 
			
		||||
 | 
			
		||||
## App
 | 
			
		||||
FROM nginx:1.27.4-alpine
 | 
			
		||||
FROM nginx:1.29.0-alpine
 | 
			
		||||
 | 
			
		||||
COPY --from=builder /src/dist /app
 | 
			
		||||
COPY --from=builder /src/docker-nginx.conf /etc/nginx/conf.d/default.conf
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -23,7 +23,6 @@ server {
 | 
			
		|||
        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;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -8,7 +8,6 @@ server {
 | 
			
		|||
    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;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -13,11 +13,6 @@
 | 
			
		|||
  to = "/sw.js"
 | 
			
		||||
  status = 200
 | 
			
		||||
 | 
			
		||||
[[redirects]]
 | 
			
		||||
  from = "*/olm.wasm"
 | 
			
		||||
  to = "/olm.wasm"
 | 
			
		||||
  status = 200
 | 
			
		||||
  force = true
 | 
			
		||||
  
 | 
			
		||||
[[redirects]]
 | 
			
		||||
  from = "/pdf.worker.min.js"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										115
									
								
								package-lock.json
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										115
									
								
								package-lock.json
									
										
									
										generated
									
									
									
								
							| 
						 | 
				
			
			@ -1,19 +1,18 @@
 | 
			
		|||
{
 | 
			
		||||
  "name": "cinny",
 | 
			
		||||
  "version": "4.5.1",
 | 
			
		||||
  "version": "4.8.1",
 | 
			
		||||
  "lockfileVersion": 3,
 | 
			
		||||
  "requires": true,
 | 
			
		||||
  "packages": {
 | 
			
		||||
    "": {
 | 
			
		||||
      "name": "cinny",
 | 
			
		||||
      "version": "4.5.1",
 | 
			
		||||
      "version": "4.8.1",
 | 
			
		||||
      "license": "AGPL-3.0-only",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "@atlaskit/pragmatic-drag-and-drop": "1.1.6",
 | 
			
		||||
        "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
 | 
			
		||||
        "@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
 | 
			
		||||
        "@fontsource/inter": "4.5.14",
 | 
			
		||||
        "@matrix-org/olm": "3.2.15",
 | 
			
		||||
        "@tanstack/react-query": "5.24.1",
 | 
			
		||||
        "@tanstack/react-query-devtools": "5.24.1",
 | 
			
		||||
        "@tanstack/react-virtual": "3.2.0",
 | 
			
		||||
| 
						 | 
				
			
			@ -22,6 +21,7 @@
 | 
			
		|||
        "@vanilla-extract/recipes": "0.3.0",
 | 
			
		||||
        "@vanilla-extract/vite-plugin": "3.7.1",
 | 
			
		||||
        "await-to-js": "3.0.0",
 | 
			
		||||
        "badwords-list": "2.0.1-4",
 | 
			
		||||
        "blurhash": "2.0.4",
 | 
			
		||||
        "browser-encrypt-attachment": "0.3.0",
 | 
			
		||||
        "chroma-js": "3.1.2",
 | 
			
		||||
| 
						 | 
				
			
			@ -34,7 +34,7 @@
 | 
			
		|||
        "file-saver": "2.0.5",
 | 
			
		||||
        "flux": "4.0.3",
 | 
			
		||||
        "focus-trap-react": "10.0.2",
 | 
			
		||||
        "folds": "2.1.0",
 | 
			
		||||
        "folds": "2.2.0",
 | 
			
		||||
        "formik": "2.4.6",
 | 
			
		||||
        "html-dom-parser": "4.0.0",
 | 
			
		||||
        "html-react-parser": "4.2.0",
 | 
			
		||||
| 
						 | 
				
			
			@ -46,7 +46,7 @@
 | 
			
		|||
        "jotai": "2.6.0",
 | 
			
		||||
        "linkify-react": "4.1.3",
 | 
			
		||||
        "linkifyjs": "4.1.3",
 | 
			
		||||
        "matrix-js-sdk": "35.0.0",
 | 
			
		||||
        "matrix-js-sdk": "37.5.0",
 | 
			
		||||
        "millify": "6.1.0",
 | 
			
		||||
        "pdfjs-dist": "4.2.67",
 | 
			
		||||
        "prismjs": "1.30.0",
 | 
			
		||||
| 
						 | 
				
			
			@ -99,7 +99,7 @@
 | 
			
		|||
        "prettier": "2.8.1",
 | 
			
		||||
        "sass": "1.56.2",
 | 
			
		||||
        "typescript": "4.9.4",
 | 
			
		||||
        "vite": "5.4.15",
 | 
			
		||||
        "vite": "5.4.19",
 | 
			
		||||
        "vite-plugin-pwa": "0.20.5",
 | 
			
		||||
        "vite-plugin-static-copy": "1.0.4",
 | 
			
		||||
        "vite-plugin-top-level-await": "1.4.4"
 | 
			
		||||
| 
						 | 
				
			
			@ -2264,17 +2264,19 @@
 | 
			
		|||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
 | 
			
		||||
      "version": "11.1.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-11.1.0.tgz",
 | 
			
		||||
      "integrity": "sha512-JPuO9RCVDklDjbFzMvZfQb7PuiFkLY72bniRSu81lRzkkrcbZtmKqBFMm9H4f2FSz+tHVkDnmsvn12I4sdJJ5A==",
 | 
			
		||||
      "version": "14.1.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-14.1.0.tgz",
 | 
			
		||||
      "integrity": "sha512-vcSxHJIr6lP0Fgo8jl0sTHg+OZxZn+skGjiyB62erfgw/R2QqJl0ZVSY8SRcbk9LtHo/ZGld1tnaOyjL2e3cLQ==",
 | 
			
		||||
      "license": "Apache-2.0",
 | 
			
		||||
      "engines": {
 | 
			
		||||
        "node": ">= 10"
 | 
			
		||||
        "node": ">= 18"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@matrix-org/olm": {
 | 
			
		||||
      "version": "3.2.15",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@matrix-org/olm/-/olm-3.2.15.tgz",
 | 
			
		||||
      "integrity": "sha512-S7lOrndAK9/8qOtaTq/WhttJC/o4GAzdfK0MUPpo8ApzsJEC0QjtwrkC3KBXdFP1cD1MXi/mlKR7aaoVMKgs6Q=="
 | 
			
		||||
      "integrity": "sha512-S7lOrndAK9/8qOtaTq/WhttJC/o4GAzdfK0MUPpo8ApzsJEC0QjtwrkC3KBXdFP1cD1MXi/mlKR7aaoVMKgs6Q==",
 | 
			
		||||
      "license": "Apache-2.0"
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@nodelib/fs.scandir": {
 | 
			
		||||
      "version": "2.1.5",
 | 
			
		||||
| 
						 | 
				
			
			@ -4590,7 +4592,8 @@
 | 
			
		|||
    "node_modules/@types/events": {
 | 
			
		||||
      "version": "3.0.3",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.3.tgz",
 | 
			
		||||
      "integrity": "sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g=="
 | 
			
		||||
      "integrity": "sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==",
 | 
			
		||||
      "license": "MIT"
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@types/file-saver": {
 | 
			
		||||
      "version": "2.0.5",
 | 
			
		||||
| 
						 | 
				
			
			@ -4679,7 +4682,8 @@
 | 
			
		|||
    "node_modules/@types/retry": {
 | 
			
		||||
      "version": "0.12.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz",
 | 
			
		||||
      "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="
 | 
			
		||||
      "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==",
 | 
			
		||||
      "license": "MIT"
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@types/sanitize-html": {
 | 
			
		||||
      "version": "2.9.0",
 | 
			
		||||
| 
						 | 
				
			
			@ -5089,7 +5093,8 @@
 | 
			
		|||
    "node_modules/another-json": {
 | 
			
		||||
      "version": "0.2.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/another-json/-/another-json-0.2.0.tgz",
 | 
			
		||||
      "integrity": "sha512-/Ndrl68UQLhnCdsAzEXLMFuOR546o2qbYRqCglaNHbjXrwG1ayTcdwr3zkSGOGtGXDyR5X9nCFfnyG2AFJIsqg=="
 | 
			
		||||
      "integrity": "sha512-/Ndrl68UQLhnCdsAzEXLMFuOR546o2qbYRqCglaNHbjXrwG1ayTcdwr3zkSGOGtGXDyR5X9nCFfnyG2AFJIsqg==",
 | 
			
		||||
      "license": "Apache-2.0"
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/ansi-regex": {
 | 
			
		||||
      "version": "5.0.1",
 | 
			
		||||
| 
						 | 
				
			
			@ -5432,6 +5437,12 @@
 | 
			
		|||
        "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/badwords-list": {
 | 
			
		||||
      "version": "2.0.1-4",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/badwords-list/-/badwords-list-2.0.1-4.tgz",
 | 
			
		||||
      "integrity": "sha512-FxfZUp7B9yCnesNtFQS9v6PvZdxTYa14Q60JR6vhjdQdWI4naTjJIyx22JzoER8ooeT8SAAKoHLjKfCV7XgYUQ==",
 | 
			
		||||
      "license": "MIT"
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/balanced-match": {
 | 
			
		||||
      "version": "1.0.2",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
 | 
			
		||||
| 
						 | 
				
			
			@ -5439,9 +5450,10 @@
 | 
			
		|||
      "devOptional": true
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/base-x": {
 | 
			
		||||
      "version": "5.0.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.0.tgz",
 | 
			
		||||
      "integrity": "sha512-sMW3VGSX1QWVFA6l8U62MLKz29rRfpTlYdCqLdpLo1/Yd4zZwSbnUaDfciIAowAqvq7YFnWq9hrhdg1KYgc1lQ=="
 | 
			
		||||
      "version": "5.0.1",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz",
 | 
			
		||||
      "integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==",
 | 
			
		||||
      "license": "MIT"
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/base64-js": {
 | 
			
		||||
      "version": "1.5.1",
 | 
			
		||||
| 
						 | 
				
			
			@ -5547,6 +5559,7 @@
 | 
			
		|||
      "version": "6.0.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz",
 | 
			
		||||
      "integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==",
 | 
			
		||||
      "license": "MIT",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "base-x": "^5.0.0"
 | 
			
		||||
      }
 | 
			
		||||
| 
						 | 
				
			
			@ -5849,6 +5862,7 @@
 | 
			
		|||
      "version": "1.0.5",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
 | 
			
		||||
      "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
 | 
			
		||||
      "license": "MIT",
 | 
			
		||||
      "engines": {
 | 
			
		||||
        "node": ">= 0.6"
 | 
			
		||||
      }
 | 
			
		||||
| 
						 | 
				
			
			@ -7000,6 +7014,7 @@
 | 
			
		|||
      "version": "3.3.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
 | 
			
		||||
      "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
 | 
			
		||||
      "license": "MIT",
 | 
			
		||||
      "engines": {
 | 
			
		||||
        "node": ">=0.8.x"
 | 
			
		||||
      }
 | 
			
		||||
| 
						 | 
				
			
			@ -7250,15 +7265,16 @@
 | 
			
		|||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/folds": {
 | 
			
		||||
      "version": "2.1.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/folds/-/folds-2.1.0.tgz",
 | 
			
		||||
      "integrity": "sha512-KwAG8bH3jsyZ9FKPMg+6ABV2YOcpp4nL0cCelsalnaPeRThkc5fgG1Xj5mhmdffYKjEXpEbERi5qmGbepgJryg==",
 | 
			
		||||
      "version": "2.2.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/folds/-/folds-2.2.0.tgz",
 | 
			
		||||
      "integrity": "sha512-uOfck5eWEIK11rhOAEdSoPIvMXwv+D1Go03pxSAKezWVb+uRoBdmE6LEqiLOF+ac4DGmZRMPvpdDsXCg7EVNIg==",
 | 
			
		||||
      "license": "Apache-2.0",
 | 
			
		||||
      "peerDependencies": {
 | 
			
		||||
        "@vanilla-extract/css": "^1.9.2",
 | 
			
		||||
        "@vanilla-extract/recipes": "^0.3.0",
 | 
			
		||||
        "classnames": "^2.3.2",
 | 
			
		||||
        "react": "^17.0.0",
 | 
			
		||||
        "react-dom": "^17.0.0"
 | 
			
		||||
        "@vanilla-extract/css": "1.9.2",
 | 
			
		||||
        "@vanilla-extract/recipes": "0.3.0",
 | 
			
		||||
        "classnames": "2.3.2",
 | 
			
		||||
        "react": "17.0.0",
 | 
			
		||||
        "react-dom": "17.0.0"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/for-each": {
 | 
			
		||||
| 
						 | 
				
			
			@ -8558,6 +8574,7 @@
 | 
			
		|||
      "version": "4.0.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
 | 
			
		||||
      "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==",
 | 
			
		||||
      "license": "MIT",
 | 
			
		||||
      "engines": {
 | 
			
		||||
        "node": ">=18"
 | 
			
		||||
      }
 | 
			
		||||
| 
						 | 
				
			
			@ -8690,6 +8707,7 @@
 | 
			
		|||
      "version": "1.9.2",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz",
 | 
			
		||||
      "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==",
 | 
			
		||||
      "license": "MIT",
 | 
			
		||||
      "engines": {
 | 
			
		||||
        "node": ">= 0.6.0"
 | 
			
		||||
      },
 | 
			
		||||
| 
						 | 
				
			
			@ -8765,21 +8783,23 @@
 | 
			
		|||
    "node_modules/matrix-events-sdk": {
 | 
			
		||||
      "version": "0.0.1",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/matrix-events-sdk/-/matrix-events-sdk-0.0.1.tgz",
 | 
			
		||||
      "integrity": "sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA=="
 | 
			
		||||
      "integrity": "sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==",
 | 
			
		||||
      "license": "Apache-2.0"
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/matrix-js-sdk": {
 | 
			
		||||
      "version": "35.0.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-35.0.0.tgz",
 | 
			
		||||
      "integrity": "sha512-X8hIsd/8x1SC9vRr8DiNKQxmdrfRujtvEWPz8mY4FxVDJG8HEGDHvqUmaSy2jrtnOUn4oHzGQVLFO3DnhsSf8w==",
 | 
			
		||||
      "version": "37.5.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-37.5.0.tgz",
 | 
			
		||||
      "integrity": "sha512-5tyuAi5hnKud1UkVq8Z2/3c22hWGELBZzErJPZkE6Hju2uGUfGtrIx6uj6puv0ZjvsUU3X6Qgm8vdReKO1PGig==",
 | 
			
		||||
      "license": "Apache-2.0",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "@babel/runtime": "^7.12.5",
 | 
			
		||||
        "@matrix-org/matrix-sdk-crypto-wasm": "^11.0.0",
 | 
			
		||||
        "@matrix-org/matrix-sdk-crypto-wasm": "^14.0.1",
 | 
			
		||||
        "@matrix-org/olm": "3.2.15",
 | 
			
		||||
        "another-json": "^0.2.0",
 | 
			
		||||
        "bs58": "^6.0.0",
 | 
			
		||||
        "content-type": "^1.0.4",
 | 
			
		||||
        "jwt-decode": "^4.0.0",
 | 
			
		||||
        "loglevel": "^1.7.1",
 | 
			
		||||
        "loglevel": "^1.9.2",
 | 
			
		||||
        "matrix-events-sdk": "0.0.1",
 | 
			
		||||
        "matrix-widget-api": "^1.10.0",
 | 
			
		||||
        "oidc-client-ts": "^3.0.1",
 | 
			
		||||
| 
						 | 
				
			
			@ -8793,21 +8813,23 @@
 | 
			
		|||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/matrix-js-sdk/node_modules/uuid": {
 | 
			
		||||
      "version": "11.0.5",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.5.tgz",
 | 
			
		||||
      "integrity": "sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==",
 | 
			
		||||
      "version": "11.1.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
 | 
			
		||||
      "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
 | 
			
		||||
      "funding": [
 | 
			
		||||
        "https://github.com/sponsors/broofa",
 | 
			
		||||
        "https://github.com/sponsors/ctavan"
 | 
			
		||||
      ],
 | 
			
		||||
      "license": "MIT",
 | 
			
		||||
      "bin": {
 | 
			
		||||
        "uuid": "dist/esm/bin/uuid"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/matrix-widget-api": {
 | 
			
		||||
      "version": "1.12.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.12.0.tgz",
 | 
			
		||||
      "integrity": "sha512-6JRd9fJGGvuBRhcTg9wX+Skn/Q1wox3jdp5yYQKJ6pPw4urW9bkTR90APBKVDB1vorJKT44jml+lCzkDMRBjww==",
 | 
			
		||||
      "version": "1.13.1",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.13.1.tgz",
 | 
			
		||||
      "integrity": "sha512-mkOHUVzaN018TCbObfGOSaMW2GoUxOfcxNNlTVx5/HeMk3OSQPQM0C9oEME5Liiv/dBUoSrEB64V8wF7e/gb1w==",
 | 
			
		||||
      "license": "Apache-2.0",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "@types/events": "^3.0.0",
 | 
			
		||||
        "events": "^3.2.0"
 | 
			
		||||
| 
						 | 
				
			
			@ -9199,9 +9221,10 @@
 | 
			
		|||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/oidc-client-ts": {
 | 
			
		||||
      "version": "3.1.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.1.0.tgz",
 | 
			
		||||
      "integrity": "sha512-IDopEXjiwjkmJLYZo6BTlvwOtnlSniWZkKZoXforC/oLZHC9wkIxd25Kwtmo5yKFMMVcsp3JY6bhcNJqdYk8+g==",
 | 
			
		||||
      "version": "3.2.1",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.2.1.tgz",
 | 
			
		||||
      "integrity": "sha512-hS5AJ5s/x4bXhHvNJT1v+GGvzHUwdRWqNQQbSrp10L1IRmzfRGKQ3VWN3dstJb+oF3WtAyKezwD2+dTEIyBiAA==",
 | 
			
		||||
      "license": "Apache-2.0",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "jwt-decode": "^4.0.0"
 | 
			
		||||
      },
 | 
			
		||||
| 
						 | 
				
			
			@ -9289,6 +9312,7 @@
 | 
			
		|||
      "version": "4.6.2",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz",
 | 
			
		||||
      "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==",
 | 
			
		||||
      "license": "MIT",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "@types/retry": "0.12.0",
 | 
			
		||||
        "retry": "^0.13.1"
 | 
			
		||||
| 
						 | 
				
			
			@ -10052,6 +10076,7 @@
 | 
			
		|||
      "version": "0.13.1",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
 | 
			
		||||
      "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==",
 | 
			
		||||
      "license": "MIT",
 | 
			
		||||
      "engines": {
 | 
			
		||||
        "node": ">= 4"
 | 
			
		||||
      }
 | 
			
		||||
| 
						 | 
				
			
			@ -10265,6 +10290,7 @@
 | 
			
		|||
      "version": "2.15.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz",
 | 
			
		||||
      "integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==",
 | 
			
		||||
      "license": "MIT",
 | 
			
		||||
      "bin": {
 | 
			
		||||
        "sdp-verify": "checker.js"
 | 
			
		||||
      }
 | 
			
		||||
| 
						 | 
				
			
			@ -11173,7 +11199,8 @@
 | 
			
		|||
    "node_modules/unhomoglyph": {
 | 
			
		||||
      "version": "1.0.6",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/unhomoglyph/-/unhomoglyph-1.0.6.tgz",
 | 
			
		||||
      "integrity": "sha512-7uvcWI3hWshSADBu4JpnyYbTVc7YlhF5GDW/oPD5AxIxl34k4wXR3WDkPnzLxkN32LiTCTKMQLtKVZiwki3zGg=="
 | 
			
		||||
      "integrity": "sha512-7uvcWI3hWshSADBu4JpnyYbTVc7YlhF5GDW/oPD5AxIxl34k4wXR3WDkPnzLxkN32LiTCTKMQLtKVZiwki3zGg==",
 | 
			
		||||
      "license": "MIT"
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/unicode-canonical-property-names-ecmascript": {
 | 
			
		||||
      "version": "2.0.1",
 | 
			
		||||
| 
						 | 
				
			
			@ -11304,9 +11331,9 @@
 | 
			
		|||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/vite": {
 | 
			
		||||
      "version": "5.4.15",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.15.tgz",
 | 
			
		||||
      "integrity": "sha512-6ANcZRivqL/4WtwPGTKNaosuNJr5tWiftOC7liM7G9+rMb8+oeJeyzymDu4rTN93seySBmbjSfsS3Vzr19KNtA==",
 | 
			
		||||
      "version": "5.4.19",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz",
 | 
			
		||||
      "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==",
 | 
			
		||||
      "license": "MIT",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "esbuild": "^0.21.3",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										10
									
								
								package.json
									
										
									
									
									
								
							
							
						
						
									
										10
									
								
								package.json
									
										
									
									
									
								
							| 
						 | 
				
			
			@ -1,6 +1,6 @@
 | 
			
		|||
{
 | 
			
		||||
  "name": "cinny",
 | 
			
		||||
  "version": "4.5.1",
 | 
			
		||||
  "version": "4.8.1",
 | 
			
		||||
  "description": "Yet another matrix client",
 | 
			
		||||
  "main": "index.js",
 | 
			
		||||
  "type": "module",
 | 
			
		||||
| 
						 | 
				
			
			@ -24,7 +24,6 @@
 | 
			
		|||
    "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
 | 
			
		||||
    "@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
 | 
			
		||||
    "@fontsource/inter": "4.5.14",
 | 
			
		||||
    "@matrix-org/olm": "3.2.15",
 | 
			
		||||
    "@tanstack/react-query": "5.24.1",
 | 
			
		||||
    "@tanstack/react-query-devtools": "5.24.1",
 | 
			
		||||
    "@tanstack/react-virtual": "3.2.0",
 | 
			
		||||
| 
						 | 
				
			
			@ -33,6 +32,7 @@
 | 
			
		|||
    "@vanilla-extract/recipes": "0.3.0",
 | 
			
		||||
    "@vanilla-extract/vite-plugin": "3.7.1",
 | 
			
		||||
    "await-to-js": "3.0.0",
 | 
			
		||||
    "badwords-list": "2.0.1-4",
 | 
			
		||||
    "blurhash": "2.0.4",
 | 
			
		||||
    "browser-encrypt-attachment": "0.3.0",
 | 
			
		||||
    "chroma-js": "3.1.2",
 | 
			
		||||
| 
						 | 
				
			
			@ -45,7 +45,7 @@
 | 
			
		|||
    "file-saver": "2.0.5",
 | 
			
		||||
    "flux": "4.0.3",
 | 
			
		||||
    "focus-trap-react": "10.0.2",
 | 
			
		||||
    "folds": "2.1.0",
 | 
			
		||||
    "folds": "2.2.0",
 | 
			
		||||
    "formik": "2.4.6",
 | 
			
		||||
    "html-dom-parser": "4.0.0",
 | 
			
		||||
    "html-react-parser": "4.2.0",
 | 
			
		||||
| 
						 | 
				
			
			@ -57,7 +57,7 @@
 | 
			
		|||
    "jotai": "2.6.0",
 | 
			
		||||
    "linkify-react": "4.1.3",
 | 
			
		||||
    "linkifyjs": "4.1.3",
 | 
			
		||||
    "matrix-js-sdk": "35.0.0",
 | 
			
		||||
    "matrix-js-sdk": "37.5.0",
 | 
			
		||||
    "millify": "6.1.0",
 | 
			
		||||
    "pdfjs-dist": "4.2.67",
 | 
			
		||||
    "prismjs": "1.30.0",
 | 
			
		||||
| 
						 | 
				
			
			@ -110,7 +110,7 @@
 | 
			
		|||
    "prettier": "2.8.1",
 | 
			
		||||
    "sass": "1.56.2",
 | 
			
		||||
    "typescript": "4.9.4",
 | 
			
		||||
    "vite": "5.4.15",
 | 
			
		||||
    "vite": "5.4.19",
 | 
			
		||||
    "vite-plugin-pwa": "0.20.5",
 | 
			
		||||
    "vite-plugin-static-copy": "1.0.4",
 | 
			
		||||
    "vite-plugin-top-level-await": "1.4.4"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -4,10 +4,25 @@ import PropTypes from 'prop-types';
 | 
			
		|||
import dateFormat from 'dateformat';
 | 
			
		||||
import { isInSameDay } from '../../../util/common';
 | 
			
		||||
 | 
			
		||||
function Time({ timestamp, fullTime }) {
 | 
			
		||||
/**
 | 
			
		||||
 * Renders a formatted timestamp.
 | 
			
		||||
 *
 | 
			
		||||
 * Displays the time in hour:minute format if the message is from today or yesterday, unless `fullTime` is true.
 | 
			
		||||
 * For older messages, it shows the date and time.
 | 
			
		||||
 *
 | 
			
		||||
 * @param {number} timestamp - The timestamp to display.
 | 
			
		||||
 * @param {boolean} [fullTime=false] - If true, always show the full date and time.
 | 
			
		||||
 * @param {boolean} hour24Clock - Whether to use 24-hour time format.
 | 
			
		||||
 * @param {string} dateFormatString - Format string for the date part.
 | 
			
		||||
 * @returns {JSX.Element} A <time> element with the formatted date/time.
 | 
			
		||||
 */
 | 
			
		||||
function Time({ timestamp, fullTime, hour24Clock, dateFormatString }) {
 | 
			
		||||
  const date = new Date(timestamp);
 | 
			
		||||
 | 
			
		||||
  const formattedFullTime = dateFormat(date, 'dd mmmm yyyy, hh:MM TT');
 | 
			
		||||
  const formattedFullTime = dateFormat(
 | 
			
		||||
    date,
 | 
			
		||||
    hour24Clock ? 'dd mmmm yyyy, HH:MM' : 'dd mmmm yyyy, hh:MM TT'
 | 
			
		||||
  );
 | 
			
		||||
  let formattedDate = formattedFullTime;
 | 
			
		||||
 | 
			
		||||
  if (!fullTime) {
 | 
			
		||||
| 
						 | 
				
			
			@ -16,17 +31,19 @@ function Time({ timestamp, fullTime }) {
 | 
			
		|||
    compareDate.setDate(compareDate.getDate() - 1);
 | 
			
		||||
    const isYesterday = isInSameDay(date, compareDate);
 | 
			
		||||
 | 
			
		||||
    formattedDate = dateFormat(date, isToday || isYesterday ? 'hh:MM TT' : 'dd/mm/yyyy');
 | 
			
		||||
    const timeFormat = hour24Clock ? 'HH:MM' : 'hh:MM TT';
 | 
			
		||||
 | 
			
		||||
    formattedDate = dateFormat(
 | 
			
		||||
      date,
 | 
			
		||||
      isToday || isYesterday ? timeFormat : dateFormatString.toLowerCase()
 | 
			
		||||
    );
 | 
			
		||||
    if (isYesterday) {
 | 
			
		||||
      formattedDate = `Yesterday, ${formattedDate}`;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <time
 | 
			
		||||
      dateTime={date.toISOString()}
 | 
			
		||||
      title={formattedFullTime}
 | 
			
		||||
    >
 | 
			
		||||
    <time dateTime={date.toISOString()} title={formattedFullTime}>
 | 
			
		||||
      {formattedDate}
 | 
			
		||||
    </time>
 | 
			
		||||
  );
 | 
			
		||||
| 
						 | 
				
			
			@ -39,6 +56,8 @@ Time.defaultProps = {
 | 
			
		|||
Time.propTypes = {
 | 
			
		||||
  timestamp: PropTypes.number.isRequired,
 | 
			
		||||
  fullTime: PropTypes.bool,
 | 
			
		||||
  hour24Clock: PropTypes.bool.isRequired,
 | 
			
		||||
  dateFormatString: PropTypes.string.isRequired,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default Time;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -203,12 +203,8 @@ export function BackupRestoreTile({ crypto }: BackupRestoreTileProps) {
 | 
			
		|||
                  escapeDeactivates: stopPropagation,
 | 
			
		||||
                }}
 | 
			
		||||
              >
 | 
			
		||||
                <Menu
 | 
			
		||||
                  style={{
 | 
			
		||||
                    padding: config.space.S100,
 | 
			
		||||
                  }}
 | 
			
		||||
                >
 | 
			
		||||
                  <Box direction="Column" gap="100">
 | 
			
		||||
                <Menu style={{ padding: config.space.S200 }}>
 | 
			
		||||
                  <Box direction="Column" gap="200">
 | 
			
		||||
                    <Box direction="Column" gap="200">
 | 
			
		||||
                      <InfoCard
 | 
			
		||||
                        variant="SurfaceVariant"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,36 +0,0 @@
 | 
			
		|||
import { ReactNode, useCallback, useEffect } from 'react';
 | 
			
		||||
import { Capabilities } from 'matrix-js-sdk';
 | 
			
		||||
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
 | 
			
		||||
import { useMatrixClient } from '../hooks/useMatrixClient';
 | 
			
		||||
import { MediaConfig } from '../hooks/useMediaConfig';
 | 
			
		||||
import { promiseFulfilledResult } from '../utils/common';
 | 
			
		||||
 | 
			
		||||
type CapabilitiesAndMediaConfigLoaderProps = {
 | 
			
		||||
  children: (capabilities?: Capabilities, mediaConfig?: MediaConfig) => ReactNode;
 | 
			
		||||
};
 | 
			
		||||
export function CapabilitiesAndMediaConfigLoader({
 | 
			
		||||
  children,
 | 
			
		||||
}: CapabilitiesAndMediaConfigLoaderProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
 | 
			
		||||
  const [state, load] = useAsyncCallback<
 | 
			
		||||
    [Capabilities | undefined, MediaConfig | undefined],
 | 
			
		||||
    unknown,
 | 
			
		||||
    []
 | 
			
		||||
  >(
 | 
			
		||||
    useCallback(async () => {
 | 
			
		||||
      const result = await Promise.allSettled([mx.getCapabilities(), mx.getMediaConfig()]);
 | 
			
		||||
      const capabilities = promiseFulfilledResult(result[0]);
 | 
			
		||||
      const mediaConfig = promiseFulfilledResult(result[1]);
 | 
			
		||||
      return [capabilities, mediaConfig];
 | 
			
		||||
    }, [mx])
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    load();
 | 
			
		||||
  }, [load]);
 | 
			
		||||
 | 
			
		||||
  const [capabilities, mediaConfig] =
 | 
			
		||||
    state.status === AsyncStatus.Success ? state.data : [undefined, undefined];
 | 
			
		||||
  return children(capabilities, mediaConfig);
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -30,7 +30,7 @@ export function HexColorPickerPopOut({ picker, onRemove, children }: HexColorPic
 | 
			
		|||
        >
 | 
			
		||||
          <Menu
 | 
			
		||||
            style={{
 | 
			
		||||
              padding: config.space.S100,
 | 
			
		||||
              padding: config.space.S200,
 | 
			
		||||
              borderRadius: config.radii.R500,
 | 
			
		||||
              overflow: 'initial',
 | 
			
		||||
            }}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -17,12 +17,16 @@ import { JoinRule } from 'matrix-js-sdk';
 | 
			
		|||
import FocusTrap from 'focus-trap-react';
 | 
			
		||||
import { stopPropagation } from '../utils/keyboard';
 | 
			
		||||
 | 
			
		||||
type JoinRuleIcons = Record<JoinRule, IconSrc>;
 | 
			
		||||
export type ExtraJoinRules = 'knock_restricted';
 | 
			
		||||
export type ExtendedJoinRules = JoinRule | ExtraJoinRules;
 | 
			
		||||
 | 
			
		||||
type JoinRuleIcons = Record<ExtendedJoinRules, IconSrc>;
 | 
			
		||||
export const useRoomJoinRuleIcon = (): JoinRuleIcons =>
 | 
			
		||||
  useMemo(
 | 
			
		||||
    () => ({
 | 
			
		||||
      [JoinRule.Invite]: Icons.HashLock,
 | 
			
		||||
      [JoinRule.Knock]: Icons.HashLock,
 | 
			
		||||
      knock_restricted: Icons.Hash,
 | 
			
		||||
      [JoinRule.Restricted]: Icons.Hash,
 | 
			
		||||
      [JoinRule.Public]: Icons.HashGlobe,
 | 
			
		||||
      [JoinRule.Private]: Icons.HashLock,
 | 
			
		||||
| 
						 | 
				
			
			@ -34,6 +38,7 @@ export const useSpaceJoinRuleIcon = (): JoinRuleIcons =>
 | 
			
		|||
    () => ({
 | 
			
		||||
      [JoinRule.Invite]: Icons.SpaceLock,
 | 
			
		||||
      [JoinRule.Knock]: Icons.SpaceLock,
 | 
			
		||||
      knock_restricted: Icons.Space,
 | 
			
		||||
      [JoinRule.Restricted]: Icons.Space,
 | 
			
		||||
      [JoinRule.Public]: Icons.SpaceGlobe,
 | 
			
		||||
      [JoinRule.Private]: Icons.SpaceLock,
 | 
			
		||||
| 
						 | 
				
			
			@ -41,12 +46,13 @@ export const useSpaceJoinRuleIcon = (): JoinRuleIcons =>
 | 
			
		|||
    []
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
type JoinRuleLabels = Record<JoinRule, string>;
 | 
			
		||||
type JoinRuleLabels = Record<ExtendedJoinRules, string>;
 | 
			
		||||
export const useRoomJoinRuleLabel = (): JoinRuleLabels =>
 | 
			
		||||
  useMemo(
 | 
			
		||||
    () => ({
 | 
			
		||||
      [JoinRule.Invite]: 'Invite Only',
 | 
			
		||||
      [JoinRule.Knock]: 'Knock & Invite',
 | 
			
		||||
      knock_restricted: 'Space Members or Knock',
 | 
			
		||||
      [JoinRule.Restricted]: 'Space Members',
 | 
			
		||||
      [JoinRule.Public]: 'Public',
 | 
			
		||||
      [JoinRule.Private]: 'Invite Only',
 | 
			
		||||
| 
						 | 
				
			
			@ -54,7 +60,7 @@ export const useRoomJoinRuleLabel = (): JoinRuleLabels =>
 | 
			
		|||
    []
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
type JoinRulesSwitcherProps<T extends JoinRule[]> = {
 | 
			
		||||
type JoinRulesSwitcherProps<T extends ExtendedJoinRules[]> = {
 | 
			
		||||
  icons: JoinRuleIcons;
 | 
			
		||||
  labels: JoinRuleLabels;
 | 
			
		||||
  rules: T;
 | 
			
		||||
| 
						 | 
				
			
			@ -63,7 +69,7 @@ type JoinRulesSwitcherProps<T extends JoinRule[]> = {
 | 
			
		|||
  disabled?: boolean;
 | 
			
		||||
  changing?: boolean;
 | 
			
		||||
};
 | 
			
		||||
export function JoinRulesSwitcher<T extends JoinRule[]>({
 | 
			
		||||
export function JoinRulesSwitcher<T extends ExtendedJoinRules[]>({
 | 
			
		||||
  icons,
 | 
			
		||||
  labels,
 | 
			
		||||
  rules,
 | 
			
		||||
| 
						 | 
				
			
			@ -79,7 +85,7 @@ export function JoinRulesSwitcher<T extends JoinRule[]>({
 | 
			
		|||
  };
 | 
			
		||||
 | 
			
		||||
  const handleChange = useCallback(
 | 
			
		||||
    (selectedRule: JoinRule) => {
 | 
			
		||||
    (selectedRule: ExtendedJoinRules) => {
 | 
			
		||||
      setCords(undefined);
 | 
			
		||||
      onChange(selectedRule);
 | 
			
		||||
    },
 | 
			
		||||
| 
						 | 
				
			
			@ -103,7 +109,7 @@ export function JoinRulesSwitcher<T extends JoinRule[]>({
 | 
			
		|||
          }}
 | 
			
		||||
        >
 | 
			
		||||
          <Menu>
 | 
			
		||||
            <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
 | 
			
		||||
            <Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
 | 
			
		||||
              {rules.map((rule) => (
 | 
			
		||||
                <MenuItem
 | 
			
		||||
                  key={rule}
 | 
			
		||||
| 
						 | 
				
			
			@ -131,7 +137,7 @@ export function JoinRulesSwitcher<T extends JoinRule[]>({
 | 
			
		|||
        fill="Soft"
 | 
			
		||||
        radii="300"
 | 
			
		||||
        outlined
 | 
			
		||||
        before={<Icon size="100" src={icons[value]} />}
 | 
			
		||||
        before={<Icon size="100" src={icons[value] ?? icons[JoinRule.Restricted]} />}
 | 
			
		||||
        after={
 | 
			
		||||
          changing ? (
 | 
			
		||||
            <Spinner size="100" variant="Secondary" fill="Soft" />
 | 
			
		||||
| 
						 | 
				
			
			@ -142,7 +148,7 @@ export function JoinRulesSwitcher<T extends JoinRule[]>({
 | 
			
		|||
        onClick={handleOpenMenu}
 | 
			
		||||
        disabled={disabled}
 | 
			
		||||
      >
 | 
			
		||||
        <Text size="B300">{labels[value]}</Text>
 | 
			
		||||
        <Text size="B300">{labels[value] ?? 'Unsupported'}</Text>
 | 
			
		||||
      </Button>
 | 
			
		||||
    </PopOut>
 | 
			
		||||
  );
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -78,7 +78,7 @@ export function ManualVerificationMethodSwitcher({
 | 
			
		|||
            }}
 | 
			
		||||
          >
 | 
			
		||||
            <Menu>
 | 
			
		||||
              <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
 | 
			
		||||
              <Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
 | 
			
		||||
                <MenuItem
 | 
			
		||||
                  size="300"
 | 
			
		||||
                  variant="Surface"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -23,7 +23,7 @@ export function MemberSortMenu({ selected, onSelect, requestClose }: MemberSortM
 | 
			
		|||
        escapeDeactivates: stopPropagation,
 | 
			
		||||
      }}
 | 
			
		||||
    >
 | 
			
		||||
      <Menu style={{ padding: config.space.S100 }}>
 | 
			
		||||
      <Menu style={{ padding: config.space.S200 }}>
 | 
			
		||||
        {memberSortMenu.map((menuItem, index) => (
 | 
			
		||||
          <MenuItem
 | 
			
		||||
            key={menuItem.name}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -27,7 +27,7 @@ export function MembershipFilterMenu({
 | 
			
		|||
        escapeDeactivates: stopPropagation,
 | 
			
		||||
      }}
 | 
			
		||||
    >
 | 
			
		||||
      <Menu style={{ padding: config.space.S100 }}>
 | 
			
		||||
      <Menu style={{ padding: config.space.S200 }}>
 | 
			
		||||
        {membershipFilterMenu.map((menuItem, index) => (
 | 
			
		||||
          <MenuItem
 | 
			
		||||
            key={menuItem.name}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -70,7 +70,7 @@ export function RoomNotificationModeSwitcher({
 | 
			
		|||
  return (
 | 
			
		||||
    <PopOut
 | 
			
		||||
      anchor={menuCords}
 | 
			
		||||
      offset={5}
 | 
			
		||||
      offset={8}
 | 
			
		||||
      position="Right"
 | 
			
		||||
      align="Start"
 | 
			
		||||
      content={
 | 
			
		||||
| 
						 | 
				
			
			@ -86,7 +86,7 @@ export function RoomNotificationModeSwitcher({
 | 
			
		|||
          }}
 | 
			
		||||
        >
 | 
			
		||||
          <Menu>
 | 
			
		||||
            <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
 | 
			
		||||
            <Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
 | 
			
		||||
              {modes.map((mode) => (
 | 
			
		||||
                <MenuItem
 | 
			
		||||
                  key={mode}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,7 +1,6 @@
 | 
			
		|||
import React, { FormEventHandler, useCallback } from 'react';
 | 
			
		||||
import { Box, Text, Button, Spinner, color } from 'folds';
 | 
			
		||||
import { decodeRecoveryKey } from 'matrix-js-sdk/lib/crypto-api';
 | 
			
		||||
import { deriveKey } from 'matrix-js-sdk/lib/crypto/key_passphrase';
 | 
			
		||||
import { decodeRecoveryKey, deriveRecoveryKeyFromPassphrase } from 'matrix-js-sdk/lib/crypto-api';
 | 
			
		||||
import { PasswordInput } from './password-input';
 | 
			
		||||
import {
 | 
			
		||||
  SecretStorageKeyContent,
 | 
			
		||||
| 
						 | 
				
			
			@ -29,11 +28,16 @@ export function SecretStorageRecoveryPassphrase({
 | 
			
		|||
  const [driveKeyState, submitPassphrase] = useAsyncCallback<
 | 
			
		||||
    Uint8Array,
 | 
			
		||||
    Error,
 | 
			
		||||
    Parameters<typeof deriveKey>
 | 
			
		||||
    Parameters<typeof deriveRecoveryKeyFromPassphrase>
 | 
			
		||||
  >(
 | 
			
		||||
    useCallback(
 | 
			
		||||
      async (passphrase, salt, iterations, bits) => {
 | 
			
		||||
        const decodedRecoveryKey = await deriveKey(passphrase, salt, iterations, bits);
 | 
			
		||||
        const decodedRecoveryKey = await deriveRecoveryKeyFromPassphrase(
 | 
			
		||||
          passphrase,
 | 
			
		||||
          salt,
 | 
			
		||||
          iterations,
 | 
			
		||||
          bits
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        const match = await mx.secretStorage.checkKey(decodedRecoveryKey, keyContent as any);
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										52
									
								
								src/app/components/ServerConfigsLoader.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								src/app/components/ServerConfigsLoader.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,52 @@
 | 
			
		|||
import { ReactNode, useCallback, useMemo } from 'react';
 | 
			
		||||
import { Capabilities, validateAuthMetadata, ValidatedAuthMetadata } from 'matrix-js-sdk';
 | 
			
		||||
import { AsyncStatus, useAsyncCallbackValue } from '../hooks/useAsyncCallback';
 | 
			
		||||
import { useMatrixClient } from '../hooks/useMatrixClient';
 | 
			
		||||
import { MediaConfig } from '../hooks/useMediaConfig';
 | 
			
		||||
import { promiseFulfilledResult } from '../utils/common';
 | 
			
		||||
 | 
			
		||||
export type ServerConfigs = {
 | 
			
		||||
  capabilities?: Capabilities;
 | 
			
		||||
  mediaConfig?: MediaConfig;
 | 
			
		||||
  authMetadata?: ValidatedAuthMetadata;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
type ServerConfigsLoaderProps = {
 | 
			
		||||
  children: (configs: ServerConfigs) => ReactNode;
 | 
			
		||||
};
 | 
			
		||||
export function ServerConfigsLoader({ children }: ServerConfigsLoaderProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const fallbackConfigs = useMemo(() => ({}), []);
 | 
			
		||||
 | 
			
		||||
  const [configsState] = useAsyncCallbackValue<ServerConfigs, unknown>(
 | 
			
		||||
    useCallback(async () => {
 | 
			
		||||
      const result = await Promise.allSettled([
 | 
			
		||||
        mx.getCapabilities(),
 | 
			
		||||
        mx.getMediaConfig(),
 | 
			
		||||
        mx.getAuthMetadata(),
 | 
			
		||||
      ]);
 | 
			
		||||
 | 
			
		||||
      const capabilities = promiseFulfilledResult(result[0]);
 | 
			
		||||
      const mediaConfig = promiseFulfilledResult(result[1]);
 | 
			
		||||
      const authMetadata = promiseFulfilledResult(result[2]);
 | 
			
		||||
      let validatedAuthMetadata: ValidatedAuthMetadata | undefined;
 | 
			
		||||
 | 
			
		||||
      try {
 | 
			
		||||
        validatedAuthMetadata = validateAuthMetadata(authMetadata);
 | 
			
		||||
      } catch (e) {
 | 
			
		||||
        console.error(e);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return {
 | 
			
		||||
        capabilities,
 | 
			
		||||
        mediaConfig,
 | 
			
		||||
        authMetadata: validatedAuthMetadata,
 | 
			
		||||
      };
 | 
			
		||||
    }, [mx])
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const configs: ServerConfigs =
 | 
			
		||||
    configsState.status === AsyncStatus.Success ? configsState.data : fallbackConfigs;
 | 
			
		||||
 | 
			
		||||
  return children(configs);
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -157,7 +157,7 @@ export function RenderElement({ attributes, element, children }: RenderElementPr
 | 
			
		|||
        <Text as="pre" className={css.CodeBlock} {...attributes}>
 | 
			
		||||
          <Scroll
 | 
			
		||||
            direction="Horizontal"
 | 
			
		||||
            variant="Secondary"
 | 
			
		||||
            variant="SurfaceVariant"
 | 
			
		||||
            size="300"
 | 
			
		||||
            visibility="Hover"
 | 
			
		||||
            hideTrack
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -155,7 +155,7 @@ export function HeadingBlockButton() {
 | 
			
		|||
            escapeDeactivates: stopPropagation,
 | 
			
		||||
          }}
 | 
			
		||||
        >
 | 
			
		||||
          <Menu style={{ padding: config.space.S100 }}>
 | 
			
		||||
          <Menu style={{ padding: config.space.S200 }}>
 | 
			
		||||
            <Box gap="100">
 | 
			
		||||
              <TooltipProvider
 | 
			
		||||
                tooltip={<BtnTooltip text="Heading 1" shortCode={`${modKey} + 1`} />}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -9,7 +9,7 @@ import { getDirectRoomAvatarUrl } from '../../../utils/room';
 | 
			
		|||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
			
		||||
import { AutocompleteQuery } from './autocompleteQuery';
 | 
			
		||||
import { AutocompleteMenu } from './AutocompleteMenu';
 | 
			
		||||
import { getMxIdServer, validMxId } from '../../../utils/matrix';
 | 
			
		||||
import { getMxIdServer, isRoomAlias } from '../../../utils/matrix';
 | 
			
		||||
import { UseAsyncSearchOptions, useAsyncSearch } from '../../../hooks/useAsyncSearch';
 | 
			
		||||
import { onTabPress } from '../../../utils/keyboard';
 | 
			
		||||
import { useKeyDown } from '../../../hooks/useKeyDown';
 | 
			
		||||
| 
						 | 
				
			
			@ -22,7 +22,7 @@ import { getViaServers } from '../../../plugins/via-servers';
 | 
			
		|||
type MentionAutoCompleteHandler = (roomAliasOrId: string, name: string) => void;
 | 
			
		||||
 | 
			
		||||
const roomAliasFromQueryText = (mx: MatrixClient, text: string) =>
 | 
			
		||||
  validMxId(`#${text}`)
 | 
			
		||||
  isRoomAlias(`#${text}`)
 | 
			
		||||
    ? `#${text}`
 | 
			
		||||
    : `#${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -15,7 +15,7 @@ import {
 | 
			
		|||
import { onTabPress } from '../../../utils/keyboard';
 | 
			
		||||
import { createMentionElement, moveCursor, replaceWithElement } from '../utils';
 | 
			
		||||
import { useKeyDown } from '../../../hooks/useKeyDown';
 | 
			
		||||
import { getMxIdLocalPart, getMxIdServer, validMxId } from '../../../utils/matrix';
 | 
			
		||||
import { getMxIdLocalPart, getMxIdServer, isUserId } from '../../../utils/matrix';
 | 
			
		||||
import { getMemberDisplayName, getMemberSearchStr } from '../../../utils/room';
 | 
			
		||||
import { UserAvatar } from '../../user-avatar';
 | 
			
		||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
 | 
			
		||||
| 
						 | 
				
			
			@ -24,7 +24,7 @@ import { Membership } from '../../../../types/matrix/room';
 | 
			
		|||
type MentionAutoCompleteHandler = (userId: string, name: string) => void;
 | 
			
		||||
 | 
			
		||||
const userIdFromQueryText = (mx: MatrixClient, text: string) =>
 | 
			
		||||
  validMxId(`@${text}`)
 | 
			
		||||
  isUserId(`@${text}`)
 | 
			
		||||
    ? `@${text}`
 | 
			
		||||
    : `@${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -33,7 +33,7 @@ export function UsageSelector({ selected, onChange }: UsageSelectorProps) {
 | 
			
		|||
  );
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
 | 
			
		||||
    <Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
 | 
			
		||||
      {allUsages.map((usage) => (
 | 
			
		||||
        <MenuItem
 | 
			
		||||
          key={getUsageStr(usage)}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -7,7 +7,6 @@ export const ReplyBend = style({
 | 
			
		|||
 | 
			
		||||
export const ThreadIndicator = style({
 | 
			
		||||
  opacity: config.opacity.P300,
 | 
			
		||||
  gap: toRem(2),
 | 
			
		||||
 | 
			
		||||
  selectors: {
 | 
			
		||||
    'button&': {
 | 
			
		||||
| 
						 | 
				
			
			@ -19,11 +18,6 @@ export const ThreadIndicator = style({
 | 
			
		|||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const ThreadIndicatorIcon = style({
 | 
			
		||||
  width: toRem(14),
 | 
			
		||||
  height: toRem(14),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const Reply = style({
 | 
			
		||||
  marginBottom: toRem(1),
 | 
			
		||||
  minWidth: 0,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -38,9 +38,16 @@ export const ReplyLayout = as<'div', ReplyLayoutProps>(
 | 
			
		|||
);
 | 
			
		||||
 | 
			
		||||
export const ThreadIndicator = as<'div'>(({ ...props }, ref) => (
 | 
			
		||||
  <Box className={css.ThreadIndicator} alignItems="Center" {...props} ref={ref}>
 | 
			
		||||
    <Icon className={css.ThreadIndicatorIcon} src={Icons.Message} />
 | 
			
		||||
    <Text size="T200">Threaded reply</Text>
 | 
			
		||||
  <Box
 | 
			
		||||
    shrink="No"
 | 
			
		||||
    className={css.ThreadIndicator}
 | 
			
		||||
    alignItems="Center"
 | 
			
		||||
    gap="100"
 | 
			
		||||
    {...props}
 | 
			
		||||
    ref={ref}
 | 
			
		||||
  >
 | 
			
		||||
    <Icon size="50" src={Icons.Thread} />
 | 
			
		||||
    <Text size="L400">Thread</Text>
 | 
			
		||||
  </Box>
 | 
			
		||||
));
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -97,7 +104,7 @@ export const Reply = as<'div', ReplyProps>(
 | 
			
		|||
    const bodyJSX = body ? scaleSystemEmoji(trimReplyFromBody(body)) : fallbackBody;
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <Box direction="Column" alignItems="Start" {...props} ref={ref}>
 | 
			
		||||
      <Box direction="Row" gap="200" alignItems="Center" {...props} ref={ref}>
 | 
			
		||||
        {threadRootId && (
 | 
			
		||||
          <ThreadIndicator as="button" data-event-id={threadRootId} onClick={onClick} />
 | 
			
		||||
        )}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -5,19 +5,35 @@ import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../utils/ti
 | 
			
		|||
export type TimeProps = {
 | 
			
		||||
  compact?: boolean;
 | 
			
		||||
  ts: number;
 | 
			
		||||
  hour24Clock: boolean;
 | 
			
		||||
  dateFormatString: string;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Renders a formatted timestamp, supporting compact and full display modes.
 | 
			
		||||
 *
 | 
			
		||||
 * Displays the time in hour:minute format if the message is from today, yesterday, or if `compact` is true.
 | 
			
		||||
 * For older messages, it shows the date and time.
 | 
			
		||||
 *
 | 
			
		||||
 * @param {number} ts - The timestamp to display.
 | 
			
		||||
 * @param {boolean} [compact=false] - If true, always show only the time.
 | 
			
		||||
 * @param {boolean} hour24Clock - Whether to use 24-hour time format.
 | 
			
		||||
 * @param {string} dateFormatString - Format string for the date part.
 | 
			
		||||
 * @returns {React.ReactElement} A <Text as="time"> element with the formatted date/time.
 | 
			
		||||
 */
 | 
			
		||||
export const Time = as<'span', TimeProps & ComponentProps<typeof Text>>(
 | 
			
		||||
  ({ compact, ts, ...props }, ref) => {
 | 
			
		||||
  ({ compact, hour24Clock, dateFormatString, ts, ...props }, ref) => {
 | 
			
		||||
    const formattedTime = timeHourMinute(ts, hour24Clock);
 | 
			
		||||
 | 
			
		||||
    let time = '';
 | 
			
		||||
    if (compact) {
 | 
			
		||||
      time = timeHourMinute(ts);
 | 
			
		||||
      time = formattedTime;
 | 
			
		||||
    } else if (today(ts)) {
 | 
			
		||||
      time = timeHourMinute(ts);
 | 
			
		||||
      time = formattedTime;
 | 
			
		||||
    } else if (yesterday(ts)) {
 | 
			
		||||
      time = `Yesterday ${timeHourMinute(ts)}`;
 | 
			
		||||
      time = `Yesterday ${formattedTime}`;
 | 
			
		||||
    } else {
 | 
			
		||||
      time = `${timeDayMonYear(ts)} ${timeHourMinute(ts)}`;
 | 
			
		||||
      time = `${timeDayMonYear(ts, dateFormatString)} ${formattedTime}`;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -30,6 +30,7 @@ import { stopPropagation } from '../../../utils/keyboard';
 | 
			
		|||
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../utils/matrix';
 | 
			
		||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
 | 
			
		||||
import { ModalWide } from '../../../styles/Modal.css';
 | 
			
		||||
import { validBlurHash } from '../../../utils/blurHash';
 | 
			
		||||
 | 
			
		||||
type RenderViewerProps = {
 | 
			
		||||
  src: string;
 | 
			
		||||
| 
						 | 
				
			
			@ -77,7 +78,7 @@ export const ImageContent = as<'div', ImageContentProps>(
 | 
			
		|||
  ) => {
 | 
			
		||||
    const mx = useMatrixClient();
 | 
			
		||||
    const useAuthentication = useMediaAuthentication();
 | 
			
		||||
    const blurHash = info?.[MATRIX_BLUR_HASH_PROPERTY_NAME];
 | 
			
		||||
    const blurHash = validBlurHash(info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]);
 | 
			
		||||
 | 
			
		||||
    const [load, setLoad] = useState(false);
 | 
			
		||||
    const [error, setError] = useState(false);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -31,6 +31,7 @@ import {
 | 
			
		|||
  mxcUrlToHttp,
 | 
			
		||||
} from '../../../utils/matrix';
 | 
			
		||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
 | 
			
		||||
import { validBlurHash } from '../../../utils/blurHash';
 | 
			
		||||
 | 
			
		||||
type RenderVideoProps = {
 | 
			
		||||
  title: string;
 | 
			
		||||
| 
						 | 
				
			
			@ -68,7 +69,7 @@ export const VideoContent = as<'div', VideoContentProps>(
 | 
			
		|||
  ) => {
 | 
			
		||||
    const mx = useMatrixClient();
 | 
			
		||||
    const useAuthentication = useMediaAuthentication();
 | 
			
		||||
    const blurHash = info.thumbnail_info?.[MATRIX_BLUR_HASH_PROPERTY_NAME];
 | 
			
		||||
    const blurHash = validBlurHash(info.thumbnail_info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]);
 | 
			
		||||
 | 
			
		||||
    const [load, setLoad] = useState(false);
 | 
			
		||||
    const [error, setError] = useState(false);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -16,6 +16,7 @@ export const AbsoluteContainer = style([
 | 
			
		|||
    position: 'absolute',
 | 
			
		||||
    top: 0,
 | 
			
		||||
    left: 0,
 | 
			
		||||
    zIndex: 1,
 | 
			
		||||
    width: '100%',
 | 
			
		||||
    height: '100%',
 | 
			
		||||
  },
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -105,6 +105,20 @@ export const PageContent = as<'div'>(({ className, ...props }, ref) => (
 | 
			
		|||
  <div className={classNames(css.PageContent, className)} {...props} ref={ref} />
 | 
			
		||||
));
 | 
			
		||||
 | 
			
		||||
export function PageHeroEmpty({ children }: { children: ReactNode }) {
 | 
			
		||||
  return (
 | 
			
		||||
    <Box
 | 
			
		||||
      className={classNames(ContainerColor({ variant: 'SurfaceVariant' }), css.PageHeroEmpty)}
 | 
			
		||||
      direction="Column"
 | 
			
		||||
      alignItems="Center"
 | 
			
		||||
      justifyContent="Center"
 | 
			
		||||
      gap="200"
 | 
			
		||||
    >
 | 
			
		||||
      {children}
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const PageHeroSection = as<'div', ComponentProps<typeof Box>>(
 | 
			
		||||
  ({ className, ...props }, ref) => (
 | 
			
		||||
    <Box
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -92,6 +92,15 @@ export const PageContent = style([
 | 
			
		|||
  },
 | 
			
		||||
]);
 | 
			
		||||
 | 
			
		||||
export const PageHeroEmpty = style([
 | 
			
		||||
  DefaultReset,
 | 
			
		||||
  {
 | 
			
		||||
    padding: config.space.S400,
 | 
			
		||||
    borderRadius: config.radii.R400,
 | 
			
		||||
    minHeight: toRem(450),
 | 
			
		||||
  },
 | 
			
		||||
]);
 | 
			
		||||
 | 
			
		||||
export const PageHeroSection = style([
 | 
			
		||||
  DefaultReset,
 | 
			
		||||
  {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -16,13 +16,14 @@ export const PowerSelector = forwardRef<HTMLDivElement, PowerSelectorProps>(
 | 
			
		|||
      ref={ref}
 | 
			
		||||
      style={{
 | 
			
		||||
        maxHeight: '75vh',
 | 
			
		||||
        maxWidth: toRem(300),
 | 
			
		||||
        maxWidth: toRem(200),
 | 
			
		||||
        width: '100vw',
 | 
			
		||||
        display: 'flex',
 | 
			
		||||
      }}
 | 
			
		||||
    >
 | 
			
		||||
      <Box grow="Yes">
 | 
			
		||||
        <Scroll size="0" hideTrack visibility="Hover">
 | 
			
		||||
          <div style={{ padding: config.space.S100 }}>
 | 
			
		||||
          <div style={{ padding: config.space.S200 }}>
 | 
			
		||||
            {getPowers(powerLevelTags).map((power) => {
 | 
			
		||||
              const selected = value === power;
 | 
			
		||||
              const tag = powerLevelTags[power];
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -273,7 +273,7 @@ export const RoomCard = as<'div', RoomCardProps>(
 | 
			
		|||
            variant="Secondary"
 | 
			
		||||
            size="300"
 | 
			
		||||
            disabled={joining}
 | 
			
		||||
            before={joining && <Spinner size="50" variant="Secondary" fill="Soft" />}
 | 
			
		||||
            before={joining && <Spinner size="50" variant="Secondary" fill="Solid" />}
 | 
			
		||||
          >
 | 
			
		||||
            <Text size="B300" truncate>
 | 
			
		||||
              {joining ? 'Joining' : 'Join'}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -15,6 +15,8 @@ import { nameInitials } from '../../utils/common';
 | 
			
		|||
import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
 | 
			
		||||
import { mDirectAtom } from '../../state/mDirectList';
 | 
			
		||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
 | 
			
		||||
import { useSetting } from '../../state/hooks/settings';
 | 
			
		||||
import { settingsAtom } from '../../state/settings';
 | 
			
		||||
 | 
			
		||||
export type RoomIntroProps = {
 | 
			
		||||
  room: Room;
 | 
			
		||||
| 
						 | 
				
			
			@ -43,6 +45,8 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
 | 
			
		|||
    useCallback(async (roomId: string) => mx.joinRoom(roomId), [mx])
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Box direction="Column" grow="Yes" gap="500" {...props} ref={ref}>
 | 
			
		||||
      <Box>
 | 
			
		||||
| 
						 | 
				
			
			@ -67,7 +71,7 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
 | 
			
		|||
            <Text size="T200" priority="300">
 | 
			
		||||
              {'Created by '}
 | 
			
		||||
              <b>@{creatorName}</b>
 | 
			
		||||
              {` on ${timeDayMonthYear(ts)} ${timeHourMinute(ts)}`}
 | 
			
		||||
              {` on ${timeDayMonthYear(ts)} ${timeHourMinute(ts, hour24Clock)}`}
 | 
			
		||||
            </Text>
 | 
			
		||||
          )}
 | 
			
		||||
        </Box>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -24,7 +24,7 @@ export const TextViewerContent = forwardRef<HTMLPreElement, TextViewerContentPro
 | 
			
		|||
    >
 | 
			
		||||
      <ErrorBoundary fallback={<code>{text}</code>}>
 | 
			
		||||
        <Suspense fallback={<code>{text}</code>}>
 | 
			
		||||
          <ReactPrism>{(codeRef) => <code ref={codeRef}>{text}</code>}</ReactPrism>
 | 
			
		||||
          <ReactPrism key={text}>{(codeRef) => <code ref={codeRef}>{text}</code>}</ReactPrism>
 | 
			
		||||
        </Suspense>
 | 
			
		||||
      </ErrorBoundary>
 | 
			
		||||
    </Text>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										129
									
								
								src/app/components/time-date/DatePicker.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										129
									
								
								src/app/components/time-date/DatePicker.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,129 @@
 | 
			
		|||
import React, { forwardRef } from 'react';
 | 
			
		||||
import { Menu, Box, Text, Chip } from 'folds';
 | 
			
		||||
import dayjs from 'dayjs';
 | 
			
		||||
import * as css from './styles.css';
 | 
			
		||||
import { PickerColumn } from './PickerColumn';
 | 
			
		||||
import { dateFor, daysInMonth, daysToMs } from '../../utils/time';
 | 
			
		||||
 | 
			
		||||
type DatePickerProps = {
 | 
			
		||||
  min: number;
 | 
			
		||||
  max: number;
 | 
			
		||||
  value: number;
 | 
			
		||||
  onChange: (value: number) => void;
 | 
			
		||||
};
 | 
			
		||||
export const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(
 | 
			
		||||
  ({ min, max, value, onChange }, ref) => {
 | 
			
		||||
    const selectedYear = dayjs(value).year();
 | 
			
		||||
    const selectedMonth = dayjs(value).month() + 1;
 | 
			
		||||
    const selectedDay = dayjs(value).date();
 | 
			
		||||
 | 
			
		||||
    const handleSubmit = (newValue: number) => {
 | 
			
		||||
      onChange(Math.min(Math.max(min, newValue), max));
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleDay = (day: number) => {
 | 
			
		||||
      const seconds = daysToMs(day);
 | 
			
		||||
      const lastSeconds = daysToMs(selectedDay);
 | 
			
		||||
      const newValue = value + (seconds - lastSeconds);
 | 
			
		||||
      handleSubmit(newValue);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleMonthAndYear = (month: number, year: number) => {
 | 
			
		||||
      const mDays = daysInMonth(month, year);
 | 
			
		||||
      const currentDate = dateFor(selectedYear, selectedMonth, selectedDay);
 | 
			
		||||
      const time = value - currentDate;
 | 
			
		||||
 | 
			
		||||
      const newDate = dateFor(year, month, mDays < selectedDay ? mDays : selectedDay);
 | 
			
		||||
 | 
			
		||||
      const newValue = newDate + time;
 | 
			
		||||
      handleSubmit(newValue);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleMonth = (month: number) => {
 | 
			
		||||
      handleMonthAndYear(month, selectedYear);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleYear = (year: number) => {
 | 
			
		||||
      handleMonthAndYear(selectedMonth, year);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const minYear = dayjs(min).year();
 | 
			
		||||
    const maxYear = dayjs(max).year();
 | 
			
		||||
    const yearsRange = maxYear - minYear + 1;
 | 
			
		||||
 | 
			
		||||
    const minMonth = dayjs(min).month() + 1;
 | 
			
		||||
    const maxMonth = dayjs(max).month() + 1;
 | 
			
		||||
 | 
			
		||||
    const minDay = dayjs(min).date();
 | 
			
		||||
    const maxDay = dayjs(max).date();
 | 
			
		||||
    return (
 | 
			
		||||
      <Menu className={css.PickerMenu} ref={ref}>
 | 
			
		||||
        <Box direction="Row" gap="200" className={css.PickerContainer}>
 | 
			
		||||
          <PickerColumn title="Day">
 | 
			
		||||
            {Array.from(Array(daysInMonth(selectedMonth, selectedYear)).keys())
 | 
			
		||||
              .map((i) => i + 1)
 | 
			
		||||
              .map((day) => (
 | 
			
		||||
                <Chip
 | 
			
		||||
                  key={day}
 | 
			
		||||
                  size="500"
 | 
			
		||||
                  variant={selectedDay === day ? 'Primary' : 'SurfaceVariant'}
 | 
			
		||||
                  fill="None"
 | 
			
		||||
                  radii="300"
 | 
			
		||||
                  aria-selected={selectedDay === day}
 | 
			
		||||
                  onClick={() => handleDay(day)}
 | 
			
		||||
                  disabled={
 | 
			
		||||
                    (selectedYear === minYear && selectedMonth === minMonth && day < minDay) ||
 | 
			
		||||
                    (selectedYear === maxYear && selectedMonth === maxMonth && day > maxDay)
 | 
			
		||||
                  }
 | 
			
		||||
                >
 | 
			
		||||
                  <Text size="T300">{day}</Text>
 | 
			
		||||
                </Chip>
 | 
			
		||||
              ))}
 | 
			
		||||
          </PickerColumn>
 | 
			
		||||
          <PickerColumn title="Month">
 | 
			
		||||
            {Array.from(Array(12).keys())
 | 
			
		||||
              .map((i) => i + 1)
 | 
			
		||||
              .map((month) => (
 | 
			
		||||
                <Chip
 | 
			
		||||
                  key={month}
 | 
			
		||||
                  size="500"
 | 
			
		||||
                  variant={selectedMonth === month ? 'Primary' : 'SurfaceVariant'}
 | 
			
		||||
                  fill="None"
 | 
			
		||||
                  radii="300"
 | 
			
		||||
                  aria-selected={selectedMonth === month}
 | 
			
		||||
                  onClick={() => handleMonth(month)}
 | 
			
		||||
                  disabled={
 | 
			
		||||
                    (selectedYear === minYear && month < minMonth) ||
 | 
			
		||||
                    (selectedYear === maxYear && month > maxMonth)
 | 
			
		||||
                  }
 | 
			
		||||
                >
 | 
			
		||||
                  <Text size="T300">
 | 
			
		||||
                    {dayjs()
 | 
			
		||||
                      .month(month - 1)
 | 
			
		||||
                      .format('MMM')}
 | 
			
		||||
                  </Text>
 | 
			
		||||
                </Chip>
 | 
			
		||||
              ))}
 | 
			
		||||
          </PickerColumn>
 | 
			
		||||
          <PickerColumn title="Year">
 | 
			
		||||
            {Array.from(Array(yearsRange).keys())
 | 
			
		||||
              .map((i) => minYear + i)
 | 
			
		||||
              .map((year) => (
 | 
			
		||||
                <Chip
 | 
			
		||||
                  key={year}
 | 
			
		||||
                  size="500"
 | 
			
		||||
                  variant={selectedYear === year ? 'Primary' : 'SurfaceVariant'}
 | 
			
		||||
                  fill="None"
 | 
			
		||||
                  radii="300"
 | 
			
		||||
                  aria-selected={selectedYear === year}
 | 
			
		||||
                  onClick={() => handleYear(year)}
 | 
			
		||||
                >
 | 
			
		||||
                  <Text size="T300">{year}</Text>
 | 
			
		||||
                </Chip>
 | 
			
		||||
              ))}
 | 
			
		||||
          </PickerColumn>
 | 
			
		||||
        </Box>
 | 
			
		||||
      </Menu>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
);
 | 
			
		||||
							
								
								
									
										23
									
								
								src/app/components/time-date/PickerColumn.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								src/app/components/time-date/PickerColumn.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,23 @@
 | 
			
		|||
import React, { ReactNode } from 'react';
 | 
			
		||||
import { Box, Text, Scroll } from 'folds';
 | 
			
		||||
import { CutoutCard } from '../cutout-card';
 | 
			
		||||
import * as css from './styles.css';
 | 
			
		||||
 | 
			
		||||
export function PickerColumn({ title, children }: { title: string; children: ReactNode }) {
 | 
			
		||||
  return (
 | 
			
		||||
    <Box direction="Column" gap="100">
 | 
			
		||||
      <Text className={css.PickerColumnLabel} size="L400">
 | 
			
		||||
        {title}
 | 
			
		||||
      </Text>
 | 
			
		||||
      <Box grow="Yes">
 | 
			
		||||
        <CutoutCard variant="Background">
 | 
			
		||||
          <Scroll variant="Background" size="300" hideTrack>
 | 
			
		||||
            <Box className={css.PickerColumnContent} direction="Column" gap="100">
 | 
			
		||||
              {children}
 | 
			
		||||
            </Box>
 | 
			
		||||
          </Scroll>
 | 
			
		||||
        </CutoutCard>
 | 
			
		||||
      </Box>
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										153
									
								
								src/app/components/time-date/TimePicker.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										153
									
								
								src/app/components/time-date/TimePicker.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,153 @@
 | 
			
		|||
import React, { forwardRef } from 'react';
 | 
			
		||||
import { Menu, Box, Text, Chip } from 'folds';
 | 
			
		||||
import dayjs from 'dayjs';
 | 
			
		||||
import * as css from './styles.css';
 | 
			
		||||
import { PickerColumn } from './PickerColumn';
 | 
			
		||||
import { hour12to24, hour24to12, hoursToMs, inSameDay, minutesToMs } from '../../utils/time';
 | 
			
		||||
import { useSetting } from '../../state/hooks/settings';
 | 
			
		||||
import { settingsAtom } from '../../state/settings';
 | 
			
		||||
 | 
			
		||||
type TimePickerProps = {
 | 
			
		||||
  min: number;
 | 
			
		||||
  max: number;
 | 
			
		||||
  value: number;
 | 
			
		||||
  onChange: (value: number) => void;
 | 
			
		||||
};
 | 
			
		||||
export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
 | 
			
		||||
  ({ min, max, value, onChange }, ref) => {
 | 
			
		||||
    const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
 | 
			
		||||
 | 
			
		||||
    const hour24 = dayjs(value).hour();
 | 
			
		||||
 | 
			
		||||
    const selectedHour = hour24Clock ? hour24 : hour24to12(hour24);
 | 
			
		||||
    const selectedMinute = dayjs(value).minute();
 | 
			
		||||
    const selectedPM = hour24 >= 12;
 | 
			
		||||
 | 
			
		||||
    const handleSubmit = (newValue: number) => {
 | 
			
		||||
      onChange(Math.min(Math.max(min, newValue), max));
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleHour = (hour: number) => {
 | 
			
		||||
      const seconds = hoursToMs(hour24Clock ? hour : hour12to24(hour, selectedPM));
 | 
			
		||||
      const lastSeconds = hoursToMs(hour24);
 | 
			
		||||
      const newValue = value + (seconds - lastSeconds);
 | 
			
		||||
      handleSubmit(newValue);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleMinute = (minute: number) => {
 | 
			
		||||
      const seconds = minutesToMs(minute);
 | 
			
		||||
      const lastSeconds = minutesToMs(selectedMinute);
 | 
			
		||||
      const newValue = value + (seconds - lastSeconds);
 | 
			
		||||
      handleSubmit(newValue);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handlePeriod = (pm: boolean) => {
 | 
			
		||||
      const seconds = hoursToMs(hour12to24(selectedHour, pm));
 | 
			
		||||
      const lastSeconds = hoursToMs(hour24);
 | 
			
		||||
      const newValue = value + (seconds - lastSeconds);
 | 
			
		||||
      handleSubmit(newValue);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const minHour24 = dayjs(min).hour();
 | 
			
		||||
    const maxHour24 = dayjs(max).hour();
 | 
			
		||||
 | 
			
		||||
    const minMinute = dayjs(min).minute();
 | 
			
		||||
    const maxMinute = dayjs(max).minute();
 | 
			
		||||
    const minPM = minHour24 >= 12;
 | 
			
		||||
    const maxPM = maxHour24 >= 12;
 | 
			
		||||
 | 
			
		||||
    const minDay = inSameDay(min, value);
 | 
			
		||||
    const maxDay = inSameDay(max, value);
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <Menu className={css.PickerMenu} ref={ref}>
 | 
			
		||||
        <Box direction="Row" gap="200" className={css.PickerContainer}>
 | 
			
		||||
          <PickerColumn title="Hour">
 | 
			
		||||
            {hour24Clock
 | 
			
		||||
              ? Array.from(Array(24).keys()).map((hour) => (
 | 
			
		||||
                  <Chip
 | 
			
		||||
                    key={hour}
 | 
			
		||||
                    size="500"
 | 
			
		||||
                    variant={hour === selectedHour ? 'Primary' : 'Background'}
 | 
			
		||||
                    fill="None"
 | 
			
		||||
                    radii="300"
 | 
			
		||||
                    aria-selected={hour === selectedHour}
 | 
			
		||||
                    onClick={() => handleHour(hour)}
 | 
			
		||||
                    disabled={(minDay && hour < minHour24) || (maxDay && hour > maxHour24)}
 | 
			
		||||
                  >
 | 
			
		||||
                    <Text size="T300">{hour < 10 ? `0${hour}` : hour}</Text>
 | 
			
		||||
                  </Chip>
 | 
			
		||||
                ))
 | 
			
		||||
              : Array.from(Array(12).keys())
 | 
			
		||||
                  .map((i) => {
 | 
			
		||||
                    if (i === 0) return 12;
 | 
			
		||||
                    return i;
 | 
			
		||||
                  })
 | 
			
		||||
                  .map((hour) => (
 | 
			
		||||
                    <Chip
 | 
			
		||||
                      key={hour}
 | 
			
		||||
                      size="500"
 | 
			
		||||
                      variant={hour === selectedHour ? 'Primary' : 'Background'}
 | 
			
		||||
                      fill="None"
 | 
			
		||||
                      radii="300"
 | 
			
		||||
                      aria-selected={hour === selectedHour}
 | 
			
		||||
                      onClick={() => handleHour(hour)}
 | 
			
		||||
                      disabled={
 | 
			
		||||
                        (minDay && hour12to24(hour, selectedPM) < minHour24) ||
 | 
			
		||||
                        (maxDay && hour12to24(hour, selectedPM) > maxHour24)
 | 
			
		||||
                      }
 | 
			
		||||
                    >
 | 
			
		||||
                      <Text size="T300">{hour < 10 ? `0${hour}` : hour}</Text>
 | 
			
		||||
                    </Chip>
 | 
			
		||||
                  ))}
 | 
			
		||||
          </PickerColumn>
 | 
			
		||||
          <PickerColumn title="Minutes">
 | 
			
		||||
            {Array.from(Array(60).keys()).map((minute) => (
 | 
			
		||||
              <Chip
 | 
			
		||||
                key={minute}
 | 
			
		||||
                size="500"
 | 
			
		||||
                variant={minute === selectedMinute ? 'Primary' : 'Background'}
 | 
			
		||||
                fill="None"
 | 
			
		||||
                radii="300"
 | 
			
		||||
                aria-selected={minute === selectedMinute}
 | 
			
		||||
                onClick={() => handleMinute(minute)}
 | 
			
		||||
                disabled={
 | 
			
		||||
                  (minDay && hour24 === minHour24 && minute < minMinute) ||
 | 
			
		||||
                  (maxDay && hour24 === maxHour24 && minute > maxMinute)
 | 
			
		||||
                }
 | 
			
		||||
              >
 | 
			
		||||
                <Text size="T300">{minute < 10 ? `0${minute}` : minute}</Text>
 | 
			
		||||
              </Chip>
 | 
			
		||||
            ))}
 | 
			
		||||
          </PickerColumn>
 | 
			
		||||
          {!hour24Clock && (
 | 
			
		||||
            <PickerColumn title="Period">
 | 
			
		||||
              <Chip
 | 
			
		||||
                size="500"
 | 
			
		||||
                variant={!selectedPM ? 'Primary' : 'SurfaceVariant'}
 | 
			
		||||
                fill="None"
 | 
			
		||||
                radii="300"
 | 
			
		||||
                aria-selected={!selectedPM}
 | 
			
		||||
                onClick={() => handlePeriod(false)}
 | 
			
		||||
                disabled={minDay && minPM}
 | 
			
		||||
              >
 | 
			
		||||
                <Text size="T300">AM</Text>
 | 
			
		||||
              </Chip>
 | 
			
		||||
              <Chip
 | 
			
		||||
                size="500"
 | 
			
		||||
                variant={selectedPM ? 'Primary' : 'SurfaceVariant'}
 | 
			
		||||
                fill="None"
 | 
			
		||||
                radii="300"
 | 
			
		||||
                aria-selected={selectedPM}
 | 
			
		||||
                onClick={() => handlePeriod(true)}
 | 
			
		||||
                disabled={maxDay && !maxPM}
 | 
			
		||||
              >
 | 
			
		||||
                <Text size="T300">PM</Text>
 | 
			
		||||
              </Chip>
 | 
			
		||||
            </PickerColumn>
 | 
			
		||||
          )}
 | 
			
		||||
        </Box>
 | 
			
		||||
      </Menu>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
);
 | 
			
		||||
							
								
								
									
										2
									
								
								src/app/components/time-date/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								src/app/components/time-date/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,2 @@
 | 
			
		|||
export * from './TimePicker';
 | 
			
		||||
export * from './DatePicker';
 | 
			
		||||
							
								
								
									
										16
									
								
								src/app/components/time-date/styles.css.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								src/app/components/time-date/styles.css.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,16 @@
 | 
			
		|||
import { style } from '@vanilla-extract/css';
 | 
			
		||||
import { config, toRem } from 'folds';
 | 
			
		||||
 | 
			
		||||
export const PickerMenu = style({
 | 
			
		||||
  padding: config.space.S200,
 | 
			
		||||
});
 | 
			
		||||
export const PickerContainer = style({
 | 
			
		||||
  maxHeight: toRem(250),
 | 
			
		||||
});
 | 
			
		||||
export const PickerColumnLabel = style({
 | 
			
		||||
  padding: config.space.S200,
 | 
			
		||||
});
 | 
			
		||||
export const PickerColumnContent = style({
 | 
			
		||||
  padding: config.space.S200,
 | 
			
		||||
  paddingRight: 0,
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			@ -119,7 +119,7 @@ export function RoomHistoryVisibility({ powerLevels }: RoomHistoryVisibilityProp
 | 
			
		|||
                  escapeDeactivates: stopPropagation,
 | 
			
		||||
                }}
 | 
			
		||||
              >
 | 
			
		||||
                <Menu style={{ padding: config.space.S100 }}>
 | 
			
		||||
                <Menu style={{ padding: config.space.S200 }}>
 | 
			
		||||
                  {visibilityMenu.map((visibility) => (
 | 
			
		||||
                    <MenuItem
 | 
			
		||||
                      key={visibility}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,8 +2,10 @@ import React, { useCallback, useMemo } from 'react';
 | 
			
		|||
import { color, Text } from 'folds';
 | 
			
		||||
import { JoinRule, MatrixError, RestrictedAllowType } from 'matrix-js-sdk';
 | 
			
		||||
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
 | 
			
		||||
import { useAtomValue } from 'jotai';
 | 
			
		||||
import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels';
 | 
			
		||||
import {
 | 
			
		||||
  ExtendedJoinRules,
 | 
			
		||||
  JoinRulesSwitcher,
 | 
			
		||||
  useRoomJoinRuleIcon,
 | 
			
		||||
  useRoomJoinRuleLabel,
 | 
			
		||||
| 
						 | 
				
			
			@ -19,6 +21,12 @@ import { useStateEvent } from '../../../hooks/useStateEvent';
 | 
			
		|||
import { useSpaceOptionally } from '../../../hooks/useSpace';
 | 
			
		||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
 | 
			
		||||
import { getStateEvents } from '../../../utils/room';
 | 
			
		||||
import {
 | 
			
		||||
  useRecursiveChildSpaceScopeFactory,
 | 
			
		||||
  useSpaceChildren,
 | 
			
		||||
} from '../../../state/hooks/roomList';
 | 
			
		||||
import { allRoomsAtom } from '../../../state/room-list/roomList';
 | 
			
		||||
import { roomToParentsAtom } from '../../../state/room/roomToParents';
 | 
			
		||||
 | 
			
		||||
type RestrictedRoomAllowContent = {
 | 
			
		||||
  room_id: string;
 | 
			
		||||
| 
						 | 
				
			
			@ -32,9 +40,14 @@ export function RoomJoinRules({ powerLevels }: RoomJoinRulesProps) {
 | 
			
		|||
  const mx = useMatrixClient();
 | 
			
		||||
  const room = useRoom();
 | 
			
		||||
  const roomVersion = parseInt(room.getVersion(), 10);
 | 
			
		||||
  const allowKnockRestricted = roomVersion >= 10;
 | 
			
		||||
  const allowRestricted = roomVersion >= 8;
 | 
			
		||||
  const allowKnock = roomVersion >= 7;
 | 
			
		||||
 | 
			
		||||
  const roomIdToParents = useAtomValue(roomToParentsAtom);
 | 
			
		||||
  const space = useSpaceOptionally();
 | 
			
		||||
  const subspacesScope = useRecursiveChildSpaceScopeFactory(mx, roomIdToParents);
 | 
			
		||||
  const subspaces = useSpaceChildren(allRoomsAtom, space?.roomId ?? '', subspacesScope);
 | 
			
		||||
 | 
			
		||||
  const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId());
 | 
			
		||||
  const canEdit = powerLevelAPI.canSendStateEvent(
 | 
			
		||||
| 
						 | 
				
			
			@ -47,18 +60,21 @@ export function RoomJoinRules({ powerLevels }: RoomJoinRulesProps) {
 | 
			
		|||
  const content = joinRuleEvent?.getContent<RoomJoinRulesEventContent>();
 | 
			
		||||
  const rule: JoinRule = content?.join_rule ?? JoinRule.Invite;
 | 
			
		||||
 | 
			
		||||
  const joinRules: Array<JoinRule> = useMemo(() => {
 | 
			
		||||
    const r: JoinRule[] = [JoinRule.Invite];
 | 
			
		||||
  const joinRules: Array<ExtendedJoinRules> = useMemo(() => {
 | 
			
		||||
    const r: ExtendedJoinRules[] = [JoinRule.Invite];
 | 
			
		||||
    if (allowKnock) {
 | 
			
		||||
      r.push(JoinRule.Knock);
 | 
			
		||||
    }
 | 
			
		||||
    if (allowRestricted && space) {
 | 
			
		||||
      r.push(JoinRule.Restricted);
 | 
			
		||||
    }
 | 
			
		||||
    if (allowKnockRestricted && space) {
 | 
			
		||||
      r.push('knock_restricted');
 | 
			
		||||
    }
 | 
			
		||||
    r.push(JoinRule.Public);
 | 
			
		||||
 | 
			
		||||
    return r;
 | 
			
		||||
  }, [allowRestricted, allowKnock, space]);
 | 
			
		||||
  }, [allowKnockRestricted, allowRestricted, allowKnock, space]);
 | 
			
		||||
 | 
			
		||||
  const icons = useRoomJoinRuleIcon();
 | 
			
		||||
  const spaceIcons = useSpaceJoinRuleIcon();
 | 
			
		||||
| 
						 | 
				
			
			@ -66,12 +82,25 @@ export function RoomJoinRules({ powerLevels }: RoomJoinRulesProps) {
 | 
			
		|||
 | 
			
		||||
  const [submitState, submit] = useAsyncCallback(
 | 
			
		||||
    useCallback(
 | 
			
		||||
      async (joinRule: JoinRule) => {
 | 
			
		||||
      async (joinRule: ExtendedJoinRules) => {
 | 
			
		||||
        const allow: RestrictedRoomAllowContent[] = [];
 | 
			
		||||
        if (joinRule === JoinRule.Restricted) {
 | 
			
		||||
          const parents = getStateEvents(room, StateEvent.SpaceParent).map((event) =>
 | 
			
		||||
            event.getStateKey()
 | 
			
		||||
          );
 | 
			
		||||
        if (joinRule === JoinRule.Restricted || joinRule === 'knock_restricted') {
 | 
			
		||||
          const roomParents = roomIdToParents.get(room.roomId);
 | 
			
		||||
 | 
			
		||||
          const parents = getStateEvents(room, StateEvent.SpaceParent)
 | 
			
		||||
            .map((event) => event.getStateKey())
 | 
			
		||||
            .filter((parentId) => typeof parentId === 'string')
 | 
			
		||||
            .filter((parentId) => roomParents?.has(parentId));
 | 
			
		||||
 | 
			
		||||
          if (parents.length === 0 && space && roomParents) {
 | 
			
		||||
            // if no m.space.parent found
 | 
			
		||||
            // find parent in current space
 | 
			
		||||
            const selectedParents = subspaces.filter((rId) => roomParents.has(rId));
 | 
			
		||||
            if (roomParents.has(space.roomId)) {
 | 
			
		||||
              selectedParents.push(space.roomId);
 | 
			
		||||
            }
 | 
			
		||||
            selectedParents.forEach((pId) => parents.push(pId));
 | 
			
		||||
          }
 | 
			
		||||
          parents.forEach((parentRoomId) => {
 | 
			
		||||
            if (!parentRoomId) return;
 | 
			
		||||
            allow.push({
 | 
			
		||||
| 
						 | 
				
			
			@ -82,12 +111,12 @@ export function RoomJoinRules({ powerLevels }: RoomJoinRulesProps) {
 | 
			
		|||
        }
 | 
			
		||||
 | 
			
		||||
        const c: RoomJoinRulesEventContent = {
 | 
			
		||||
          join_rule: joinRule,
 | 
			
		||||
          join_rule: joinRule as JoinRule,
 | 
			
		||||
        };
 | 
			
		||||
        if (allow.length > 0) c.allow = allow;
 | 
			
		||||
        await mx.sendStateEvent(room.roomId, StateEvent.RoomJoinRules as any, c);
 | 
			
		||||
      },
 | 
			
		||||
      [mx, room]
 | 
			
		||||
      [mx, room, space, subspaces, roomIdToParents]
 | 
			
		||||
    )
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -63,7 +63,7 @@ function PeekPermissions({ powerLevels, power, permissionGroups, children }: Pee
 | 
			
		|||
          >
 | 
			
		||||
            <Box grow="Yes" tabIndex={0}>
 | 
			
		||||
              <Scroll size="0" hideTrack visibility="Hover">
 | 
			
		||||
                <Box style={{ padding: config.space.S200 }} direction="Column" gap="400">
 | 
			
		||||
                <Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
 | 
			
		||||
                  {permissionGroups.map((group, groupIndex) => (
 | 
			
		||||
                    <Box key={groupIndex} direction="Column" gap="100">
 | 
			
		||||
                      <Text size="L400">{group.name}</Text>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -234,9 +234,9 @@ export function HierarchyItemMenu({
 | 
			
		|||
                escapeDeactivates: stopPropagation,
 | 
			
		||||
              }}
 | 
			
		||||
            >
 | 
			
		||||
              <Menu style={{ maxWidth: toRem(150), width: '100vw' }}>
 | 
			
		||||
              <Menu style={{ minWidth: toRem(200) }}>
 | 
			
		||||
                {joined && (
 | 
			
		||||
                  <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
 | 
			
		||||
                  <Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
 | 
			
		||||
                    {onTogglePin && (
 | 
			
		||||
                      <MenuItem
 | 
			
		||||
                        size="300"
 | 
			
		||||
| 
						 | 
				
			
			@ -296,7 +296,7 @@ export function HierarchyItemMenu({
 | 
			
		|||
                  <Line size="300" variant="Surface" direction="Horizontal" />
 | 
			
		||||
                )}
 | 
			
		||||
                {canEditChild && (
 | 
			
		||||
                  <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
 | 
			
		||||
                  <Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
 | 
			
		||||
                    <SuggestMenuItem item={item} requestClose={handleRequestClose} />
 | 
			
		||||
                    <RemoveMenuItem item={item} requestClose={handleRequestClose} />
 | 
			
		||||
                  </Box>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,5 +1,5 @@
 | 
			
		|||
import React, { MouseEventHandler, useCallback, useMemo, useRef, useState } from 'react';
 | 
			
		||||
import { Box, Icon, IconButton, Icons, Line, Scroll, config } from 'folds';
 | 
			
		||||
import { Box, Chip, Icon, IconButton, Icons, Line, Scroll, Spinner, Text, config } from 'folds';
 | 
			
		||||
import { useVirtualizer } from '@tanstack/react-virtual';
 | 
			
		||||
import { useAtom, useAtomValue } from 'jotai';
 | 
			
		||||
import { useNavigate } from 'react-router-dom';
 | 
			
		||||
| 
						 | 
				
			
			@ -36,7 +36,7 @@ import { makeLobbyCategoryId } from '../../state/closedLobbyCategories';
 | 
			
		|||
import { useCategoryHandler } from '../../hooks/useCategoryHandler';
 | 
			
		||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
 | 
			
		||||
import { allRoomsAtom } from '../../state/room-list/roomList';
 | 
			
		||||
import { getCanonicalAliasOrRoomId } from '../../utils/matrix';
 | 
			
		||||
import { getCanonicalAliasOrRoomId, rateLimitedActions } from '../../utils/matrix';
 | 
			
		||||
import { getSpaceRoomPath } from '../../pages/pathUtils';
 | 
			
		||||
import { StateEvent } from '../../../types/matrix/room';
 | 
			
		||||
import { CanDropCallback, useDnDMonitor } from './DnD';
 | 
			
		||||
| 
						 | 
				
			
			@ -53,6 +53,95 @@ import { roomToParentsAtom } from '../../state/room/roomToParents';
 | 
			
		|||
import { AccountDataEvent } from '../../../types/matrix/accountData';
 | 
			
		||||
import { useRoomMembers } from '../../hooks/useRoomMembers';
 | 
			
		||||
import { SpaceHierarchy } from './SpaceHierarchy';
 | 
			
		||||
import { useGetRoom } from '../../hooks/useGetRoom';
 | 
			
		||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
 | 
			
		||||
 | 
			
		||||
const useCanDropLobbyItem = (
 | 
			
		||||
  space: Room,
 | 
			
		||||
  roomsPowerLevels: Map<string, IPowerLevels>,
 | 
			
		||||
  getRoom: (roomId: string) => Room | undefined,
 | 
			
		||||
  canEditSpaceChild: (powerLevels: IPowerLevels) => boolean
 | 
			
		||||
): CanDropCallback => {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
 | 
			
		||||
  const canDropSpace: CanDropCallback = useCallback(
 | 
			
		||||
    (item, container) => {
 | 
			
		||||
      if (!('space' in container.item)) {
 | 
			
		||||
        // can not drop around rooms.
 | 
			
		||||
        // space can only be drop around other spaces
 | 
			
		||||
        return false;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const containerSpaceId = space.roomId;
 | 
			
		||||
 | 
			
		||||
      if (
 | 
			
		||||
        getRoom(containerSpaceId) === undefined ||
 | 
			
		||||
        !canEditSpaceChild(roomsPowerLevels.get(containerSpaceId) ?? {})
 | 
			
		||||
      ) {
 | 
			
		||||
        return false;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return true;
 | 
			
		||||
    },
 | 
			
		||||
    [space, roomsPowerLevels, getRoom, canEditSpaceChild]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const canDropRoom: CanDropCallback = useCallback(
 | 
			
		||||
    (item, container) => {
 | 
			
		||||
      const containerSpaceId =
 | 
			
		||||
        'space' in container.item ? container.item.roomId : container.item.parentId;
 | 
			
		||||
 | 
			
		||||
      const draggingOutsideSpace = item.parentId !== containerSpaceId;
 | 
			
		||||
      const restrictedItem = mx.getRoom(item.roomId)?.getJoinRule() === JoinRule.Restricted;
 | 
			
		||||
 | 
			
		||||
      // check and do not allow restricted room to be dragged outside
 | 
			
		||||
      // current space if can't change `m.room.join_rules` `content.allow`
 | 
			
		||||
      if (draggingOutsideSpace && restrictedItem) {
 | 
			
		||||
        const itemPowerLevel = roomsPowerLevels.get(item.roomId) ?? {};
 | 
			
		||||
        const userPLInItem = powerLevelAPI.getPowerLevel(
 | 
			
		||||
          itemPowerLevel,
 | 
			
		||||
          mx.getUserId() ?? undefined
 | 
			
		||||
        );
 | 
			
		||||
        const canChangeJoinRuleAllow = powerLevelAPI.canSendStateEvent(
 | 
			
		||||
          itemPowerLevel,
 | 
			
		||||
          StateEvent.RoomJoinRules,
 | 
			
		||||
          userPLInItem
 | 
			
		||||
        );
 | 
			
		||||
        if (!canChangeJoinRuleAllow) {
 | 
			
		||||
          return false;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (
 | 
			
		||||
        getRoom(containerSpaceId) === undefined ||
 | 
			
		||||
        !canEditSpaceChild(roomsPowerLevels.get(containerSpaceId) ?? {})
 | 
			
		||||
      ) {
 | 
			
		||||
        return false;
 | 
			
		||||
      }
 | 
			
		||||
      return true;
 | 
			
		||||
    },
 | 
			
		||||
    [mx, getRoom, canEditSpaceChild, roomsPowerLevels]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const canDrop: CanDropCallback = useCallback(
 | 
			
		||||
    (item, container): boolean => {
 | 
			
		||||
      if (item.roomId === container.item.roomId || item.roomId === container.nextRoomId) {
 | 
			
		||||
        // can not drop before or after itself
 | 
			
		||||
        return false;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // if we are dragging a space
 | 
			
		||||
      if ('space' in item) {
 | 
			
		||||
        return canDropSpace(item, container);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return canDropRoom(item, container);
 | 
			
		||||
    },
 | 
			
		||||
    [canDropSpace, canDropRoom]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return canDrop;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export function Lobby() {
 | 
			
		||||
  const navigate = useNavigate();
 | 
			
		||||
| 
						 | 
				
			
			@ -92,15 +181,7 @@ export function Lobby() {
 | 
			
		|||
    useCallback((w, height) => setHeroSectionHeight(height), [])
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const getRoom = useCallback(
 | 
			
		||||
    (rId: string) => {
 | 
			
		||||
      if (allJoinedRooms.has(rId)) {
 | 
			
		||||
        return mx.getRoom(rId) ?? undefined;
 | 
			
		||||
      }
 | 
			
		||||
      return undefined;
 | 
			
		||||
    },
 | 
			
		||||
    [mx, allJoinedRooms]
 | 
			
		||||
  );
 | 
			
		||||
  const getRoom = useGetRoom(allJoinedRooms);
 | 
			
		||||
 | 
			
		||||
  const canEditSpaceChild = useCallback(
 | 
			
		||||
    (powerLevels: IPowerLevels) =>
 | 
			
		||||
| 
						 | 
				
			
			@ -150,180 +231,155 @@ export function Lobby() {
 | 
			
		|||
    )
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const canDrop: CanDropCallback = useCallback(
 | 
			
		||||
    (item, container): boolean => {
 | 
			
		||||
      const restrictedItem = mx.getRoom(item.roomId)?.getJoinRule() === JoinRule.Restricted;
 | 
			
		||||
      if (item.roomId === container.item.roomId || item.roomId === container.nextRoomId) {
 | 
			
		||||
        // can not drop before or after itself
 | 
			
		||||
        return false;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if ('space' in item) {
 | 
			
		||||
        if (!('space' in container.item)) return false;
 | 
			
		||||
        const containerSpaceId = space.roomId;
 | 
			
		||||
 | 
			
		||||
        if (
 | 
			
		||||
          getRoom(containerSpaceId) === undefined ||
 | 
			
		||||
          !canEditSpaceChild(roomsPowerLevels.get(containerSpaceId) ?? {})
 | 
			
		||||
        ) {
 | 
			
		||||
          return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return true;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const containerSpaceId =
 | 
			
		||||
        'space' in container.item ? container.item.roomId : container.item.parentId;
 | 
			
		||||
 | 
			
		||||
      const dropOutsideSpace = item.parentId !== containerSpaceId;
 | 
			
		||||
 | 
			
		||||
      if (dropOutsideSpace && restrictedItem) {
 | 
			
		||||
        // do not allow restricted room to drop outside
 | 
			
		||||
        // current space if can't change join rule allow
 | 
			
		||||
        const itemPowerLevel = roomsPowerLevels.get(item.roomId) ?? {};
 | 
			
		||||
        const userPLInItem = powerLevelAPI.getPowerLevel(
 | 
			
		||||
          itemPowerLevel,
 | 
			
		||||
          mx.getUserId() ?? undefined
 | 
			
		||||
        );
 | 
			
		||||
        const canChangeJoinRuleAllow = powerLevelAPI.canSendStateEvent(
 | 
			
		||||
          itemPowerLevel,
 | 
			
		||||
          StateEvent.RoomJoinRules,
 | 
			
		||||
          userPLInItem
 | 
			
		||||
        );
 | 
			
		||||
        if (!canChangeJoinRuleAllow) {
 | 
			
		||||
          return false;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (
 | 
			
		||||
        getRoom(containerSpaceId) === undefined ||
 | 
			
		||||
        !canEditSpaceChild(roomsPowerLevels.get(containerSpaceId) ?? {})
 | 
			
		||||
      ) {
 | 
			
		||||
        return false;
 | 
			
		||||
      }
 | 
			
		||||
      return true;
 | 
			
		||||
    },
 | 
			
		||||
    [getRoom, space.roomId, roomsPowerLevels, canEditSpaceChild, mx]
 | 
			
		||||
  const canDrop: CanDropCallback = useCanDropLobbyItem(
 | 
			
		||||
    space,
 | 
			
		||||
    roomsPowerLevels,
 | 
			
		||||
    getRoom,
 | 
			
		||||
    canEditSpaceChild
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const reorderSpace = useCallback(
 | 
			
		||||
    (item: HierarchyItemSpace, containerItem: HierarchyItem) => {
 | 
			
		||||
      if (!item.parentId) return;
 | 
			
		||||
  const [reorderSpaceState, reorderSpace] = useAsyncCallback(
 | 
			
		||||
    useCallback(
 | 
			
		||||
      async (item: HierarchyItemSpace, containerItem: HierarchyItem) => {
 | 
			
		||||
        if (!item.parentId) return;
 | 
			
		||||
 | 
			
		||||
      const itemSpaces: HierarchyItemSpace[] = hierarchy
 | 
			
		||||
        .map((i) => i.space)
 | 
			
		||||
        .filter((i) => i.roomId !== item.roomId);
 | 
			
		||||
        const itemSpaces: HierarchyItemSpace[] = hierarchy
 | 
			
		||||
          .map((i) => i.space)
 | 
			
		||||
          .filter((i) => i.roomId !== item.roomId);
 | 
			
		||||
 | 
			
		||||
      const beforeIndex = itemSpaces.findIndex((i) => i.roomId === containerItem.roomId);
 | 
			
		||||
      const insertIndex = beforeIndex + 1;
 | 
			
		||||
        const beforeIndex = itemSpaces.findIndex((i) => i.roomId === containerItem.roomId);
 | 
			
		||||
        const insertIndex = beforeIndex + 1;
 | 
			
		||||
 | 
			
		||||
      itemSpaces.splice(insertIndex, 0, {
 | 
			
		||||
        ...item,
 | 
			
		||||
        content: { ...item.content, order: undefined },
 | 
			
		||||
      });
 | 
			
		||||
        itemSpaces.splice(insertIndex, 0, {
 | 
			
		||||
          ...item,
 | 
			
		||||
          content: { ...item.content, order: undefined },
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
      const currentOrders = itemSpaces.map((i) => {
 | 
			
		||||
        if (typeof i.content.order === 'string' && lex.has(i.content.order)) {
 | 
			
		||||
          return i.content.order;
 | 
			
		||||
        }
 | 
			
		||||
        return undefined;
 | 
			
		||||
      });
 | 
			
		||||
        const currentOrders = itemSpaces.map((i) => {
 | 
			
		||||
          if (typeof i.content.order === 'string' && lex.has(i.content.order)) {
 | 
			
		||||
            return i.content.order;
 | 
			
		||||
          }
 | 
			
		||||
          return undefined;
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
      const newOrders = orderKeys(lex, currentOrders);
 | 
			
		||||
        const newOrders = orderKeys(lex, currentOrders);
 | 
			
		||||
 | 
			
		||||
      newOrders?.forEach((orderKey, 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 as any,
 | 
			
		||||
            { ...itm.content, order: orderKey },
 | 
			
		||||
            itm.roomId
 | 
			
		||||
          );
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
    },
 | 
			
		||||
    [mx, hierarchy, lex, roomsPowerLevels, canEditSpaceChild]
 | 
			
		||||
  );
 | 
			
		||||
        const reorders = newOrders
 | 
			
		||||
          ?.map((orderKey, index) => ({
 | 
			
		||||
            item: itemSpaces[index],
 | 
			
		||||
            orderKey,
 | 
			
		||||
          }))
 | 
			
		||||
          .filter((reorder, index) => {
 | 
			
		||||
            if (!reorder.item.parentId) return false;
 | 
			
		||||
            const parentPL = roomsPowerLevels.get(reorder.item.parentId);
 | 
			
		||||
            const canEdit = parentPL && canEditSpaceChild(parentPL);
 | 
			
		||||
            return canEdit && reorder.orderKey !== currentOrders[index];
 | 
			
		||||
          });
 | 
			
		||||
 | 
			
		||||
  const reorderRoom = useCallback(
 | 
			
		||||
    (item: HierarchyItem, containerItem: HierarchyItem): void => {
 | 
			
		||||
      const itemRoom = mx.getRoom(item.roomId);
 | 
			
		||||
      if (!item.parentId) {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      const containerParentId: string =
 | 
			
		||||
        'space' in containerItem ? containerItem.roomId : containerItem.parentId;
 | 
			
		||||
      const itemContent = item.content;
 | 
			
		||||
 | 
			
		||||
      if (item.parentId !== containerParentId) {
 | 
			
		||||
        mx.sendStateEvent(item.parentId, StateEvent.SpaceChild as any, {}, item.roomId);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (
 | 
			
		||||
        itemRoom &&
 | 
			
		||||
        itemRoom.getJoinRule() === JoinRule.Restricted &&
 | 
			
		||||
        item.parentId !== containerParentId
 | 
			
		||||
      ) {
 | 
			
		||||
        // change join rule allow parameter when dragging
 | 
			
		||||
        // restricted room from one space to another
 | 
			
		||||
        const joinRuleContent = getStateEvent(
 | 
			
		||||
          itemRoom,
 | 
			
		||||
          StateEvent.RoomJoinRules
 | 
			
		||||
        )?.getContent<RoomJoinRulesEventContent>();
 | 
			
		||||
 | 
			
		||||
        if (joinRuleContent) {
 | 
			
		||||
          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 as any, {
 | 
			
		||||
            ...joinRuleContent,
 | 
			
		||||
            allow,
 | 
			
		||||
        if (reorders) {
 | 
			
		||||
          await rateLimitedActions(reorders, async (reorder) => {
 | 
			
		||||
            if (!reorder.item.parentId) return;
 | 
			
		||||
            await mx.sendStateEvent(
 | 
			
		||||
              reorder.item.parentId,
 | 
			
		||||
              StateEvent.SpaceChild as any,
 | 
			
		||||
              { ...reorder.item.content, order: reorder.orderKey },
 | 
			
		||||
              reorder.item.roomId
 | 
			
		||||
            );
 | 
			
		||||
          });
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const itemSpaces = Array.from(
 | 
			
		||||
        hierarchy?.find((i) => i.space.roomId === containerParentId)?.rooms ?? []
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      const beforeItem: HierarchyItem | undefined =
 | 
			
		||||
        'space' in containerItem ? undefined : containerItem;
 | 
			
		||||
      const beforeIndex = itemSpaces.findIndex((i) => i.roomId === beforeItem?.roomId);
 | 
			
		||||
      const insertIndex = beforeIndex + 1;
 | 
			
		||||
 | 
			
		||||
      itemSpaces.splice(insertIndex, 0, {
 | 
			
		||||
        ...item,
 | 
			
		||||
        parentId: containerParentId,
 | 
			
		||||
        content: { ...itemContent, order: undefined },
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const currentOrders = itemSpaces.map((i) => {
 | 
			
		||||
        if (typeof i.content.order === 'string' && lex.has(i.content.order)) {
 | 
			
		||||
          return i.content.order;
 | 
			
		||||
        }
 | 
			
		||||
        return undefined;
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      const newOrders = orderKeys(lex, currentOrders);
 | 
			
		||||
 | 
			
		||||
      newOrders?.forEach((orderKey, index) => {
 | 
			
		||||
        const itm = itemSpaces[index];
 | 
			
		||||
        if (itm && orderKey !== currentOrders[index]) {
 | 
			
		||||
          mx.sendStateEvent(
 | 
			
		||||
            containerParentId,
 | 
			
		||||
            StateEvent.SpaceChild as any,
 | 
			
		||||
            { ...itm.content, order: orderKey },
 | 
			
		||||
            itm.roomId
 | 
			
		||||
          );
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
    },
 | 
			
		||||
    [mx, hierarchy, lex]
 | 
			
		||||
      },
 | 
			
		||||
      [mx, hierarchy, lex, roomsPowerLevels, canEditSpaceChild]
 | 
			
		||||
    )
 | 
			
		||||
  );
 | 
			
		||||
  const reorderingSpace = reorderSpaceState.status === AsyncStatus.Loading;
 | 
			
		||||
 | 
			
		||||
  const [reorderRoomState, reorderRoom] = useAsyncCallback(
 | 
			
		||||
    useCallback(
 | 
			
		||||
      async (item: HierarchyItem, containerItem: HierarchyItem) => {
 | 
			
		||||
        const itemRoom = mx.getRoom(item.roomId);
 | 
			
		||||
        if (!item.parentId) {
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
        const containerParentId: string =
 | 
			
		||||
          'space' in containerItem ? containerItem.roomId : containerItem.parentId;
 | 
			
		||||
        const itemContent = item.content;
 | 
			
		||||
 | 
			
		||||
        // remove from current space
 | 
			
		||||
        if (item.parentId !== containerParentId) {
 | 
			
		||||
          mx.sendStateEvent(item.parentId, StateEvent.SpaceChild as any, {}, item.roomId);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (
 | 
			
		||||
          itemRoom &&
 | 
			
		||||
          itemRoom.getJoinRule() === JoinRule.Restricted &&
 | 
			
		||||
          item.parentId !== containerParentId
 | 
			
		||||
        ) {
 | 
			
		||||
          // change join rule allow parameter when dragging
 | 
			
		||||
          // restricted room from one space to another
 | 
			
		||||
          const joinRuleContent = getStateEvent(
 | 
			
		||||
            itemRoom,
 | 
			
		||||
            StateEvent.RoomJoinRules
 | 
			
		||||
          )?.getContent<RoomJoinRulesEventContent>();
 | 
			
		||||
 | 
			
		||||
          if (joinRuleContent) {
 | 
			
		||||
            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 as any, {
 | 
			
		||||
              ...joinRuleContent,
 | 
			
		||||
              allow,
 | 
			
		||||
            });
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const itemSpaces = Array.from(
 | 
			
		||||
          hierarchy?.find((i) => i.space.roomId === containerParentId)?.rooms ?? []
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        const beforeItem: HierarchyItem | undefined =
 | 
			
		||||
          'space' in containerItem ? undefined : containerItem;
 | 
			
		||||
        const beforeIndex = itemSpaces.findIndex((i) => i.roomId === beforeItem?.roomId);
 | 
			
		||||
        const insertIndex = beforeIndex + 1;
 | 
			
		||||
 | 
			
		||||
        itemSpaces.splice(insertIndex, 0, {
 | 
			
		||||
          ...item,
 | 
			
		||||
          parentId: containerParentId,
 | 
			
		||||
          content: { ...itemContent, order: undefined },
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        const currentOrders = itemSpaces.map((i) => {
 | 
			
		||||
          if (typeof i.content.order === 'string' && lex.has(i.content.order)) {
 | 
			
		||||
            return i.content.order;
 | 
			
		||||
          }
 | 
			
		||||
          return undefined;
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        const newOrders = orderKeys(lex, currentOrders);
 | 
			
		||||
 | 
			
		||||
        const reorders = newOrders
 | 
			
		||||
          ?.map((orderKey, index) => ({
 | 
			
		||||
            item: itemSpaces[index],
 | 
			
		||||
            orderKey,
 | 
			
		||||
          }))
 | 
			
		||||
          .filter((reorder, index) => reorder.item && reorder.orderKey !== currentOrders[index]);
 | 
			
		||||
 | 
			
		||||
        if (reorders) {
 | 
			
		||||
          await rateLimitedActions(reorders, async (reorder) => {
 | 
			
		||||
            await mx.sendStateEvent(
 | 
			
		||||
              containerParentId,
 | 
			
		||||
              StateEvent.SpaceChild as any,
 | 
			
		||||
              { ...reorder.item.content, order: reorder.orderKey },
 | 
			
		||||
              reorder.item.roomId
 | 
			
		||||
            );
 | 
			
		||||
          });
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      [mx, hierarchy, lex]
 | 
			
		||||
    )
 | 
			
		||||
  );
 | 
			
		||||
  const reorderingRoom = reorderRoomState.status === AsyncStatus.Loading;
 | 
			
		||||
  const reordering = reorderingRoom || reorderingSpace;
 | 
			
		||||
 | 
			
		||||
  useDnDMonitor(
 | 
			
		||||
    scrollRef,
 | 
			
		||||
| 
						 | 
				
			
			@ -449,6 +505,7 @@ export function Lobby() {
 | 
			
		|||
                            draggingItem={draggingItem}
 | 
			
		||||
                            onDragging={setDraggingItem}
 | 
			
		||||
                            canDrop={canDrop}
 | 
			
		||||
                            disabledReorder={reordering}
 | 
			
		||||
                            nextSpaceId={nextSpaceId}
 | 
			
		||||
                            getRoom={getRoom}
 | 
			
		||||
                            pinned={sidebarSpaces.has(item.space.roomId)}
 | 
			
		||||
| 
						 | 
				
			
			@ -460,6 +517,28 @@ export function Lobby() {
 | 
			
		|||
                      );
 | 
			
		||||
                    })}
 | 
			
		||||
                  </div>
 | 
			
		||||
                  {reordering && (
 | 
			
		||||
                    <Box
 | 
			
		||||
                      style={{
 | 
			
		||||
                        position: 'absolute',
 | 
			
		||||
                        bottom: config.space.S400,
 | 
			
		||||
                        left: 0,
 | 
			
		||||
                        right: 0,
 | 
			
		||||
                        zIndex: 2,
 | 
			
		||||
                        pointerEvents: 'none',
 | 
			
		||||
                      }}
 | 
			
		||||
                      justifyContent="Center"
 | 
			
		||||
                    >
 | 
			
		||||
                      <Chip
 | 
			
		||||
                        variant="Secondary"
 | 
			
		||||
                        outlined
 | 
			
		||||
                        radii="Pill"
 | 
			
		||||
                        before={<Spinner variant="Secondary" fill="Soft" size="100" />}
 | 
			
		||||
                      >
 | 
			
		||||
                        <Text size="L400">Reordering</Text>
 | 
			
		||||
                      </Chip>
 | 
			
		||||
                    </Box>
 | 
			
		||||
                  )}
 | 
			
		||||
                </PageContentCenter>
 | 
			
		||||
              </PageContent>
 | 
			
		||||
            </Scroll>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -60,8 +60,8 @@ const LobbyMenu = forwardRef<HTMLDivElement, LobbyMenuProps>(
 | 
			
		|||
    };
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
 | 
			
		||||
        <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
 | 
			
		||||
      <Menu ref={ref} style={{ minWidth: toRem(200) }}>
 | 
			
		||||
        <Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
 | 
			
		||||
          <MenuItem
 | 
			
		||||
            onClick={handleInvite}
 | 
			
		||||
            variant="Primary"
 | 
			
		||||
| 
						 | 
				
			
			@ -87,7 +87,7 @@ const LobbyMenu = forwardRef<HTMLDivElement, LobbyMenuProps>(
 | 
			
		|||
          </MenuItem>
 | 
			
		||||
        </Box>
 | 
			
		||||
        <Line variant="Surface" size="300" />
 | 
			
		||||
        <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
 | 
			
		||||
        <Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
 | 
			
		||||
          <UseStateProvider initial={false}>
 | 
			
		||||
            {(promptLeave, setPromptLeave) => (
 | 
			
		||||
              <>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -31,6 +31,7 @@ type SpaceHierarchyProps = {
 | 
			
		|||
  draggingItem?: HierarchyItem;
 | 
			
		||||
  onDragging: (item?: HierarchyItem) => void;
 | 
			
		||||
  canDrop: CanDropCallback;
 | 
			
		||||
  disabledReorder?: boolean;
 | 
			
		||||
  nextSpaceId?: string;
 | 
			
		||||
  getRoom: (roomId: string) => Room | undefined;
 | 
			
		||||
  pinned: boolean;
 | 
			
		||||
| 
						 | 
				
			
			@ -54,6 +55,7 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
 | 
			
		|||
      draggingItem,
 | 
			
		||||
      onDragging,
 | 
			
		||||
      canDrop,
 | 
			
		||||
      disabledReorder,
 | 
			
		||||
      nextSpaceId,
 | 
			
		||||
      getRoom,
 | 
			
		||||
      pinned,
 | 
			
		||||
| 
						 | 
				
			
			@ -116,7 +118,9 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
 | 
			
		|||
          handleClose={handleClose}
 | 
			
		||||
          getRoom={getRoom}
 | 
			
		||||
          canEditChild={canEditSpaceChild(spacePowerLevels)}
 | 
			
		||||
          canReorder={parentPowerLevels ? canEditSpaceChild(parentPowerLevels) : false}
 | 
			
		||||
          canReorder={
 | 
			
		||||
            parentPowerLevels && !disabledReorder ? canEditSpaceChild(parentPowerLevels) : false
 | 
			
		||||
          }
 | 
			
		||||
          options={
 | 
			
		||||
            parentId &&
 | 
			
		||||
            parentPowerLevels && (
 | 
			
		||||
| 
						 | 
				
			
			@ -174,7 +178,7 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
 | 
			
		|||
                  dm={mDirects.has(roomItem.roomId)}
 | 
			
		||||
                  onOpen={onOpenRoom}
 | 
			
		||||
                  getRoom={getRoom}
 | 
			
		||||
                  canReorder={canEditSpaceChild(spacePowerLevels)}
 | 
			
		||||
                  canReorder={canEditSpaceChild(spacePowerLevels) && !disabledReorder}
 | 
			
		||||
                  options={
 | 
			
		||||
                    <HierarchyItemMenu
 | 
			
		||||
                      item={roomItem}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -271,7 +271,7 @@ function AddRoomButton({ item }: { item: HierarchyItem }) {
 | 
			
		|||
            escapeDeactivates: stopPropagation,
 | 
			
		||||
          }}
 | 
			
		||||
        >
 | 
			
		||||
          <Menu style={{ padding: config.space.S100 }}>
 | 
			
		||||
          <Menu style={{ padding: config.space.S200 }}>
 | 
			
		||||
            <MenuItem
 | 
			
		||||
              size="300"
 | 
			
		||||
              radii="300"
 | 
			
		||||
| 
						 | 
				
			
			@ -333,7 +333,7 @@ function AddSpaceButton({ item }: { item: HierarchyItem }) {
 | 
			
		|||
            escapeDeactivates: stopPropagation,
 | 
			
		||||
          }}
 | 
			
		||||
        >
 | 
			
		||||
          <Menu style={{ padding: config.space.S100 }}>
 | 
			
		||||
          <Menu style={{ padding: config.space.S200 }}>
 | 
			
		||||
            <MenuItem
 | 
			
		||||
              size="300"
 | 
			
		||||
              radii="300"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -5,7 +5,7 @@ import { useVirtualizer } from '@tanstack/react-virtual';
 | 
			
		|||
import { useInfiniteQuery } from '@tanstack/react-query';
 | 
			
		||||
import { useSearchParams } from 'react-router-dom';
 | 
			
		||||
import { SearchOrderBy } from 'matrix-js-sdk';
 | 
			
		||||
import { PageHero, PageHeroSection } from '../../components/page';
 | 
			
		||||
import { PageHero, PageHeroEmpty, PageHeroSection } from '../../components/page';
 | 
			
		||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
 | 
			
		||||
import { _SearchPathSearchParams } from '../../pages/paths';
 | 
			
		||||
import { useSetting } from '../../state/hooks/settings';
 | 
			
		||||
| 
						 | 
				
			
			@ -57,6 +57,9 @@ export function MessageSearch({
 | 
			
		|||
  const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
 | 
			
		||||
  const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
 | 
			
		||||
 | 
			
		||||
  const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
 | 
			
		||||
  const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
 | 
			
		||||
 | 
			
		||||
  const searchInputRef = useRef<HTMLInputElement>(null);
 | 
			
		||||
  const scrollTopAnchorRef = useRef<HTMLDivElement>(null);
 | 
			
		||||
  const [searchParams, setSearchParams] = useSearchParams();
 | 
			
		||||
| 
						 | 
				
			
			@ -222,18 +225,7 @@ export function MessageSearch({
 | 
			
		|||
      </Box>
 | 
			
		||||
 | 
			
		||||
      {!msgSearchParams.term && status === 'pending' && (
 | 
			
		||||
        <Box
 | 
			
		||||
          className={ContainerColor({ variant: 'SurfaceVariant' })}
 | 
			
		||||
          style={{
 | 
			
		||||
            padding: config.space.S400,
 | 
			
		||||
            borderRadius: config.radii.R400,
 | 
			
		||||
            minHeight: toRem(450),
 | 
			
		||||
          }}
 | 
			
		||||
          direction="Column"
 | 
			
		||||
          alignItems="Center"
 | 
			
		||||
          justifyContent="Center"
 | 
			
		||||
          gap="200"
 | 
			
		||||
        >
 | 
			
		||||
        <PageHeroEmpty>
 | 
			
		||||
          <PageHeroSection>
 | 
			
		||||
            <PageHero
 | 
			
		||||
              icon={<Icon size="600" src={Icons.Message} />}
 | 
			
		||||
| 
						 | 
				
			
			@ -241,7 +233,7 @@ export function MessageSearch({
 | 
			
		|||
              subTitle="Find helpful messages in your community by searching with related keywords."
 | 
			
		||||
            />
 | 
			
		||||
          </PageHeroSection>
 | 
			
		||||
        </Box>
 | 
			
		||||
        </PageHeroEmpty>
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      {msgSearchParams.term && groups.length === 0 && status === 'success' && (
 | 
			
		||||
| 
						 | 
				
			
			@ -300,6 +292,8 @@ export function MessageSearch({
 | 
			
		|||
                    urlPreview={urlPreview}
 | 
			
		||||
                    onOpen={navigateRoom}
 | 
			
		||||
                    legacyUsernameColor={legacyUsernameColor || mDirects.has(groupRoom.roomId)}
 | 
			
		||||
                    hour24Clock={hour24Clock}
 | 
			
		||||
                    dateFormatString={dateFormatString}
 | 
			
		||||
                  />
 | 
			
		||||
                </VirtualTile>
 | 
			
		||||
              );
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -74,8 +74,7 @@ function OrderButton({ order, onChange }: OrderButtonProps) {
 | 
			
		|||
            <Header size="300" variant="Surface" style={{ padding: `0 ${config.space.S300}` }}>
 | 
			
		||||
              <Text size="L400">Sort by</Text>
 | 
			
		||||
            </Header>
 | 
			
		||||
            <Line variant="Surface" size="300" />
 | 
			
		||||
            <div style={{ padding: config.space.S100 }}>
 | 
			
		||||
            <div style={{ padding: config.space.S200, paddingTop: 0 }}>
 | 
			
		||||
              <MenuItem
 | 
			
		||||
                onClick={() => setOrder()}
 | 
			
		||||
                variant="Surface"
 | 
			
		||||
| 
						 | 
				
			
			@ -291,7 +290,7 @@ function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButto
 | 
			
		|||
                </Box>
 | 
			
		||||
              </Scroll>
 | 
			
		||||
              <Line variant="Surface" size="300" />
 | 
			
		||||
              <Box shrink="No" direction="Column" gap="100" style={{ padding: config.space.S200 }}>
 | 
			
		||||
              <Box shrink="No" direction="Column" gap="200" style={{ padding: config.space.S200 }}>
 | 
			
		||||
                <Button size="300" variant="Secondary" radii="300" onClick={handleSave}>
 | 
			
		||||
                  {localSelected && localSelected.length > 0 ? (
 | 
			
		||||
                    <Text size="B300">Save ({localSelected.length})</Text>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -29,6 +29,7 @@ export function SearchInput({ active, loading, searchInputRef, onSearch, onReset
 | 
			
		|||
        ref={searchInputRef}
 | 
			
		||||
        style={{ paddingRight: config.space.S300 }}
 | 
			
		||||
        name="searchInput"
 | 
			
		||||
        autoFocus
 | 
			
		||||
        size="500"
 | 
			
		||||
        variant="Background"
 | 
			
		||||
        placeholder="Search for keyword"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -57,6 +57,8 @@ type SearchResultGroupProps = {
 | 
			
		|||
  urlPreview?: boolean;
 | 
			
		||||
  onOpen: (roomId: string, eventId: string) => void;
 | 
			
		||||
  legacyUsernameColor?: boolean;
 | 
			
		||||
  hour24Clock: boolean;
 | 
			
		||||
  dateFormatString: string;
 | 
			
		||||
};
 | 
			
		||||
export function SearchResultGroup({
 | 
			
		||||
  room,
 | 
			
		||||
| 
						 | 
				
			
			@ -66,6 +68,8 @@ export function SearchResultGroup({
 | 
			
		|||
  urlPreview,
 | 
			
		||||
  onOpen,
 | 
			
		||||
  legacyUsernameColor,
 | 
			
		||||
  hour24Clock,
 | 
			
		||||
  dateFormatString,
 | 
			
		||||
}: SearchResultGroupProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const useAuthentication = useMediaAuthentication();
 | 
			
		||||
| 
						 | 
				
			
			@ -275,7 +279,11 @@ export function SearchResultGroup({
 | 
			
		|||
                      </Username>
 | 
			
		||||
                      {tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
 | 
			
		||||
                    </Box>
 | 
			
		||||
                    <Time ts={event.origin_server_ts} />
 | 
			
		||||
                    <Time
 | 
			
		||||
                      ts={event.origin_server_ts}
 | 
			
		||||
                      hour24Clock={hour24Clock}
 | 
			
		||||
                      dateFormatString={dateFormatString}
 | 
			
		||||
                    />
 | 
			
		||||
                  </Box>
 | 
			
		||||
                  <Box shrink="No" gap="200" alignItems="Center">
 | 
			
		||||
                    <Chip
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -89,8 +89,8 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
 | 
			
		|||
    };
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
 | 
			
		||||
        <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
 | 
			
		||||
      <Menu ref={ref} style={{ minWidth: toRem(200) }}>
 | 
			
		||||
        <Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
 | 
			
		||||
          <MenuItem
 | 
			
		||||
            onClick={handleMarkAsRead}
 | 
			
		||||
            size="300"
 | 
			
		||||
| 
						 | 
				
			
			@ -125,7 +125,7 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
 | 
			
		|||
          </RoomNotificationModeSwitcher>
 | 
			
		||||
        </Box>
 | 
			
		||||
        <Line variant="Surface" size="300" />
 | 
			
		||||
        <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
 | 
			
		||||
        <Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
 | 
			
		||||
          <MenuItem
 | 
			
		||||
            onClick={handleInvite}
 | 
			
		||||
            variant="Primary"
 | 
			
		||||
| 
						 | 
				
			
			@ -161,7 +161,7 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
 | 
			
		|||
          </MenuItem>
 | 
			
		||||
        </Box>
 | 
			
		||||
        <Line variant="Surface" size="300" />
 | 
			
		||||
        <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
 | 
			
		||||
        <Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
 | 
			
		||||
          <UseStateProvider initial={false}>
 | 
			
		||||
            {(promptLeave, setPromptLeave) => (
 | 
			
		||||
              <>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,6 +1,6 @@
 | 
			
		|||
import React, { KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo } from 'react';
 | 
			
		||||
import { Editor } from 'slate';
 | 
			
		||||
import { Box, MenuItem, Text } from 'folds';
 | 
			
		||||
import { Box, config, MenuItem, Text } from 'folds';
 | 
			
		||||
import { Room } from 'matrix-js-sdk';
 | 
			
		||||
import { Command, useCommands } from '../../hooks/useCommands';
 | 
			
		||||
import {
 | 
			
		||||
| 
						 | 
				
			
			@ -75,9 +75,6 @@ export function CommandAutocomplete({
 | 
			
		|||
      headerContent={
 | 
			
		||||
        <Box grow="Yes" direction="Row" gap="200" justifyContent="SpaceBetween">
 | 
			
		||||
          <Text size="L400">Commands</Text>
 | 
			
		||||
          <Text size="T200" priority="300" truncate>
 | 
			
		||||
            Begin your message with command
 | 
			
		||||
          </Text>
 | 
			
		||||
        </Box>
 | 
			
		||||
      }
 | 
			
		||||
      requestClose={requestClose}
 | 
			
		||||
| 
						 | 
				
			
			@ -87,17 +84,22 @@ export function CommandAutocomplete({
 | 
			
		|||
          key={commandName}
 | 
			
		||||
          as="button"
 | 
			
		||||
          radii="300"
 | 
			
		||||
          style={{ height: 'unset' }}
 | 
			
		||||
          onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) =>
 | 
			
		||||
            onTabPress(evt, () => handleAutocomplete(commandName))
 | 
			
		||||
          }
 | 
			
		||||
          onClick={() => handleAutocomplete(commandName)}
 | 
			
		||||
        >
 | 
			
		||||
          <Box grow="Yes" direction="Row" gap="200" justifyContent="SpaceBetween">
 | 
			
		||||
            <Box shrink="No">
 | 
			
		||||
              <Text style={{ flexGrow: 1 }} size="B400" truncate>
 | 
			
		||||
                {`/${commandName}`}
 | 
			
		||||
              </Text>
 | 
			
		||||
            </Box>
 | 
			
		||||
          <Box
 | 
			
		||||
            style={{ padding: `${config.space.S300} 0` }}
 | 
			
		||||
            grow="Yes"
 | 
			
		||||
            direction="Column"
 | 
			
		||||
            gap="100"
 | 
			
		||||
            justifyContent="SpaceBetween"
 | 
			
		||||
          >
 | 
			
		||||
            <Text style={{ flexGrow: 1 }} size="B400" truncate>
 | 
			
		||||
              {`/${commandName}`}
 | 
			
		||||
            </Text>
 | 
			
		||||
            <Text truncate priority="300" size="T200">
 | 
			
		||||
              {commands[commandName].description}
 | 
			
		||||
            </Text>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -543,7 +543,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
 | 
			
		|||
                  >
 | 
			
		||||
                    <Icon src={Icons.Cross} size="50" />
 | 
			
		||||
                  </IconButton>
 | 
			
		||||
                  <Box direction="Column">
 | 
			
		||||
                  <Box direction="Row" gap="200" alignItems="Center">
 | 
			
		||||
                    {replyDraft.relation?.rel_type === RelationType.Thread && <ThreadIndicator />}
 | 
			
		||||
                    <ReplyLayout
 | 
			
		||||
                      userColor={replyUsernameColor}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -448,6 +448,10 @@ export function RoomTimeline({
 | 
			
		|||
  const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
 | 
			
		||||
  const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
 | 
			
		||||
  const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
 | 
			
		||||
  const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
 | 
			
		||||
 | 
			
		||||
  const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
 | 
			
		||||
  const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
 | 
			
		||||
 | 
			
		||||
  const ignoredUsersList = useIgnoredUsers();
 | 
			
		||||
  const ignoredUsersSet = useMemo(() => new Set(ignoredUsersList), [ignoredUsersList]);
 | 
			
		||||
| 
						 | 
				
			
			@ -932,7 +936,7 @@ export function RoomTimeline({
 | 
			
		|||
  );
 | 
			
		||||
 | 
			
		||||
  const handleReplyClick: MouseEventHandler<HTMLButtonElement> = useCallback(
 | 
			
		||||
    (evt) => {
 | 
			
		||||
    (evt, startThread = false) => {
 | 
			
		||||
      const replyId = evt.currentTarget.getAttribute('data-event-id');
 | 
			
		||||
      if (!replyId) {
 | 
			
		||||
        console.warn('Button should have "data-event-id" attribute!');
 | 
			
		||||
| 
						 | 
				
			
			@ -943,7 +947,9 @@ export function RoomTimeline({
 | 
			
		|||
      const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet());
 | 
			
		||||
      const content: IContent = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
 | 
			
		||||
      const { body, formatted_body: formattedBody } = content;
 | 
			
		||||
      const { 'm.relates_to': relation } = replyEvt.getWireContent();
 | 
			
		||||
      const { 'm.relates_to': relation } = startThread
 | 
			
		||||
        ? { 'm.relates_to': { rel_type: 'm.thread', event_id: replyId } }
 | 
			
		||||
        : replyEvt.getWireContent();
 | 
			
		||||
      const senderId = replyEvt.getSender();
 | 
			
		||||
      if (senderId && typeof body === 'string') {
 | 
			
		||||
        setReplyDraft({
 | 
			
		||||
| 
						 | 
				
			
			@ -1065,9 +1071,12 @@ export function RoomTimeline({
 | 
			
		|||
              )
 | 
			
		||||
            }
 | 
			
		||||
            hideReadReceipts={hideActivity}
 | 
			
		||||
            showDeveloperTools={showDeveloperTools}
 | 
			
		||||
            powerLevelTag={getPowerLevelTag(senderPowerLevel)}
 | 
			
		||||
            accessibleTagColors={accessibleTagColors}
 | 
			
		||||
            legacyUsernameColor={legacyUsernameColor || direct}
 | 
			
		||||
            hour24Clock={hour24Clock}
 | 
			
		||||
            dateFormatString={dateFormatString}
 | 
			
		||||
          >
 | 
			
		||||
            {mEvent.isRedacted() ? (
 | 
			
		||||
              <RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
 | 
			
		||||
| 
						 | 
				
			
			@ -1146,9 +1155,12 @@ export function RoomTimeline({
 | 
			
		|||
              )
 | 
			
		||||
            }
 | 
			
		||||
            hideReadReceipts={hideActivity}
 | 
			
		||||
            showDeveloperTools={showDeveloperTools}
 | 
			
		||||
            powerLevelTag={getPowerLevelTag(senderPowerLevel)}
 | 
			
		||||
            accessibleTagColors={accessibleTagColors}
 | 
			
		||||
            legacyUsernameColor={legacyUsernameColor || direct}
 | 
			
		||||
            hour24Clock={hour24Clock}
 | 
			
		||||
            dateFormatString={dateFormatString}
 | 
			
		||||
          >
 | 
			
		||||
            <EncryptedContent mEvent={mEvent}>
 | 
			
		||||
              {() => {
 | 
			
		||||
| 
						 | 
				
			
			@ -1247,9 +1259,12 @@ export function RoomTimeline({
 | 
			
		|||
              )
 | 
			
		||||
            }
 | 
			
		||||
            hideReadReceipts={hideActivity}
 | 
			
		||||
            showDeveloperTools={showDeveloperTools}
 | 
			
		||||
            powerLevelTag={getPowerLevelTag(senderPowerLevel)}
 | 
			
		||||
            accessibleTagColors={accessibleTagColors}
 | 
			
		||||
            legacyUsernameColor={legacyUsernameColor || direct}
 | 
			
		||||
            hour24Clock={hour24Clock}
 | 
			
		||||
            dateFormatString={dateFormatString}
 | 
			
		||||
          >
 | 
			
		||||
            {mEvent.isRedacted() ? (
 | 
			
		||||
              <RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
 | 
			
		||||
| 
						 | 
				
			
			@ -1278,7 +1293,12 @@ export function RoomTimeline({
 | 
			
		|||
        const parsed = parseMemberEvent(mEvent);
 | 
			
		||||
 | 
			
		||||
        const timeJSX = (
 | 
			
		||||
          <Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
 | 
			
		||||
          <Time
 | 
			
		||||
            ts={mEvent.getTs()}
 | 
			
		||||
            compact={messageLayout === MessageLayout.Compact}
 | 
			
		||||
            hour24Clock={hour24Clock}
 | 
			
		||||
            dateFormatString={dateFormatString}
 | 
			
		||||
          />
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        return (
 | 
			
		||||
| 
						 | 
				
			
			@ -1292,6 +1312,7 @@ export function RoomTimeline({
 | 
			
		|||
            messageSpacing={messageSpacing}
 | 
			
		||||
            canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
 | 
			
		||||
            hideReadReceipts={hideActivity}
 | 
			
		||||
            showDeveloperTools={showDeveloperTools}
 | 
			
		||||
          >
 | 
			
		||||
            <EventContent
 | 
			
		||||
              messageLayout={messageLayout}
 | 
			
		||||
| 
						 | 
				
			
			@ -1314,7 +1335,12 @@ export function RoomTimeline({
 | 
			
		|||
        const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
 | 
			
		||||
 | 
			
		||||
        const timeJSX = (
 | 
			
		||||
          <Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
 | 
			
		||||
          <Time
 | 
			
		||||
            ts={mEvent.getTs()}
 | 
			
		||||
            compact={messageLayout === MessageLayout.Compact}
 | 
			
		||||
            hour24Clock={hour24Clock}
 | 
			
		||||
            dateFormatString={dateFormatString}
 | 
			
		||||
          />
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        return (
 | 
			
		||||
| 
						 | 
				
			
			@ -1328,6 +1354,7 @@ export function RoomTimeline({
 | 
			
		|||
            messageSpacing={messageSpacing}
 | 
			
		||||
            canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
 | 
			
		||||
            hideReadReceipts={hideActivity}
 | 
			
		||||
            showDeveloperTools={showDeveloperTools}
 | 
			
		||||
          >
 | 
			
		||||
            <EventContent
 | 
			
		||||
              messageLayout={messageLayout}
 | 
			
		||||
| 
						 | 
				
			
			@ -1351,7 +1378,12 @@ export function RoomTimeline({
 | 
			
		|||
        const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
 | 
			
		||||
 | 
			
		||||
        const timeJSX = (
 | 
			
		||||
          <Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
 | 
			
		||||
          <Time
 | 
			
		||||
            ts={mEvent.getTs()}
 | 
			
		||||
            compact={messageLayout === MessageLayout.Compact}
 | 
			
		||||
            hour24Clock={hour24Clock}
 | 
			
		||||
            dateFormatString={dateFormatString}
 | 
			
		||||
          />
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        return (
 | 
			
		||||
| 
						 | 
				
			
			@ -1365,6 +1397,7 @@ export function RoomTimeline({
 | 
			
		|||
            messageSpacing={messageSpacing}
 | 
			
		||||
            canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
 | 
			
		||||
            hideReadReceipts={hideActivity}
 | 
			
		||||
            showDeveloperTools={showDeveloperTools}
 | 
			
		||||
          >
 | 
			
		||||
            <EventContent
 | 
			
		||||
              messageLayout={messageLayout}
 | 
			
		||||
| 
						 | 
				
			
			@ -1388,7 +1421,12 @@ export function RoomTimeline({
 | 
			
		|||
        const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
 | 
			
		||||
 | 
			
		||||
        const timeJSX = (
 | 
			
		||||
          <Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
 | 
			
		||||
          <Time
 | 
			
		||||
            ts={mEvent.getTs()}
 | 
			
		||||
            compact={messageLayout === MessageLayout.Compact}
 | 
			
		||||
            hour24Clock={hour24Clock}
 | 
			
		||||
            dateFormatString={dateFormatString}
 | 
			
		||||
          />
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        return (
 | 
			
		||||
| 
						 | 
				
			
			@ -1402,6 +1440,7 @@ export function RoomTimeline({
 | 
			
		|||
            messageSpacing={messageSpacing}
 | 
			
		||||
            canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
 | 
			
		||||
            hideReadReceipts={hideActivity}
 | 
			
		||||
            showDeveloperTools={showDeveloperTools}
 | 
			
		||||
          >
 | 
			
		||||
            <EventContent
 | 
			
		||||
              messageLayout={messageLayout}
 | 
			
		||||
| 
						 | 
				
			
			@ -1427,7 +1466,12 @@ export function RoomTimeline({
 | 
			
		|||
      const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
 | 
			
		||||
 | 
			
		||||
      const timeJSX = (
 | 
			
		||||
        <Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
 | 
			
		||||
        <Time
 | 
			
		||||
          ts={mEvent.getTs()}
 | 
			
		||||
          compact={messageLayout === MessageLayout.Compact}
 | 
			
		||||
          hour24Clock={hour24Clock}
 | 
			
		||||
          dateFormatString={dateFormatString}
 | 
			
		||||
        />
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      return (
 | 
			
		||||
| 
						 | 
				
			
			@ -1441,6 +1485,7 @@ export function RoomTimeline({
 | 
			
		|||
          messageSpacing={messageSpacing}
 | 
			
		||||
          canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
 | 
			
		||||
          hideReadReceipts={hideActivity}
 | 
			
		||||
          showDeveloperTools={showDeveloperTools}
 | 
			
		||||
        >
 | 
			
		||||
          <EventContent
 | 
			
		||||
            messageLayout={messageLayout}
 | 
			
		||||
| 
						 | 
				
			
			@ -1471,7 +1516,12 @@ export function RoomTimeline({
 | 
			
		|||
      const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
 | 
			
		||||
 | 
			
		||||
      const timeJSX = (
 | 
			
		||||
        <Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
 | 
			
		||||
        <Time
 | 
			
		||||
          ts={mEvent.getTs()}
 | 
			
		||||
          compact={messageLayout === MessageLayout.Compact}
 | 
			
		||||
          hour24Clock={hour24Clock}
 | 
			
		||||
          dateFormatString={dateFormatString}
 | 
			
		||||
        />
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      return (
 | 
			
		||||
| 
						 | 
				
			
			@ -1485,6 +1535,7 @@ export function RoomTimeline({
 | 
			
		|||
          messageSpacing={messageSpacing}
 | 
			
		||||
          canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
 | 
			
		||||
          hideReadReceipts={hideActivity}
 | 
			
		||||
          showDeveloperTools={showDeveloperTools}
 | 
			
		||||
        >
 | 
			
		||||
          <EventContent
 | 
			
		||||
            messageLayout={messageLayout}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -65,6 +65,8 @@ import {
 | 
			
		|||
  getRoomNotificationModeIcon,
 | 
			
		||||
  useRoomsNotificationPreferencesContext,
 | 
			
		||||
} from '../../hooks/useRoomsNotificationPreferences';
 | 
			
		||||
import { JumpToTime } from './jump-to-time';
 | 
			
		||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
 | 
			
		||||
 | 
			
		||||
type RoomMenuProps = {
 | 
			
		||||
  room: Room;
 | 
			
		||||
| 
						 | 
				
			
			@ -79,6 +81,7 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
 | 
			
		|||
  const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? ''));
 | 
			
		||||
  const notificationPreferences = useRoomsNotificationPreferencesContext();
 | 
			
		||||
  const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId);
 | 
			
		||||
  const { navigateRoom } = useRoomNavigate();
 | 
			
		||||
 | 
			
		||||
  const handleMarkAsRead = () => {
 | 
			
		||||
    markAsRead(mx, room.roomId, hideActivity);
 | 
			
		||||
| 
						 | 
				
			
			@ -105,8 +108,8 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
 | 
			
		|||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
 | 
			
		||||
      <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
 | 
			
		||||
    <Menu ref={ref} style={{ minWidth: toRem(200) }}>
 | 
			
		||||
      <Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
 | 
			
		||||
        <MenuItem
 | 
			
		||||
          onClick={handleMarkAsRead}
 | 
			
		||||
          size="300"
 | 
			
		||||
| 
						 | 
				
			
			@ -141,7 +144,7 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
 | 
			
		|||
        </RoomNotificationModeSwitcher>
 | 
			
		||||
      </Box>
 | 
			
		||||
      <Line variant="Surface" size="300" />
 | 
			
		||||
      <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
 | 
			
		||||
      <Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
 | 
			
		||||
        <MenuItem
 | 
			
		||||
          onClick={handleInvite}
 | 
			
		||||
          variant="Primary"
 | 
			
		||||
| 
						 | 
				
			
			@ -175,9 +178,36 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
 | 
			
		|||
            Room Settings
 | 
			
		||||
          </Text>
 | 
			
		||||
        </MenuItem>
 | 
			
		||||
        <UseStateProvider initial={false}>
 | 
			
		||||
          {(promptJump, setPromptJump) => (
 | 
			
		||||
            <>
 | 
			
		||||
              <MenuItem
 | 
			
		||||
                onClick={() => setPromptJump(true)}
 | 
			
		||||
                size="300"
 | 
			
		||||
                after={<Icon size="100" src={Icons.RecentClock} />}
 | 
			
		||||
                radii="300"
 | 
			
		||||
                aria-pressed={promptJump}
 | 
			
		||||
              >
 | 
			
		||||
                <Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
 | 
			
		||||
                  Jump to Time
 | 
			
		||||
                </Text>
 | 
			
		||||
              </MenuItem>
 | 
			
		||||
              {promptJump && (
 | 
			
		||||
                <JumpToTime
 | 
			
		||||
                  onSubmit={(eventId) => {
 | 
			
		||||
                    setPromptJump(false);
 | 
			
		||||
                    navigateRoom(room.roomId, eventId);
 | 
			
		||||
                    requestClose();
 | 
			
		||||
                  }}
 | 
			
		||||
                  onCancel={() => setPromptJump(false)}
 | 
			
		||||
                />
 | 
			
		||||
              )}
 | 
			
		||||
            </>
 | 
			
		||||
          )}
 | 
			
		||||
        </UseStateProvider>
 | 
			
		||||
      </Box>
 | 
			
		||||
      <Line variant="Surface" size="300" />
 | 
			
		||||
      <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
 | 
			
		||||
      <Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
 | 
			
		||||
        <UseStateProvider initial={false}>
 | 
			
		||||
          {(promptLeave, setPromptLeave) => (
 | 
			
		||||
            <>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										260
									
								
								src/app/features/room/jump-to-time/JumpToTime.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										260
									
								
								src/app/features/room/jump-to-time/JumpToTime.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,260 @@
 | 
			
		|||
import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react';
 | 
			
		||||
import FocusTrap from 'focus-trap-react';
 | 
			
		||||
import {
 | 
			
		||||
  Dialog,
 | 
			
		||||
  Overlay,
 | 
			
		||||
  OverlayCenter,
 | 
			
		||||
  OverlayBackdrop,
 | 
			
		||||
  Header,
 | 
			
		||||
  config,
 | 
			
		||||
  Box,
 | 
			
		||||
  Text,
 | 
			
		||||
  IconButton,
 | 
			
		||||
  Icon,
 | 
			
		||||
  Icons,
 | 
			
		||||
  color,
 | 
			
		||||
  Button,
 | 
			
		||||
  Spinner,
 | 
			
		||||
  Chip,
 | 
			
		||||
  PopOut,
 | 
			
		||||
  RectCords,
 | 
			
		||||
} from 'folds';
 | 
			
		||||
import { Direction, MatrixError } from 'matrix-js-sdk';
 | 
			
		||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
			
		||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
 | 
			
		||||
import { stopPropagation } from '../../../utils/keyboard';
 | 
			
		||||
import { useAlive } from '../../../hooks/useAlive';
 | 
			
		||||
import { useStateEvent } from '../../../hooks/useStateEvent';
 | 
			
		||||
import { useRoom } from '../../../hooks/useRoom';
 | 
			
		||||
import { StateEvent } from '../../../../types/matrix/room';
 | 
			
		||||
import { getToday, getYesterday, timeDayMonthYear, timeHourMinute } from '../../../utils/time';
 | 
			
		||||
import { DatePicker, TimePicker } from '../../../components/time-date';
 | 
			
		||||
import { useSetting } from '../../../state/hooks/settings';
 | 
			
		||||
import { settingsAtom } from '../../../state/settings';
 | 
			
		||||
 | 
			
		||||
type JumpToTimeProps = {
 | 
			
		||||
  onCancel: () => void;
 | 
			
		||||
  onSubmit: (eventId: string) => void;
 | 
			
		||||
};
 | 
			
		||||
export function JumpToTime({ onCancel, onSubmit }: JumpToTimeProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const room = useRoom();
 | 
			
		||||
  const alive = useAlive();
 | 
			
		||||
  const createStateEvent = useStateEvent(room, StateEvent.RoomCreate);
 | 
			
		||||
 | 
			
		||||
  const todayTs = getToday();
 | 
			
		||||
  const yesterdayTs = getYesterday();
 | 
			
		||||
  const createTs = useMemo(() => createStateEvent?.getTs() ?? 0, [createStateEvent]);
 | 
			
		||||
  const [ts, setTs] = useState(() => Date.now());
 | 
			
		||||
 | 
			
		||||
  const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
 | 
			
		||||
 | 
			
		||||
  const [timePickerCords, setTimePickerCords] = useState<RectCords>();
 | 
			
		||||
  const [datePickerCords, setDatePickerCords] = useState<RectCords>();
 | 
			
		||||
 | 
			
		||||
  const handleTimePicker: MouseEventHandler<HTMLButtonElement> = (evt) => {
 | 
			
		||||
    setTimePickerCords(evt.currentTarget.getBoundingClientRect());
 | 
			
		||||
  };
 | 
			
		||||
  const handleDatePicker: MouseEventHandler<HTMLButtonElement> = (evt) => {
 | 
			
		||||
    setDatePickerCords(evt.currentTarget.getBoundingClientRect());
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleToday = () => {
 | 
			
		||||
    setTs(todayTs < createTs ? createTs : todayTs);
 | 
			
		||||
  };
 | 
			
		||||
  const handleYesterday = () => {
 | 
			
		||||
    setTs(yesterdayTs < createTs ? createTs : yesterdayTs);
 | 
			
		||||
  };
 | 
			
		||||
  const handleBeginning = () => setTs(createTs);
 | 
			
		||||
 | 
			
		||||
  const [timestampState, timestampToEvent] = useAsyncCallback<string, MatrixError, [number]>(
 | 
			
		||||
    useCallback(
 | 
			
		||||
      async (newTs) => {
 | 
			
		||||
        const result = await mx.timestampToEvent(room.roomId, newTs, Direction.Forward);
 | 
			
		||||
        return result.event_id;
 | 
			
		||||
      },
 | 
			
		||||
      [mx, room]
 | 
			
		||||
    )
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const handleSubmit = () => {
 | 
			
		||||
    timestampToEvent(ts).then((eventId) => {
 | 
			
		||||
      if (alive()) {
 | 
			
		||||
        onSubmit(eventId);
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Overlay open backdrop={<OverlayBackdrop />}>
 | 
			
		||||
      <OverlayCenter>
 | 
			
		||||
        <FocusTrap
 | 
			
		||||
          focusTrapOptions={{
 | 
			
		||||
            initialFocus: false,
 | 
			
		||||
            onDeactivate: onCancel,
 | 
			
		||||
            clickOutsideDeactivates: true,
 | 
			
		||||
            escapeDeactivates: stopPropagation,
 | 
			
		||||
          }}
 | 
			
		||||
        >
 | 
			
		||||
          <Dialog variant="Surface">
 | 
			
		||||
            <Header
 | 
			
		||||
              style={{
 | 
			
		||||
                padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
 | 
			
		||||
                borderBottomWidth: config.borderWidth.B300,
 | 
			
		||||
              }}
 | 
			
		||||
              variant="Surface"
 | 
			
		||||
              size="500"
 | 
			
		||||
            >
 | 
			
		||||
              <Box grow="Yes">
 | 
			
		||||
                <Text size="H4">Jump to Time</Text>
 | 
			
		||||
              </Box>
 | 
			
		||||
              <IconButton size="300" onClick={onCancel} radii="300">
 | 
			
		||||
                <Icon src={Icons.Cross} />
 | 
			
		||||
              </IconButton>
 | 
			
		||||
            </Header>
 | 
			
		||||
            <Box style={{ padding: config.space.S400 }} direction="Column" gap="500">
 | 
			
		||||
              <Box direction="Row" gap="300">
 | 
			
		||||
                <Box direction="Column" gap="100">
 | 
			
		||||
                  <Text size="L400" priority="400">
 | 
			
		||||
                    Time
 | 
			
		||||
                  </Text>
 | 
			
		||||
                  <Box gap="100" alignItems="Center">
 | 
			
		||||
                    <Chip
 | 
			
		||||
                      size="500"
 | 
			
		||||
                      variant="Surface"
 | 
			
		||||
                      fill="None"
 | 
			
		||||
                      outlined
 | 
			
		||||
                      radii="300"
 | 
			
		||||
                      aria-pressed={!!timePickerCords}
 | 
			
		||||
                      after={<Icon size="50" src={Icons.ChevronBottom} />}
 | 
			
		||||
                      onClick={handleTimePicker}
 | 
			
		||||
                    >
 | 
			
		||||
                      <Text size="B300">{timeHourMinute(ts, hour24Clock)}</Text>
 | 
			
		||||
                    </Chip>
 | 
			
		||||
                    <PopOut
 | 
			
		||||
                      anchor={timePickerCords}
 | 
			
		||||
                      offset={5}
 | 
			
		||||
                      position="Bottom"
 | 
			
		||||
                      align="Center"
 | 
			
		||||
                      content={
 | 
			
		||||
                        <FocusTrap
 | 
			
		||||
                          focusTrapOptions={{
 | 
			
		||||
                            initialFocus: false,
 | 
			
		||||
                            onDeactivate: () => setTimePickerCords(undefined),
 | 
			
		||||
                            clickOutsideDeactivates: true,
 | 
			
		||||
                            isKeyForward: (evt: KeyboardEvent) =>
 | 
			
		||||
                              evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
 | 
			
		||||
                            isKeyBackward: (evt: KeyboardEvent) =>
 | 
			
		||||
                              evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
 | 
			
		||||
                            escapeDeactivates: stopPropagation,
 | 
			
		||||
                          }}
 | 
			
		||||
                        >
 | 
			
		||||
                          <TimePicker min={createTs} max={Date.now()} value={ts} onChange={setTs} />
 | 
			
		||||
                        </FocusTrap>
 | 
			
		||||
                      }
 | 
			
		||||
                    />
 | 
			
		||||
                  </Box>
 | 
			
		||||
                </Box>
 | 
			
		||||
                <Box direction="Column" gap="100">
 | 
			
		||||
                  <Text size="L400" priority="400">
 | 
			
		||||
                    Date
 | 
			
		||||
                  </Text>
 | 
			
		||||
                  <Box gap="100" alignItems="Center">
 | 
			
		||||
                    <Chip
 | 
			
		||||
                      size="500"
 | 
			
		||||
                      variant="Surface"
 | 
			
		||||
                      fill="None"
 | 
			
		||||
                      outlined
 | 
			
		||||
                      radii="300"
 | 
			
		||||
                      aria-pressed={!!datePickerCords}
 | 
			
		||||
                      after={<Icon size="50" src={Icons.ChevronBottom} />}
 | 
			
		||||
                      onClick={handleDatePicker}
 | 
			
		||||
                    >
 | 
			
		||||
                      <Text size="B300">{timeDayMonthYear(ts)}</Text>
 | 
			
		||||
                    </Chip>
 | 
			
		||||
                    <PopOut
 | 
			
		||||
                      anchor={datePickerCords}
 | 
			
		||||
                      offset={5}
 | 
			
		||||
                      position="Bottom"
 | 
			
		||||
                      align="Center"
 | 
			
		||||
                      content={
 | 
			
		||||
                        <FocusTrap
 | 
			
		||||
                          focusTrapOptions={{
 | 
			
		||||
                            initialFocus: false,
 | 
			
		||||
                            onDeactivate: () => setDatePickerCords(undefined),
 | 
			
		||||
                            clickOutsideDeactivates: true,
 | 
			
		||||
                            isKeyForward: (evt: KeyboardEvent) =>
 | 
			
		||||
                              evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
 | 
			
		||||
                            isKeyBackward: (evt: KeyboardEvent) =>
 | 
			
		||||
                              evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
 | 
			
		||||
                            escapeDeactivates: stopPropagation,
 | 
			
		||||
                          }}
 | 
			
		||||
                        >
 | 
			
		||||
                          <DatePicker min={createTs} max={Date.now()} value={ts} onChange={setTs} />
 | 
			
		||||
                        </FocusTrap>
 | 
			
		||||
                      }
 | 
			
		||||
                    />
 | 
			
		||||
                  </Box>
 | 
			
		||||
                </Box>
 | 
			
		||||
              </Box>
 | 
			
		||||
              <Box direction="Column" gap="100">
 | 
			
		||||
                <Text size="L400">Preset</Text>
 | 
			
		||||
                <Box gap="200">
 | 
			
		||||
                  {createTs < todayTs && (
 | 
			
		||||
                    <Chip
 | 
			
		||||
                      variant={ts === todayTs ? 'Success' : 'SurfaceVariant'}
 | 
			
		||||
                      radii="Pill"
 | 
			
		||||
                      aria-pressed={ts === todayTs}
 | 
			
		||||
                      onClick={handleToday}
 | 
			
		||||
                    >
 | 
			
		||||
                      <Text size="B300">Today</Text>
 | 
			
		||||
                    </Chip>
 | 
			
		||||
                  )}
 | 
			
		||||
                  {createTs < yesterdayTs && (
 | 
			
		||||
                    <Chip
 | 
			
		||||
                      variant={ts === yesterdayTs ? 'Success' : 'SurfaceVariant'}
 | 
			
		||||
                      radii="Pill"
 | 
			
		||||
                      aria-pressed={ts === yesterdayTs}
 | 
			
		||||
                      onClick={handleYesterday}
 | 
			
		||||
                    >
 | 
			
		||||
                      <Text size="B300">Yesterday</Text>
 | 
			
		||||
                    </Chip>
 | 
			
		||||
                  )}
 | 
			
		||||
                  <Chip
 | 
			
		||||
                    variant={ts === createTs ? 'Success' : 'SurfaceVariant'}
 | 
			
		||||
                    radii="Pill"
 | 
			
		||||
                    aria-pressed={ts === createTs}
 | 
			
		||||
                    onClick={handleBeginning}
 | 
			
		||||
                  >
 | 
			
		||||
                    <Text size="B300">Beginning</Text>
 | 
			
		||||
                  </Chip>
 | 
			
		||||
                </Box>
 | 
			
		||||
              </Box>
 | 
			
		||||
              {timestampState.status === AsyncStatus.Error && (
 | 
			
		||||
                <Text style={{ color: color.Critical.Main }} size="T300">
 | 
			
		||||
                  {timestampState.error.message}
 | 
			
		||||
                </Text>
 | 
			
		||||
              )}
 | 
			
		||||
              <Button
 | 
			
		||||
                type="submit"
 | 
			
		||||
                variant="Primary"
 | 
			
		||||
                before={
 | 
			
		||||
                  timestampState.status === AsyncStatus.Loading ? (
 | 
			
		||||
                    <Spinner fill="Solid" variant="Primary" size="200" />
 | 
			
		||||
                  ) : undefined
 | 
			
		||||
                }
 | 
			
		||||
                aria-disabled={
 | 
			
		||||
                  timestampState.status === AsyncStatus.Loading ||
 | 
			
		||||
                  timestampState.status === AsyncStatus.Success
 | 
			
		||||
                }
 | 
			
		||||
                onClick={handleSubmit}
 | 
			
		||||
              >
 | 
			
		||||
                <Text size="B400">Open Timeline</Text>
 | 
			
		||||
              </Button>
 | 
			
		||||
            </Box>
 | 
			
		||||
          </Dialog>
 | 
			
		||||
        </FocusTrap>
 | 
			
		||||
      </OverlayCenter>
 | 
			
		||||
    </Overlay>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										1
									
								
								src/app/features/room/jump-to-time/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/app/features/room/jump-to-time/index.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
export * from './JumpToTime';
 | 
			
		||||
| 
						 | 
				
			
			@ -22,6 +22,7 @@ import {
 | 
			
		|||
  as,
 | 
			
		||||
  color,
 | 
			
		||||
  config,
 | 
			
		||||
  toRem,
 | 
			
		||||
} from 'folds';
 | 
			
		||||
import React, {
 | 
			
		||||
  FormEventHandler,
 | 
			
		||||
| 
						 | 
				
			
			@ -94,10 +95,10 @@ export const MessageQuickReactions = as<'div', MessageQuickReactionsProps>(
 | 
			
		|||
    return (
 | 
			
		||||
      <>
 | 
			
		||||
        <Box
 | 
			
		||||
          style={{ padding: config.space.S200 }}
 | 
			
		||||
          style={{ padding: config.space.S300 }}
 | 
			
		||||
          alignItems="Center"
 | 
			
		||||
          justifyContent="Center"
 | 
			
		||||
          gap="200"
 | 
			
		||||
          gap="300"
 | 
			
		||||
          {...props}
 | 
			
		||||
          ref={ref}
 | 
			
		||||
        >
 | 
			
		||||
| 
						 | 
				
			
			@ -669,15 +670,21 @@ export type MessageProps = {
 | 
			
		|||
  messageSpacing: MessageSpacing;
 | 
			
		||||
  onUserClick: MouseEventHandler<HTMLButtonElement>;
 | 
			
		||||
  onUsernameClick: MouseEventHandler<HTMLButtonElement>;
 | 
			
		||||
  onReplyClick: MouseEventHandler<HTMLButtonElement>;
 | 
			
		||||
  onReplyClick: (
 | 
			
		||||
    ev: Parameters<MouseEventHandler<HTMLButtonElement>>[0],
 | 
			
		||||
    startThread?: boolean
 | 
			
		||||
  ) => void;
 | 
			
		||||
  onEditId?: (eventId?: string) => void;
 | 
			
		||||
  onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void;
 | 
			
		||||
  reply?: ReactNode;
 | 
			
		||||
  reactions?: ReactNode;
 | 
			
		||||
  hideReadReceipts?: boolean;
 | 
			
		||||
  showDeveloperTools?: boolean;
 | 
			
		||||
  powerLevelTag?: PowerLevelTag;
 | 
			
		||||
  accessibleTagColors?: Map<string, string>;
 | 
			
		||||
  legacyUsernameColor?: boolean;
 | 
			
		||||
  hour24Clock: boolean;
 | 
			
		||||
  dateFormatString: string;
 | 
			
		||||
};
 | 
			
		||||
export const Message = as<'div', MessageProps>(
 | 
			
		||||
  (
 | 
			
		||||
| 
						 | 
				
			
			@ -703,9 +710,12 @@ export const Message = as<'div', MessageProps>(
 | 
			
		|||
      reply,
 | 
			
		||||
      reactions,
 | 
			
		||||
      hideReadReceipts,
 | 
			
		||||
      showDeveloperTools,
 | 
			
		||||
      powerLevelTag,
 | 
			
		||||
      accessibleTagColors,
 | 
			
		||||
      legacyUsernameColor,
 | 
			
		||||
      hour24Clock,
 | 
			
		||||
      dateFormatString,
 | 
			
		||||
      children,
 | 
			
		||||
      ...props
 | 
			
		||||
    },
 | 
			
		||||
| 
						 | 
				
			
			@ -770,7 +780,12 @@ export const Message = as<'div', MessageProps>(
 | 
			
		|||
              </Text>
 | 
			
		||||
            </>
 | 
			
		||||
          )}
 | 
			
		||||
          <Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
 | 
			
		||||
          <Time
 | 
			
		||||
            ts={mEvent.getTs()}
 | 
			
		||||
            compact={messageLayout === MessageLayout.Compact}
 | 
			
		||||
            hour24Clock={hour24Clock}
 | 
			
		||||
            dateFormatString={dateFormatString}
 | 
			
		||||
          />
 | 
			
		||||
        </Box>
 | 
			
		||||
      </Box>
 | 
			
		||||
    );
 | 
			
		||||
| 
						 | 
				
			
			@ -857,6 +872,8 @@ export const Message = as<'div', MessageProps>(
 | 
			
		|||
      }, 100);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const isThreadedMessage = mEvent.threadRootId !== undefined;
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <MessageBase
 | 
			
		||||
        className={classNames(css.MessageBase, className)}
 | 
			
		||||
| 
						 | 
				
			
			@ -919,6 +936,17 @@ export const Message = as<'div', MessageProps>(
 | 
			
		|||
                >
 | 
			
		||||
                  <Icon src={Icons.ReplyArrow} size="100" />
 | 
			
		||||
                </IconButton>
 | 
			
		||||
                {!isThreadedMessage && (
 | 
			
		||||
                  <IconButton
 | 
			
		||||
                    onClick={(ev) => onReplyClick(ev, true)}
 | 
			
		||||
                    data-event-id={mEvent.getId()}
 | 
			
		||||
                    variant="SurfaceVariant"
 | 
			
		||||
                    size="300"
 | 
			
		||||
                    radii="300"
 | 
			
		||||
                  >
 | 
			
		||||
                    <Icon src={Icons.ThreadPlus} size="100" />
 | 
			
		||||
                  </IconButton>
 | 
			
		||||
                )}
 | 
			
		||||
                {canEditEvent(mx, mEvent) && onEditId && (
 | 
			
		||||
                  <IconButton
 | 
			
		||||
                    onClick={() => onEditId(mEvent.getId())}
 | 
			
		||||
| 
						 | 
				
			
			@ -945,7 +973,7 @@ export const Message = as<'div', MessageProps>(
 | 
			
		|||
                        escapeDeactivates: stopPropagation,
 | 
			
		||||
                      }}
 | 
			
		||||
                    >
 | 
			
		||||
                      <Menu>
 | 
			
		||||
                      <Menu style={{ minWidth: toRem(200) }}>
 | 
			
		||||
                        {canSendReaction && (
 | 
			
		||||
                          <MessageQuickReactions
 | 
			
		||||
                            onReaction={(key, shortcode) => {
 | 
			
		||||
| 
						 | 
				
			
			@ -998,6 +1026,27 @@ export const Message = as<'div', MessageProps>(
 | 
			
		|||
                              Reply
 | 
			
		||||
                            </Text>
 | 
			
		||||
                          </MenuItem>
 | 
			
		||||
                          {!isThreadedMessage && (
 | 
			
		||||
                            <MenuItem
 | 
			
		||||
                              size="300"
 | 
			
		||||
                              after={<Icon src={Icons.ThreadPlus} size="100" />}
 | 
			
		||||
                              radii="300"
 | 
			
		||||
                              data-event-id={mEvent.getId()}
 | 
			
		||||
                              onClick={(evt: any) => {
 | 
			
		||||
                                onReplyClick(evt, true);
 | 
			
		||||
                                closeMenu();
 | 
			
		||||
                              }}
 | 
			
		||||
                            >
 | 
			
		||||
                              <Text
 | 
			
		||||
                                className={css.MessageMenuItemText}
 | 
			
		||||
                                as="span"
 | 
			
		||||
                                size="T300"
 | 
			
		||||
                                truncate
 | 
			
		||||
                              >
 | 
			
		||||
                                Reply in Thread
 | 
			
		||||
                              </Text>
 | 
			
		||||
                            </MenuItem>
 | 
			
		||||
                          )}
 | 
			
		||||
                          {canEditEvent(mx, mEvent) && onEditId && (
 | 
			
		||||
                            <MenuItem
 | 
			
		||||
                              size="300"
 | 
			
		||||
| 
						 | 
				
			
			@ -1026,7 +1075,13 @@ export const Message = as<'div', MessageProps>(
 | 
			
		|||
                              onClose={closeMenu}
 | 
			
		||||
                            />
 | 
			
		||||
                          )}
 | 
			
		||||
                          <MessageSourceCodeItem room={room} mEvent={mEvent} onClose={closeMenu} />
 | 
			
		||||
                          {showDeveloperTools && (
 | 
			
		||||
                            <MessageSourceCodeItem
 | 
			
		||||
                              room={room}
 | 
			
		||||
                              mEvent={mEvent}
 | 
			
		||||
                              onClose={closeMenu}
 | 
			
		||||
                            />
 | 
			
		||||
                          )}
 | 
			
		||||
                          <MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
 | 
			
		||||
                          {canPinEvent && (
 | 
			
		||||
                            <MessagePinItem room={room} mEvent={mEvent} onClose={closeMenu} />
 | 
			
		||||
| 
						 | 
				
			
			@ -1101,6 +1156,7 @@ export type EventProps = {
 | 
			
		|||
  canDelete?: boolean;
 | 
			
		||||
  messageSpacing: MessageSpacing;
 | 
			
		||||
  hideReadReceipts?: boolean;
 | 
			
		||||
  showDeveloperTools?: boolean;
 | 
			
		||||
};
 | 
			
		||||
export const Event = as<'div', EventProps>(
 | 
			
		||||
  (
 | 
			
		||||
| 
						 | 
				
			
			@ -1112,7 +1168,9 @@ export const Event = as<'div', EventProps>(
 | 
			
		|||
      canDelete,
 | 
			
		||||
      messageSpacing,
 | 
			
		||||
      hideReadReceipts,
 | 
			
		||||
      showDeveloperTools,
 | 
			
		||||
      children,
 | 
			
		||||
      style,
 | 
			
		||||
      ...props
 | 
			
		||||
    },
 | 
			
		||||
    ref
 | 
			
		||||
| 
						 | 
				
			
			@ -1179,7 +1237,7 @@ export const Event = as<'div', EventProps>(
 | 
			
		|||
                        escapeDeactivates: stopPropagation,
 | 
			
		||||
                      }}
 | 
			
		||||
                    >
 | 
			
		||||
                      <Menu {...props} ref={ref}>
 | 
			
		||||
                      <Menu style={{ minWidth: toRem(200), ...style }} {...props} ref={ref}>
 | 
			
		||||
                        <Box direction="Column" gap="100" className={css.MessageMenuGroup}>
 | 
			
		||||
                          {!hideReadReceipts && (
 | 
			
		||||
                            <MessageReadReceiptItem
 | 
			
		||||
| 
						 | 
				
			
			@ -1188,7 +1246,13 @@ export const Event = as<'div', EventProps>(
 | 
			
		|||
                              onClose={closeMenu}
 | 
			
		||||
                            />
 | 
			
		||||
                          )}
 | 
			
		||||
                          <MessageSourceCodeItem room={room} mEvent={mEvent} onClose={closeMenu} />
 | 
			
		||||
                          {showDeveloperTools && (
 | 
			
		||||
                            <MessageSourceCodeItem
 | 
			
		||||
                              room={room}
 | 
			
		||||
                              mEvent={mEvent}
 | 
			
		||||
                              onClose={closeMenu}
 | 
			
		||||
                            />
 | 
			
		||||
                          )}
 | 
			
		||||
                          <MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
 | 
			
		||||
                        </Box>
 | 
			
		||||
                        {((!mEvent.isRedacted() && canDelete && !stateEvent) ||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -30,7 +30,7 @@ export const MessageQuickReaction = style({
 | 
			
		|||
});
 | 
			
		||||
 | 
			
		||||
export const MessageMenuGroup = style({
 | 
			
		||||
  padding: config.space.S100,
 | 
			
		||||
  padding: config.space.S200,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const MessageMenuItemText = style({
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -102,6 +102,9 @@ function PinnedMessage({ room, eventId, renderContent, onOpen, canPinEvent }: Pi
 | 
			
		|||
  const theme = useTheme();
 | 
			
		||||
  const accessibleTagColors = useAccessibleTagColors(theme.kind, powerLevelTags);
 | 
			
		||||
 | 
			
		||||
  const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
 | 
			
		||||
  const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
 | 
			
		||||
 | 
			
		||||
  const [unpinState, unpin] = useAsyncCallback(
 | 
			
		||||
    useCallback(() => {
 | 
			
		||||
      const pinEvent = getStateEvent(room, StateEvent.RoomPinnedEvents);
 | 
			
		||||
| 
						 | 
				
			
			@ -205,7 +208,11 @@ function PinnedMessage({ room, eventId, renderContent, onOpen, canPinEvent }: Pi
 | 
			
		|||
            </Username>
 | 
			
		||||
            {tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
 | 
			
		||||
          </Box>
 | 
			
		||||
          <Time ts={pinnedEvent.getTs()} />
 | 
			
		||||
          <Time
 | 
			
		||||
            ts={pinnedEvent.getTs()}
 | 
			
		||||
            hour24Clock={hour24Clock}
 | 
			
		||||
            dateFormatString={dateFormatString}
 | 
			
		||||
          />
 | 
			
		||||
        </Box>
 | 
			
		||||
        {renderOptions()}
 | 
			
		||||
      </Box>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -71,7 +71,7 @@ const useSettingsMenuItems = (): SettingsMenuItem[] =>
 | 
			
		|||
      {
 | 
			
		||||
        page: SettingsPages.DevicesPage,
 | 
			
		||||
        name: 'Devices',
 | 
			
		||||
        icon: Icons.Category,
 | 
			
		||||
        icon: Icons.Monitor,
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        page: SettingsPages.EmojisStickersPage,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,396 +1,10 @@
 | 
			
		|||
import React, {
 | 
			
		||||
  ChangeEventHandler,
 | 
			
		||||
  FormEventHandler,
 | 
			
		||||
  useCallback,
 | 
			
		||||
  useEffect,
 | 
			
		||||
  useMemo,
 | 
			
		||||
  useState,
 | 
			
		||||
} from 'react';
 | 
			
		||||
import {
 | 
			
		||||
  Box,
 | 
			
		||||
  Text,
 | 
			
		||||
  IconButton,
 | 
			
		||||
  Icon,
 | 
			
		||||
  Icons,
 | 
			
		||||
  Scroll,
 | 
			
		||||
  Input,
 | 
			
		||||
  Avatar,
 | 
			
		||||
  Button,
 | 
			
		||||
  Chip,
 | 
			
		||||
  Overlay,
 | 
			
		||||
  OverlayBackdrop,
 | 
			
		||||
  OverlayCenter,
 | 
			
		||||
  Modal,
 | 
			
		||||
  Dialog,
 | 
			
		||||
  Header,
 | 
			
		||||
  config,
 | 
			
		||||
  Spinner,
 | 
			
		||||
} from 'folds';
 | 
			
		||||
import FocusTrap from 'focus-trap-react';
 | 
			
		||||
import React from 'react';
 | 
			
		||||
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 { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
			
		||||
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
 | 
			
		||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
 | 
			
		||||
import { UserAvatar } from '../../../components/user-avatar';
 | 
			
		||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
 | 
			
		||||
import { nameInitials } from '../../../utils/common';
 | 
			
		||||
import { copyToClipboard } from '../../../utils/dom';
 | 
			
		||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
 | 
			
		||||
import { useFilePicker } from '../../../hooks/useFilePicker';
 | 
			
		||||
import { useObjectURL } from '../../../hooks/useObjectURL';
 | 
			
		||||
import { stopPropagation } from '../../../utils/keyboard';
 | 
			
		||||
import { ImageEditor } from '../../../components/image-editor';
 | 
			
		||||
import { ModalWide } from '../../../styles/Modal.css';
 | 
			
		||||
import { createUploadAtom, UploadSuccess } from '../../../state/upload';
 | 
			
		||||
import { CompactUploadCardRenderer } from '../../../components/upload-card';
 | 
			
		||||
import { useCapabilities } from '../../../hooks/useCapabilities';
 | 
			
		||||
 | 
			
		||||
function MatrixId() {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const userId = mx.getUserId()!;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Box direction="Column" gap="100">
 | 
			
		||||
      <Text size="L400">Matrix ID</Text>
 | 
			
		||||
      <SequenceCard
 | 
			
		||||
        className={SequenceCardStyle}
 | 
			
		||||
        variant="SurfaceVariant"
 | 
			
		||||
        direction="Column"
 | 
			
		||||
        gap="400"
 | 
			
		||||
      >
 | 
			
		||||
        <SettingTile
 | 
			
		||||
          title={userId}
 | 
			
		||||
          after={
 | 
			
		||||
            <Chip variant="Secondary" radii="Pill" onClick={() => copyToClipboard(userId)}>
 | 
			
		||||
              <Text size="T200">Copy</Text>
 | 
			
		||||
            </Chip>
 | 
			
		||||
          }
 | 
			
		||||
        />
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type ProfileProps = {
 | 
			
		||||
  profile: UserProfile;
 | 
			
		||||
  userId: string;
 | 
			
		||||
};
 | 
			
		||||
function ProfileAvatar({ profile, userId }: ProfileProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const useAuthentication = useMediaAuthentication();
 | 
			
		||||
  const capabilities = useCapabilities();
 | 
			
		||||
  const [alertRemove, setAlertRemove] = useState(false);
 | 
			
		||||
  const disableSetAvatar = capabilities['m.set_avatar_url']?.enabled === false;
 | 
			
		||||
 | 
			
		||||
  const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
 | 
			
		||||
  const avatarUrl = profile.avatarUrl
 | 
			
		||||
    ? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
 | 
			
		||||
    : undefined;
 | 
			
		||||
 | 
			
		||||
  const [imageFile, setImageFile] = useState<File>();
 | 
			
		||||
  const imageFileURL = useObjectURL(imageFile);
 | 
			
		||||
  const uploadAtom = useMemo(() => {
 | 
			
		||||
    if (imageFile) return createUploadAtom(imageFile);
 | 
			
		||||
    return undefined;
 | 
			
		||||
  }, [imageFile]);
 | 
			
		||||
 | 
			
		||||
  const pickFile = useFilePicker(setImageFile, false);
 | 
			
		||||
 | 
			
		||||
  const handleRemoveUpload = useCallback(() => {
 | 
			
		||||
    setImageFile(undefined);
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  const handleUploaded = useCallback(
 | 
			
		||||
    (upload: UploadSuccess) => {
 | 
			
		||||
      const { mxc } = upload;
 | 
			
		||||
      mx.setAvatarUrl(mxc);
 | 
			
		||||
      handleRemoveUpload();
 | 
			
		||||
    },
 | 
			
		||||
    [mx, handleRemoveUpload]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const handleRemoveAvatar = () => {
 | 
			
		||||
    mx.setAvatarUrl('');
 | 
			
		||||
    setAlertRemove(false);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <SettingTile
 | 
			
		||||
      title={
 | 
			
		||||
        <Text as="span" size="L400">
 | 
			
		||||
          Avatar
 | 
			
		||||
        </Text>
 | 
			
		||||
      }
 | 
			
		||||
      after={
 | 
			
		||||
        <Avatar size="500" radii="300">
 | 
			
		||||
          <UserAvatar
 | 
			
		||||
            userId={userId}
 | 
			
		||||
            src={avatarUrl}
 | 
			
		||||
            renderFallback={() => <Text size="H4">{nameInitials(defaultDisplayName)}</Text>}
 | 
			
		||||
          />
 | 
			
		||||
        </Avatar>
 | 
			
		||||
      }
 | 
			
		||||
    >
 | 
			
		||||
      {uploadAtom ? (
 | 
			
		||||
        <Box gap="200" direction="Column">
 | 
			
		||||
          <CompactUploadCardRenderer
 | 
			
		||||
            uploadAtom={uploadAtom}
 | 
			
		||||
            onRemove={handleRemoveUpload}
 | 
			
		||||
            onComplete={handleUploaded}
 | 
			
		||||
          />
 | 
			
		||||
        </Box>
 | 
			
		||||
      ) : (
 | 
			
		||||
        <Box gap="200">
 | 
			
		||||
          <Button
 | 
			
		||||
            onClick={() => pickFile('image/*')}
 | 
			
		||||
            size="300"
 | 
			
		||||
            variant="Secondary"
 | 
			
		||||
            fill="Soft"
 | 
			
		||||
            outlined
 | 
			
		||||
            radii="300"
 | 
			
		||||
            disabled={disableSetAvatar}
 | 
			
		||||
          >
 | 
			
		||||
            <Text size="B300">Upload</Text>
 | 
			
		||||
          </Button>
 | 
			
		||||
          {avatarUrl && (
 | 
			
		||||
            <Button
 | 
			
		||||
              size="300"
 | 
			
		||||
              variant="Critical"
 | 
			
		||||
              fill="None"
 | 
			
		||||
              radii="300"
 | 
			
		||||
              disabled={disableSetAvatar}
 | 
			
		||||
              onClick={() => setAlertRemove(true)}
 | 
			
		||||
            >
 | 
			
		||||
              <Text size="B300">Remove</Text>
 | 
			
		||||
            </Button>
 | 
			
		||||
          )}
 | 
			
		||||
        </Box>
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      {imageFileURL && (
 | 
			
		||||
        <Overlay open={false} backdrop={<OverlayBackdrop />}>
 | 
			
		||||
          <OverlayCenter>
 | 
			
		||||
            <FocusTrap
 | 
			
		||||
              focusTrapOptions={{
 | 
			
		||||
                initialFocus: false,
 | 
			
		||||
                onDeactivate: handleRemoveUpload,
 | 
			
		||||
                clickOutsideDeactivates: true,
 | 
			
		||||
                escapeDeactivates: stopPropagation,
 | 
			
		||||
              }}
 | 
			
		||||
            >
 | 
			
		||||
              <Modal className={ModalWide} variant="Surface" size="500">
 | 
			
		||||
                <ImageEditor
 | 
			
		||||
                  name={imageFile?.name ?? 'Unnamed'}
 | 
			
		||||
                  url={imageFileURL}
 | 
			
		||||
                  requestClose={handleRemoveUpload}
 | 
			
		||||
                />
 | 
			
		||||
              </Modal>
 | 
			
		||||
            </FocusTrap>
 | 
			
		||||
          </OverlayCenter>
 | 
			
		||||
        </Overlay>
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      <Overlay open={alertRemove} backdrop={<OverlayBackdrop />}>
 | 
			
		||||
        <OverlayCenter>
 | 
			
		||||
          <FocusTrap
 | 
			
		||||
            focusTrapOptions={{
 | 
			
		||||
              initialFocus: false,
 | 
			
		||||
              onDeactivate: () => setAlertRemove(false),
 | 
			
		||||
              clickOutsideDeactivates: true,
 | 
			
		||||
              escapeDeactivates: stopPropagation,
 | 
			
		||||
            }}
 | 
			
		||||
          >
 | 
			
		||||
            <Dialog variant="Surface">
 | 
			
		||||
              <Header
 | 
			
		||||
                style={{
 | 
			
		||||
                  padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
 | 
			
		||||
                  borderBottomWidth: config.borderWidth.B300,
 | 
			
		||||
                }}
 | 
			
		||||
                variant="Surface"
 | 
			
		||||
                size="500"
 | 
			
		||||
              >
 | 
			
		||||
                <Box grow="Yes">
 | 
			
		||||
                  <Text size="H4">Remove Avatar</Text>
 | 
			
		||||
                </Box>
 | 
			
		||||
                <IconButton size="300" onClick={() => setAlertRemove(false)} radii="300">
 | 
			
		||||
                  <Icon src={Icons.Cross} />
 | 
			
		||||
                </IconButton>
 | 
			
		||||
              </Header>
 | 
			
		||||
              <Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
 | 
			
		||||
                <Box direction="Column" gap="200">
 | 
			
		||||
                  <Text priority="400">Are you sure you want to remove profile avatar?</Text>
 | 
			
		||||
                </Box>
 | 
			
		||||
                <Button variant="Critical" onClick={handleRemoveAvatar}>
 | 
			
		||||
                  <Text size="B400">Remove</Text>
 | 
			
		||||
                </Button>
 | 
			
		||||
              </Box>
 | 
			
		||||
            </Dialog>
 | 
			
		||||
          </FocusTrap>
 | 
			
		||||
        </OverlayCenter>
 | 
			
		||||
      </Overlay>
 | 
			
		||||
    </SettingTile>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function ProfileDisplayName({ profile, userId }: ProfileProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const capabilities = useCapabilities();
 | 
			
		||||
  const disableSetDisplayname = capabilities['m.set_displayname']?.enabled === false;
 | 
			
		||||
 | 
			
		||||
  const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
 | 
			
		||||
  const [displayName, setDisplayName] = useState<string>(defaultDisplayName);
 | 
			
		||||
 | 
			
		||||
  const [changeState, changeDisplayName] = useAsyncCallback(
 | 
			
		||||
    useCallback((name: string) => mx.setDisplayName(name), [mx])
 | 
			
		||||
  );
 | 
			
		||||
  const changingDisplayName = changeState.status === AsyncStatus.Loading;
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    setDisplayName(defaultDisplayName);
 | 
			
		||||
  }, [defaultDisplayName]);
 | 
			
		||||
 | 
			
		||||
  const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
 | 
			
		||||
    const name = evt.currentTarget.value;
 | 
			
		||||
    setDisplayName(name);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleReset = () => {
 | 
			
		||||
    setDisplayName(defaultDisplayName);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
 | 
			
		||||
    evt.preventDefault();
 | 
			
		||||
    if (changingDisplayName) return;
 | 
			
		||||
 | 
			
		||||
    const target = evt.target as HTMLFormElement | undefined;
 | 
			
		||||
    const displayNameInput = target?.displayNameInput as HTMLInputElement | undefined;
 | 
			
		||||
    const name = displayNameInput?.value;
 | 
			
		||||
    if (!name) return;
 | 
			
		||||
 | 
			
		||||
    changeDisplayName(name);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const hasChanges = displayName !== defaultDisplayName;
 | 
			
		||||
  return (
 | 
			
		||||
    <SettingTile
 | 
			
		||||
      title={
 | 
			
		||||
        <Text as="span" size="L400">
 | 
			
		||||
          Display Name
 | 
			
		||||
        </Text>
 | 
			
		||||
      }
 | 
			
		||||
    >
 | 
			
		||||
      <Box direction="Column" grow="Yes" gap="100">
 | 
			
		||||
        <Box
 | 
			
		||||
          as="form"
 | 
			
		||||
          onSubmit={handleSubmit}
 | 
			
		||||
          gap="200"
 | 
			
		||||
          aria-disabled={changingDisplayName || disableSetDisplayname}
 | 
			
		||||
        >
 | 
			
		||||
          <Box grow="Yes" direction="Column">
 | 
			
		||||
            <Input
 | 
			
		||||
              required
 | 
			
		||||
              name="displayNameInput"
 | 
			
		||||
              value={displayName}
 | 
			
		||||
              onChange={handleChange}
 | 
			
		||||
              variant="Secondary"
 | 
			
		||||
              radii="300"
 | 
			
		||||
              style={{ paddingRight: config.space.S200 }}
 | 
			
		||||
              readOnly={changingDisplayName || disableSetDisplayname}
 | 
			
		||||
              after={
 | 
			
		||||
                hasChanges &&
 | 
			
		||||
                !changingDisplayName && (
 | 
			
		||||
                  <IconButton
 | 
			
		||||
                    type="reset"
 | 
			
		||||
                    onClick={handleReset}
 | 
			
		||||
                    size="300"
 | 
			
		||||
                    radii="300"
 | 
			
		||||
                    variant="Secondary"
 | 
			
		||||
                  >
 | 
			
		||||
                    <Icon src={Icons.Cross} size="100" />
 | 
			
		||||
                  </IconButton>
 | 
			
		||||
                )
 | 
			
		||||
              }
 | 
			
		||||
            />
 | 
			
		||||
          </Box>
 | 
			
		||||
          <Button
 | 
			
		||||
            size="400"
 | 
			
		||||
            variant={hasChanges ? 'Success' : 'Secondary'}
 | 
			
		||||
            fill={hasChanges ? 'Solid' : 'Soft'}
 | 
			
		||||
            outlined
 | 
			
		||||
            radii="300"
 | 
			
		||||
            disabled={!hasChanges || changingDisplayName}
 | 
			
		||||
            type="submit"
 | 
			
		||||
          >
 | 
			
		||||
            {changingDisplayName && <Spinner variant="Success" fill="Solid" size="300" />}
 | 
			
		||||
            <Text size="B400">Save</Text>
 | 
			
		||||
          </Button>
 | 
			
		||||
        </Box>
 | 
			
		||||
      </Box>
 | 
			
		||||
    </SettingTile>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function Profile() {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const userId = mx.getUserId()!;
 | 
			
		||||
  const profile = useUserProfile(userId);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Box direction="Column" gap="100">
 | 
			
		||||
      <Text size="L400">Profile</Text>
 | 
			
		||||
      <SequenceCard
 | 
			
		||||
        className={SequenceCardStyle}
 | 
			
		||||
        variant="SurfaceVariant"
 | 
			
		||||
        direction="Column"
 | 
			
		||||
        gap="400"
 | 
			
		||||
      >
 | 
			
		||||
        <ProfileAvatar userId={userId} profile={profile} />
 | 
			
		||||
        <ProfileDisplayName userId={userId} profile={profile} />
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function ContactInformation() {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const [threePIdsState, loadThreePIds] = useAsyncCallback(
 | 
			
		||||
    useCallback(() => mx.getThreePids(), [mx])
 | 
			
		||||
  );
 | 
			
		||||
  const threePIds =
 | 
			
		||||
    threePIdsState.status === AsyncStatus.Success ? threePIdsState.data.threepids : undefined;
 | 
			
		||||
 | 
			
		||||
  const emailIds = threePIds?.filter((id) => id.medium === 'email');
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    loadThreePIds();
 | 
			
		||||
  }, [loadThreePIds]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Box direction="Column" gap="100">
 | 
			
		||||
      <Text size="L400">Contact Information</Text>
 | 
			
		||||
      <SequenceCard
 | 
			
		||||
        className={SequenceCardStyle}
 | 
			
		||||
        variant="SurfaceVariant"
 | 
			
		||||
        direction="Column"
 | 
			
		||||
        gap="400"
 | 
			
		||||
      >
 | 
			
		||||
        <SettingTile title="Email Address" description="Email address attached to your account.">
 | 
			
		||||
          <Box>
 | 
			
		||||
            {emailIds?.map((email) => (
 | 
			
		||||
              <Chip key={email.address} as="span" variant="Secondary" radii="Pill">
 | 
			
		||||
                <Text size="T200">{email.address}</Text>
 | 
			
		||||
              </Chip>
 | 
			
		||||
            ))}
 | 
			
		||||
          </Box>
 | 
			
		||||
          {/* <Input defaultValue="" variant="Secondary" radii="300" /> */}
 | 
			
		||||
        </SettingTile>
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
import { MatrixId } from './MatrixId';
 | 
			
		||||
import { Profile } from './Profile';
 | 
			
		||||
import { ContactInformation } from './ContactInfo';
 | 
			
		||||
import { IgnoredUserList } from './IgnoredUserList';
 | 
			
		||||
 | 
			
		||||
type AccountProps = {
 | 
			
		||||
  requestClose: () => void;
 | 
			
		||||
| 
						 | 
				
			
			@ -419,6 +33,7 @@ export function Account({ requestClose }: AccountProps) {
 | 
			
		|||
              <Profile />
 | 
			
		||||
              <MatrixId />
 | 
			
		||||
              <ContactInformation />
 | 
			
		||||
              <IgnoredUserList />
 | 
			
		||||
            </Box>
 | 
			
		||||
          </PageContent>
 | 
			
		||||
        </Scroll>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										45
									
								
								src/app/features/settings/account/ContactInfo.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								src/app/features/settings/account/ContactInfo.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,45 @@
 | 
			
		|||
import React, { useCallback, useEffect } from 'react';
 | 
			
		||||
import { Box, Text, Chip } 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 { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
 | 
			
		||||
 | 
			
		||||
export function ContactInformation() {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const [threePIdsState, loadThreePIds] = useAsyncCallback(
 | 
			
		||||
    useCallback(() => mx.getThreePids(), [mx])
 | 
			
		||||
  );
 | 
			
		||||
  const threePIds =
 | 
			
		||||
    threePIdsState.status === AsyncStatus.Success ? threePIdsState.data.threepids : undefined;
 | 
			
		||||
 | 
			
		||||
  const emailIds = threePIds?.filter((id) => id.medium === 'email');
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    loadThreePIds();
 | 
			
		||||
  }, [loadThreePIds]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Box direction="Column" gap="100">
 | 
			
		||||
      <Text size="L400">Contact Information</Text>
 | 
			
		||||
      <SequenceCard
 | 
			
		||||
        className={SequenceCardStyle}
 | 
			
		||||
        variant="SurfaceVariant"
 | 
			
		||||
        direction="Column"
 | 
			
		||||
        gap="400"
 | 
			
		||||
      >
 | 
			
		||||
        <SettingTile title="Email Address" description="Email address attached to your account.">
 | 
			
		||||
          <Box>
 | 
			
		||||
            {emailIds?.map((email) => (
 | 
			
		||||
              <Chip key={email.address} as="span" variant="Secondary" radii="Pill">
 | 
			
		||||
                <Text size="T200">{email.address}</Text>
 | 
			
		||||
              </Chip>
 | 
			
		||||
            ))}
 | 
			
		||||
          </Box>
 | 
			
		||||
          {/* <Input defaultValue="" variant="Secondary" radii="300" /> */}
 | 
			
		||||
        </SettingTile>
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -7,16 +7,17 @@ import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
			
		|||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
 | 
			
		||||
import { isUserId } from '../../../utils/matrix';
 | 
			
		||||
import { useIgnoredUsers } from '../../../hooks/useIgnoredUsers';
 | 
			
		||||
import { useAlive } from '../../../hooks/useAlive';
 | 
			
		||||
 | 
			
		||||
function IgnoreUserInput({ userList }: { userList: string[] }) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const [userId, setUserId] = useState<string>('');
 | 
			
		||||
  const alive = useAlive();
 | 
			
		||||
 | 
			
		||||
  const [ignoreState, ignore] = useAsyncCallback(
 | 
			
		||||
    useCallback(
 | 
			
		||||
      async (uId: string) => {
 | 
			
		||||
        mx.setIgnoredUsers([...userList, uId]);
 | 
			
		||||
        setUserId('');
 | 
			
		||||
        await mx.setIgnoredUsers([...userList, uId]);
 | 
			
		||||
      },
 | 
			
		||||
      [mx, userList]
 | 
			
		||||
    )
 | 
			
		||||
| 
						 | 
				
			
			@ -43,7 +44,11 @@ function IgnoreUserInput({ userList }: { userList: string[] }) {
 | 
			
		|||
 | 
			
		||||
    if (!isUserId(uId)) return;
 | 
			
		||||
 | 
			
		||||
    ignore(uId);
 | 
			
		||||
    ignore(uId).then(() => {
 | 
			
		||||
      if (alive()) {
 | 
			
		||||
        setUserId('');
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
| 
						 | 
				
			
			@ -129,7 +134,7 @@ export function IgnoredUserList() {
 | 
			
		|||
  return (
 | 
			
		||||
    <Box direction="Column" gap="100">
 | 
			
		||||
      <Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
 | 
			
		||||
        <Text size="L400">Block Messages</Text>
 | 
			
		||||
        <Text size="L400">Blocked Users</Text>
 | 
			
		||||
      </Box>
 | 
			
		||||
      <SequenceCard
 | 
			
		||||
        className={SequenceCardStyle}
 | 
			
		||||
| 
						 | 
				
			
			@ -139,13 +144,13 @@ export function IgnoredUserList() {
 | 
			
		|||
      >
 | 
			
		||||
        <SettingTile
 | 
			
		||||
          title="Select User"
 | 
			
		||||
          description="Prevent receiving message by adding userId into blocklist."
 | 
			
		||||
          description="Prevent receiving messages or invites from user by adding their userId."
 | 
			
		||||
        >
 | 
			
		||||
          <Box direction="Column" gap="300">
 | 
			
		||||
            <IgnoreUserInput userList={ignoredUsers} />
 | 
			
		||||
            {ignoredUsers.length > 0 && (
 | 
			
		||||
              <Box direction="Inherit" gap="100">
 | 
			
		||||
                <Text size="L400">Blocklist</Text>
 | 
			
		||||
                <Text size="L400">Users</Text>
 | 
			
		||||
                <Box wrap="Wrap" gap="200">
 | 
			
		||||
                  {ignoredUsers.map((userId) => (
 | 
			
		||||
                    <IgnoredUserChip key={userId} userId={userId} userList={ignoredUsers} />
 | 
			
		||||
							
								
								
									
										33
									
								
								src/app/features/settings/account/MatrixId.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								src/app/features/settings/account/MatrixId.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,33 @@
 | 
			
		|||
import React from 'react';
 | 
			
		||||
import { Box, Text, Chip } from 'folds';
 | 
			
		||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
			
		||||
import { SequenceCard } from '../../../components/sequence-card';
 | 
			
		||||
import { SequenceCardStyle } from '../styles.css';
 | 
			
		||||
import { SettingTile } from '../../../components/setting-tile';
 | 
			
		||||
import { copyToClipboard } from '../../../../util/common';
 | 
			
		||||
 | 
			
		||||
export function MatrixId() {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const userId = mx.getUserId()!;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Box direction="Column" gap="100">
 | 
			
		||||
      <Text size="L400">Matrix ID</Text>
 | 
			
		||||
      <SequenceCard
 | 
			
		||||
        className={SequenceCardStyle}
 | 
			
		||||
        variant="SurfaceVariant"
 | 
			
		||||
        direction="Column"
 | 
			
		||||
        gap="400"
 | 
			
		||||
      >
 | 
			
		||||
        <SettingTile
 | 
			
		||||
          title={userId}
 | 
			
		||||
          after={
 | 
			
		||||
            <Chip variant="Secondary" radii="Pill" onClick={() => copyToClipboard(userId)}>
 | 
			
		||||
              <Text size="T200">Copy</Text>
 | 
			
		||||
            </Chip>
 | 
			
		||||
          }
 | 
			
		||||
        />
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										325
									
								
								src/app/features/settings/account/Profile.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										325
									
								
								src/app/features/settings/account/Profile.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,325 @@
 | 
			
		|||
import React, {
 | 
			
		||||
  ChangeEventHandler,
 | 
			
		||||
  FormEventHandler,
 | 
			
		||||
  useCallback,
 | 
			
		||||
  useEffect,
 | 
			
		||||
  useMemo,
 | 
			
		||||
  useState,
 | 
			
		||||
} from 'react';
 | 
			
		||||
import {
 | 
			
		||||
  Box,
 | 
			
		||||
  Text,
 | 
			
		||||
  IconButton,
 | 
			
		||||
  Icon,
 | 
			
		||||
  Icons,
 | 
			
		||||
  Input,
 | 
			
		||||
  Avatar,
 | 
			
		||||
  Button,
 | 
			
		||||
  Overlay,
 | 
			
		||||
  OverlayBackdrop,
 | 
			
		||||
  OverlayCenter,
 | 
			
		||||
  Modal,
 | 
			
		||||
  Dialog,
 | 
			
		||||
  Header,
 | 
			
		||||
  config,
 | 
			
		||||
  Spinner,
 | 
			
		||||
} from 'folds';
 | 
			
		||||
import FocusTrap from 'focus-trap-react';
 | 
			
		||||
import { SequenceCard } from '../../../components/sequence-card';
 | 
			
		||||
import { SequenceCardStyle } from '../styles.css';
 | 
			
		||||
import { SettingTile } from '../../../components/setting-tile';
 | 
			
		||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
			
		||||
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
 | 
			
		||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
 | 
			
		||||
import { UserAvatar } from '../../../components/user-avatar';
 | 
			
		||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
 | 
			
		||||
import { nameInitials } from '../../../utils/common';
 | 
			
		||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
 | 
			
		||||
import { useFilePicker } from '../../../hooks/useFilePicker';
 | 
			
		||||
import { useObjectURL } from '../../../hooks/useObjectURL';
 | 
			
		||||
import { stopPropagation } from '../../../utils/keyboard';
 | 
			
		||||
import { ImageEditor } from '../../../components/image-editor';
 | 
			
		||||
import { ModalWide } from '../../../styles/Modal.css';
 | 
			
		||||
import { createUploadAtom, UploadSuccess } from '../../../state/upload';
 | 
			
		||||
import { CompactUploadCardRenderer } from '../../../components/upload-card';
 | 
			
		||||
import { useCapabilities } from '../../../hooks/useCapabilities';
 | 
			
		||||
 | 
			
		||||
type ProfileProps = {
 | 
			
		||||
  profile: UserProfile;
 | 
			
		||||
  userId: string;
 | 
			
		||||
};
 | 
			
		||||
function ProfileAvatar({ profile, userId }: ProfileProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const useAuthentication = useMediaAuthentication();
 | 
			
		||||
  const capabilities = useCapabilities();
 | 
			
		||||
  const [alertRemove, setAlertRemove] = useState(false);
 | 
			
		||||
  const disableSetAvatar = capabilities['m.set_avatar_url']?.enabled === false;
 | 
			
		||||
 | 
			
		||||
  const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
 | 
			
		||||
  const avatarUrl = profile.avatarUrl
 | 
			
		||||
    ? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
 | 
			
		||||
    : undefined;
 | 
			
		||||
 | 
			
		||||
  const [imageFile, setImageFile] = useState<File>();
 | 
			
		||||
  const imageFileURL = useObjectURL(imageFile);
 | 
			
		||||
  const uploadAtom = useMemo(() => {
 | 
			
		||||
    if (imageFile) return createUploadAtom(imageFile);
 | 
			
		||||
    return undefined;
 | 
			
		||||
  }, [imageFile]);
 | 
			
		||||
 | 
			
		||||
  const pickFile = useFilePicker(setImageFile, false);
 | 
			
		||||
 | 
			
		||||
  const handleRemoveUpload = useCallback(() => {
 | 
			
		||||
    setImageFile(undefined);
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  const handleUploaded = useCallback(
 | 
			
		||||
    (upload: UploadSuccess) => {
 | 
			
		||||
      const { mxc } = upload;
 | 
			
		||||
      mx.setAvatarUrl(mxc);
 | 
			
		||||
      handleRemoveUpload();
 | 
			
		||||
    },
 | 
			
		||||
    [mx, handleRemoveUpload]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const handleRemoveAvatar = () => {
 | 
			
		||||
    mx.setAvatarUrl('');
 | 
			
		||||
    setAlertRemove(false);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <SettingTile
 | 
			
		||||
      title={
 | 
			
		||||
        <Text as="span" size="L400">
 | 
			
		||||
          Avatar
 | 
			
		||||
        </Text>
 | 
			
		||||
      }
 | 
			
		||||
      after={
 | 
			
		||||
        <Avatar size="500" radii="300">
 | 
			
		||||
          <UserAvatar
 | 
			
		||||
            userId={userId}
 | 
			
		||||
            src={avatarUrl}
 | 
			
		||||
            renderFallback={() => <Text size="H4">{nameInitials(defaultDisplayName)}</Text>}
 | 
			
		||||
          />
 | 
			
		||||
        </Avatar>
 | 
			
		||||
      }
 | 
			
		||||
    >
 | 
			
		||||
      {uploadAtom ? (
 | 
			
		||||
        <Box gap="200" direction="Column">
 | 
			
		||||
          <CompactUploadCardRenderer
 | 
			
		||||
            uploadAtom={uploadAtom}
 | 
			
		||||
            onRemove={handleRemoveUpload}
 | 
			
		||||
            onComplete={handleUploaded}
 | 
			
		||||
          />
 | 
			
		||||
        </Box>
 | 
			
		||||
      ) : (
 | 
			
		||||
        <Box gap="200">
 | 
			
		||||
          <Button
 | 
			
		||||
            onClick={() => pickFile('image/*')}
 | 
			
		||||
            size="300"
 | 
			
		||||
            variant="Secondary"
 | 
			
		||||
            fill="Soft"
 | 
			
		||||
            outlined
 | 
			
		||||
            radii="300"
 | 
			
		||||
            disabled={disableSetAvatar}
 | 
			
		||||
          >
 | 
			
		||||
            <Text size="B300">Upload</Text>
 | 
			
		||||
          </Button>
 | 
			
		||||
          {avatarUrl && (
 | 
			
		||||
            <Button
 | 
			
		||||
              size="300"
 | 
			
		||||
              variant="Critical"
 | 
			
		||||
              fill="None"
 | 
			
		||||
              radii="300"
 | 
			
		||||
              disabled={disableSetAvatar}
 | 
			
		||||
              onClick={() => setAlertRemove(true)}
 | 
			
		||||
            >
 | 
			
		||||
              <Text size="B300">Remove</Text>
 | 
			
		||||
            </Button>
 | 
			
		||||
          )}
 | 
			
		||||
        </Box>
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      {imageFileURL && (
 | 
			
		||||
        <Overlay open={false} backdrop={<OverlayBackdrop />}>
 | 
			
		||||
          <OverlayCenter>
 | 
			
		||||
            <FocusTrap
 | 
			
		||||
              focusTrapOptions={{
 | 
			
		||||
                initialFocus: false,
 | 
			
		||||
                onDeactivate: handleRemoveUpload,
 | 
			
		||||
                clickOutsideDeactivates: true,
 | 
			
		||||
                escapeDeactivates: stopPropagation,
 | 
			
		||||
              }}
 | 
			
		||||
            >
 | 
			
		||||
              <Modal className={ModalWide} variant="Surface" size="500">
 | 
			
		||||
                <ImageEditor
 | 
			
		||||
                  name={imageFile?.name ?? 'Unnamed'}
 | 
			
		||||
                  url={imageFileURL}
 | 
			
		||||
                  requestClose={handleRemoveUpload}
 | 
			
		||||
                />
 | 
			
		||||
              </Modal>
 | 
			
		||||
            </FocusTrap>
 | 
			
		||||
          </OverlayCenter>
 | 
			
		||||
        </Overlay>
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      <Overlay open={alertRemove} backdrop={<OverlayBackdrop />}>
 | 
			
		||||
        <OverlayCenter>
 | 
			
		||||
          <FocusTrap
 | 
			
		||||
            focusTrapOptions={{
 | 
			
		||||
              initialFocus: false,
 | 
			
		||||
              onDeactivate: () => setAlertRemove(false),
 | 
			
		||||
              clickOutsideDeactivates: true,
 | 
			
		||||
              escapeDeactivates: stopPropagation,
 | 
			
		||||
            }}
 | 
			
		||||
          >
 | 
			
		||||
            <Dialog variant="Surface">
 | 
			
		||||
              <Header
 | 
			
		||||
                style={{
 | 
			
		||||
                  padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
 | 
			
		||||
                  borderBottomWidth: config.borderWidth.B300,
 | 
			
		||||
                }}
 | 
			
		||||
                variant="Surface"
 | 
			
		||||
                size="500"
 | 
			
		||||
              >
 | 
			
		||||
                <Box grow="Yes">
 | 
			
		||||
                  <Text size="H4">Remove Avatar</Text>
 | 
			
		||||
                </Box>
 | 
			
		||||
                <IconButton size="300" onClick={() => setAlertRemove(false)} radii="300">
 | 
			
		||||
                  <Icon src={Icons.Cross} />
 | 
			
		||||
                </IconButton>
 | 
			
		||||
              </Header>
 | 
			
		||||
              <Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
 | 
			
		||||
                <Box direction="Column" gap="200">
 | 
			
		||||
                  <Text priority="400">Are you sure you want to remove profile avatar?</Text>
 | 
			
		||||
                </Box>
 | 
			
		||||
                <Button variant="Critical" onClick={handleRemoveAvatar}>
 | 
			
		||||
                  <Text size="B400">Remove</Text>
 | 
			
		||||
                </Button>
 | 
			
		||||
              </Box>
 | 
			
		||||
            </Dialog>
 | 
			
		||||
          </FocusTrap>
 | 
			
		||||
        </OverlayCenter>
 | 
			
		||||
      </Overlay>
 | 
			
		||||
    </SettingTile>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function ProfileDisplayName({ profile, userId }: ProfileProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const capabilities = useCapabilities();
 | 
			
		||||
  const disableSetDisplayname = capabilities['m.set_displayname']?.enabled === false;
 | 
			
		||||
 | 
			
		||||
  const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
 | 
			
		||||
  const [displayName, setDisplayName] = useState<string>(defaultDisplayName);
 | 
			
		||||
 | 
			
		||||
  const [changeState, changeDisplayName] = useAsyncCallback(
 | 
			
		||||
    useCallback((name: string) => mx.setDisplayName(name), [mx])
 | 
			
		||||
  );
 | 
			
		||||
  const changingDisplayName = changeState.status === AsyncStatus.Loading;
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    setDisplayName(defaultDisplayName);
 | 
			
		||||
  }, [defaultDisplayName]);
 | 
			
		||||
 | 
			
		||||
  const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
 | 
			
		||||
    const name = evt.currentTarget.value;
 | 
			
		||||
    setDisplayName(name);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleReset = () => {
 | 
			
		||||
    setDisplayName(defaultDisplayName);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
 | 
			
		||||
    evt.preventDefault();
 | 
			
		||||
    if (changingDisplayName) return;
 | 
			
		||||
 | 
			
		||||
    const target = evt.target as HTMLFormElement | undefined;
 | 
			
		||||
    const displayNameInput = target?.displayNameInput as HTMLInputElement | undefined;
 | 
			
		||||
    const name = displayNameInput?.value;
 | 
			
		||||
    if (!name) return;
 | 
			
		||||
 | 
			
		||||
    changeDisplayName(name);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const hasChanges = displayName !== defaultDisplayName;
 | 
			
		||||
  return (
 | 
			
		||||
    <SettingTile
 | 
			
		||||
      title={
 | 
			
		||||
        <Text as="span" size="L400">
 | 
			
		||||
          Display Name
 | 
			
		||||
        </Text>
 | 
			
		||||
      }
 | 
			
		||||
    >
 | 
			
		||||
      <Box direction="Column" grow="Yes" gap="100">
 | 
			
		||||
        <Box
 | 
			
		||||
          as="form"
 | 
			
		||||
          onSubmit={handleSubmit}
 | 
			
		||||
          gap="200"
 | 
			
		||||
          aria-disabled={changingDisplayName || disableSetDisplayname}
 | 
			
		||||
        >
 | 
			
		||||
          <Box grow="Yes" direction="Column">
 | 
			
		||||
            <Input
 | 
			
		||||
              required
 | 
			
		||||
              name="displayNameInput"
 | 
			
		||||
              value={displayName}
 | 
			
		||||
              onChange={handleChange}
 | 
			
		||||
              variant="Secondary"
 | 
			
		||||
              radii="300"
 | 
			
		||||
              style={{ paddingRight: config.space.S200 }}
 | 
			
		||||
              readOnly={changingDisplayName || disableSetDisplayname}
 | 
			
		||||
              after={
 | 
			
		||||
                hasChanges &&
 | 
			
		||||
                !changingDisplayName && (
 | 
			
		||||
                  <IconButton
 | 
			
		||||
                    type="reset"
 | 
			
		||||
                    onClick={handleReset}
 | 
			
		||||
                    size="300"
 | 
			
		||||
                    radii="300"
 | 
			
		||||
                    variant="Secondary"
 | 
			
		||||
                  >
 | 
			
		||||
                    <Icon src={Icons.Cross} size="100" />
 | 
			
		||||
                  </IconButton>
 | 
			
		||||
                )
 | 
			
		||||
              }
 | 
			
		||||
            />
 | 
			
		||||
          </Box>
 | 
			
		||||
          <Button
 | 
			
		||||
            size="400"
 | 
			
		||||
            variant={hasChanges ? 'Success' : 'Secondary'}
 | 
			
		||||
            fill={hasChanges ? 'Solid' : 'Soft'}
 | 
			
		||||
            outlined
 | 
			
		||||
            radii="300"
 | 
			
		||||
            disabled={!hasChanges || changingDisplayName}
 | 
			
		||||
            type="submit"
 | 
			
		||||
          >
 | 
			
		||||
            {changingDisplayName && <Spinner variant="Success" fill="Solid" size="300" />}
 | 
			
		||||
            <Text size="B400">Save</Text>
 | 
			
		||||
          </Button>
 | 
			
		||||
        </Box>
 | 
			
		||||
      </Box>
 | 
			
		||||
    </SettingTile>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function Profile() {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const userId = mx.getUserId()!;
 | 
			
		||||
  const profile = useUserProfile(userId);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Box direction="Column" gap="100">
 | 
			
		||||
      <Text size="L400">Profile</Text>
 | 
			
		||||
      <SequenceCard
 | 
			
		||||
        className={SequenceCardStyle}
 | 
			
		||||
        variant="SurfaceVariant"
 | 
			
		||||
        direction="Column"
 | 
			
		||||
        gap="400"
 | 
			
		||||
      >
 | 
			
		||||
        <ProfileAvatar userId={userId} profile={profile} />
 | 
			
		||||
        <ProfileDisplayName userId={userId} profile={profile} />
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -27,6 +27,8 @@ import { SequenceCard } from '../../../components/sequence-card';
 | 
			
		|||
import { SequenceCardStyle } from '../styles.css';
 | 
			
		||||
import { LogoutDialog } from '../../../components/LogoutDialog';
 | 
			
		||||
import { stopPropagation } from '../../../utils/keyboard';
 | 
			
		||||
import { useSetting } from '../../../state/hooks/settings';
 | 
			
		||||
import { settingsAtom } from '../../../state/settings';
 | 
			
		||||
 | 
			
		||||
export function DeviceTilePlaceholder() {
 | 
			
		||||
  return (
 | 
			
		||||
| 
						 | 
				
			
			@ -41,6 +43,9 @@ export function DeviceTilePlaceholder() {
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
function DeviceActiveTime({ ts }: { ts: number }) {
 | 
			
		||||
  const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
 | 
			
		||||
  const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Text className={BreakWord} size="T200">
 | 
			
		||||
      <Text size="Inherit" as="span" priority="300">
 | 
			
		||||
| 
						 | 
				
			
			@ -49,7 +54,8 @@ function DeviceActiveTime({ ts }: { ts: number }) {
 | 
			
		|||
      <>
 | 
			
		||||
        {today(ts) && 'Today'}
 | 
			
		||||
        {yesterday(ts) && 'Yesterday'}
 | 
			
		||||
        {!today(ts) && !yesterday(ts) && timeDayMonYear(ts)} {timeHourMinute(ts)}
 | 
			
		||||
        {!today(ts) && !yesterday(ts) && timeDayMonYear(ts, dateFormatString)}{' '}
 | 
			
		||||
        {timeHourMinute(ts, hour24Clock)}
 | 
			
		||||
      </>
 | 
			
		||||
    </Text>
 | 
			
		||||
  );
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -11,6 +11,10 @@ import { useUIAMatrixError } from '../../../hooks/useUIAFlows';
 | 
			
		|||
import { DeviceVerificationStatus } from '../../../components/DeviceVerificationStatus';
 | 
			
		||||
import { VerifyOtherDeviceTile } from './Verification';
 | 
			
		||||
import { VerificationStatus } from '../../../hooks/useDeviceVerificationStatus';
 | 
			
		||||
import { useAuthMetadata } from '../../../hooks/useAuthMetadata';
 | 
			
		||||
import { withSearchParam } from '../../../pages/pathUtils';
 | 
			
		||||
import { useAccountManagementActions } from '../../../hooks/useAccountManagement';
 | 
			
		||||
import { SettingTile } from '../../../components/setting-tile';
 | 
			
		||||
 | 
			
		||||
type OtherDevicesProps = {
 | 
			
		||||
  devices: IMyDevice[];
 | 
			
		||||
| 
						 | 
				
			
			@ -20,8 +24,39 @@ type OtherDevicesProps = {
 | 
			
		|||
export function OtherDevices({ devices, refreshDeviceList, showVerification }: OtherDevicesProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const crypto = mx.getCrypto();
 | 
			
		||||
  const authMetadata = useAuthMetadata();
 | 
			
		||||
  const accountManagementActions = useAccountManagementActions();
 | 
			
		||||
 | 
			
		||||
  const [deleted, setDeleted] = useState<Set<string>>(new Set());
 | 
			
		||||
 | 
			
		||||
  const handleDashboardOIDC = useCallback(() => {
 | 
			
		||||
    const authUrl = authMetadata?.account_management_uri ?? authMetadata?.issuer;
 | 
			
		||||
    if (!authUrl) return;
 | 
			
		||||
 | 
			
		||||
    window.open(
 | 
			
		||||
      withSearchParam(authUrl, {
 | 
			
		||||
        action: accountManagementActions.sessionsList,
 | 
			
		||||
      }),
 | 
			
		||||
      '_blank'
 | 
			
		||||
    );
 | 
			
		||||
  }, [authMetadata, accountManagementActions]);
 | 
			
		||||
 | 
			
		||||
  const handleDeleteOIDC = useCallback(
 | 
			
		||||
    (deviceId: string) => {
 | 
			
		||||
      const authUrl = authMetadata?.account_management_uri ?? authMetadata?.issuer;
 | 
			
		||||
      if (!authUrl) return;
 | 
			
		||||
 | 
			
		||||
      window.open(
 | 
			
		||||
        withSearchParam(authUrl, {
 | 
			
		||||
          action: accountManagementActions.sessionEnd,
 | 
			
		||||
          device_id: deviceId,
 | 
			
		||||
        }),
 | 
			
		||||
        '_blank'
 | 
			
		||||
      );
 | 
			
		||||
    },
 | 
			
		||||
    [authMetadata, accountManagementActions]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const handleToggleDelete = useCallback((deviceId: string) => {
 | 
			
		||||
    setDeleted((deviceIds) => {
 | 
			
		||||
      const newIds = new Set(deviceIds);
 | 
			
		||||
| 
						 | 
				
			
			@ -70,6 +105,31 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O
 | 
			
		|||
    <>
 | 
			
		||||
      <Box direction="Column" gap="100">
 | 
			
		||||
        <Text size="L400">Others</Text>
 | 
			
		||||
        {authMetadata && (
 | 
			
		||||
          <SequenceCard
 | 
			
		||||
            className={SequenceCardStyle}
 | 
			
		||||
            variant="SurfaceVariant"
 | 
			
		||||
            direction="Column"
 | 
			
		||||
            gap="400"
 | 
			
		||||
          >
 | 
			
		||||
            <SettingTile
 | 
			
		||||
              title="Device Dashboard"
 | 
			
		||||
              description="Manage your devices on OIDC dashboard."
 | 
			
		||||
              after={
 | 
			
		||||
                <Button
 | 
			
		||||
                  size="300"
 | 
			
		||||
                  variant="Secondary"
 | 
			
		||||
                  fill="Soft"
 | 
			
		||||
                  radii="300"
 | 
			
		||||
                  outlined
 | 
			
		||||
                  onClick={handleDashboardOIDC}
 | 
			
		||||
                >
 | 
			
		||||
                  <Text size="B300">Open</Text>
 | 
			
		||||
                </Button>
 | 
			
		||||
              }
 | 
			
		||||
            />
 | 
			
		||||
          </SequenceCard>
 | 
			
		||||
        )}
 | 
			
		||||
        {devices
 | 
			
		||||
          .sort((d1, d2) => {
 | 
			
		||||
            if (!d1.last_seen_ts || !d2.last_seen_ts) return 0;
 | 
			
		||||
| 
						 | 
				
			
			@ -89,12 +149,20 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O
 | 
			
		|||
                refreshDeviceList={refreshDeviceList}
 | 
			
		||||
                disabled={deleting}
 | 
			
		||||
                options={
 | 
			
		||||
                  <DeviceDeleteBtn
 | 
			
		||||
                    deviceId={device.device_id}
 | 
			
		||||
                    deleted={deleted.has(device.device_id)}
 | 
			
		||||
                    onDeleteToggle={handleToggleDelete}
 | 
			
		||||
                    disabled={deleting}
 | 
			
		||||
                  />
 | 
			
		||||
                  authMetadata ? (
 | 
			
		||||
                    <DeviceDeleteBtn
 | 
			
		||||
                      deviceId={device.device_id}
 | 
			
		||||
                      deleted={false}
 | 
			
		||||
                      onDeleteToggle={handleDeleteOIDC}
 | 
			
		||||
                    />
 | 
			
		||||
                  ) : (
 | 
			
		||||
                    <DeviceDeleteBtn
 | 
			
		||||
                      deviceId={device.device_id}
 | 
			
		||||
                      deleted={deleted.has(device.device_id)}
 | 
			
		||||
                      onDeleteToggle={handleToggleDelete}
 | 
			
		||||
                      disabled={deleting}
 | 
			
		||||
                    />
 | 
			
		||||
                  )
 | 
			
		||||
                }
 | 
			
		||||
              />
 | 
			
		||||
              {showVerification && crypto && (
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -32,6 +32,9 @@ import {
 | 
			
		|||
  DeviceVerificationSetup,
 | 
			
		||||
} from '../../../components/DeviceVerificationSetup';
 | 
			
		||||
import { stopPropagation } from '../../../utils/keyboard';
 | 
			
		||||
import { useAuthMetadata } from '../../../hooks/useAuthMetadata';
 | 
			
		||||
import { withSearchParam } from '../../../pages/pathUtils';
 | 
			
		||||
import { useAccountManagementActions } from '../../../hooks/useAccountManagement';
 | 
			
		||||
 | 
			
		||||
type VerificationStatusBadgeProps = {
 | 
			
		||||
  verificationStatus: VerificationStatus;
 | 
			
		||||
| 
						 | 
				
			
			@ -252,6 +255,8 @@ export function EnableVerification({ visible }: EnableVerificationProps) {
 | 
			
		|||
 | 
			
		||||
export function DeviceVerificationOptions() {
 | 
			
		||||
  const [menuCords, setMenuCords] = useState<RectCords>();
 | 
			
		||||
  const authMetadata = useAuthMetadata();
 | 
			
		||||
  const accountManagementActions = useAccountManagementActions();
 | 
			
		||||
 | 
			
		||||
  const [reset, setReset] = useState(false);
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -265,6 +270,18 @@ export function DeviceVerificationOptions() {
 | 
			
		|||
 | 
			
		||||
  const handleReset = () => {
 | 
			
		||||
    setMenuCords(undefined);
 | 
			
		||||
 | 
			
		||||
    if (authMetadata) {
 | 
			
		||||
      const authUrl = authMetadata.account_management_uri ?? authMetadata.issuer;
 | 
			
		||||
      window.open(
 | 
			
		||||
        withSearchParam(authUrl, {
 | 
			
		||||
          action: accountManagementActions.crossSigningReset,
 | 
			
		||||
        }),
 | 
			
		||||
        '_blank'
 | 
			
		||||
      );
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    setReset(true);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -298,7 +315,7 @@ export function DeviceVerificationOptions() {
 | 
			
		|||
            }}
 | 
			
		||||
          >
 | 
			
		||||
            <Menu>
 | 
			
		||||
              <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
 | 
			
		||||
              <Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
 | 
			
		||||
                <MenuItem
 | 
			
		||||
                  variant="Critical"
 | 
			
		||||
                  onClick={handleReset}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,15 +1,19 @@
 | 
			
		|||
import React, {
 | 
			
		||||
  ChangeEventHandler,
 | 
			
		||||
  FormEventHandler,
 | 
			
		||||
  KeyboardEventHandler,
 | 
			
		||||
  MouseEventHandler,
 | 
			
		||||
  useEffect,
 | 
			
		||||
  useState,
 | 
			
		||||
} from 'react';
 | 
			
		||||
import dayjs from 'dayjs';
 | 
			
		||||
import {
 | 
			
		||||
  as,
 | 
			
		||||
  Box,
 | 
			
		||||
  Button,
 | 
			
		||||
  Chip,
 | 
			
		||||
  config,
 | 
			
		||||
  Header,
 | 
			
		||||
  Icon,
 | 
			
		||||
  IconButton,
 | 
			
		||||
  Icons,
 | 
			
		||||
| 
						 | 
				
			
			@ -28,7 +32,7 @@ import FocusTrap from 'focus-trap-react';
 | 
			
		|||
import { Page, PageContent, PageHeader } from '../../../components/page';
 | 
			
		||||
import { SequenceCard } from '../../../components/sequence-card';
 | 
			
		||||
import { useSetting } from '../../../state/hooks/settings';
 | 
			
		||||
import { MessageLayout, MessageSpacing, settingsAtom } from '../../../state/settings';
 | 
			
		||||
import { DateFormat, MessageLayout, MessageSpacing, settingsAtom } from '../../../state/settings';
 | 
			
		||||
import { SettingTile } from '../../../components/setting-tile';
 | 
			
		||||
import { KeySymbol } from '../../../utils/key-symbol';
 | 
			
		||||
import { isMacOS } from '../../../utils/user-agent';
 | 
			
		||||
| 
						 | 
				
			
			@ -44,6 +48,7 @@ import {
 | 
			
		|||
import { stopPropagation } from '../../../utils/keyboard';
 | 
			
		||||
import { useMessageLayoutItems } from '../../../hooks/useMessageLayout';
 | 
			
		||||
import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing';
 | 
			
		||||
import { useDateFormatItems } from '../../../hooks/useDateFormat';
 | 
			
		||||
import { SequenceCardStyle } from '../styles.css';
 | 
			
		||||
 | 
			
		||||
type ThemeSelectorProps = {
 | 
			
		||||
| 
						 | 
				
			
			@ -55,7 +60,7 @@ type ThemeSelectorProps = {
 | 
			
		|||
const ThemeSelector = as<'div', ThemeSelectorProps>(
 | 
			
		||||
  ({ themeNames, themes, selected, onSelect, ...props }, ref) => (
 | 
			
		||||
    <Menu {...props} ref={ref}>
 | 
			
		||||
      <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
 | 
			
		||||
      <Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
 | 
			
		||||
        {themes.map((theme) => (
 | 
			
		||||
          <MenuItem
 | 
			
		||||
            key={theme.id}
 | 
			
		||||
| 
						 | 
				
			
			@ -341,6 +346,359 @@ function Appearance() {
 | 
			
		|||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type DateHintProps = {
 | 
			
		||||
  hasChanges: boolean;
 | 
			
		||||
  handleReset: () => void;
 | 
			
		||||
};
 | 
			
		||||
function DateHint({ hasChanges, handleReset }: DateHintProps) {
 | 
			
		||||
  const [anchor, setAnchor] = useState<RectCords>();
 | 
			
		||||
  const categoryPadding = { padding: config.space.S200, paddingTop: 0 };
 | 
			
		||||
 | 
			
		||||
  const handleOpenMenu: MouseEventHandler<HTMLElement> = (evt) => {
 | 
			
		||||
    setAnchor(evt.currentTarget.getBoundingClientRect());
 | 
			
		||||
  };
 | 
			
		||||
  return (
 | 
			
		||||
    <PopOut
 | 
			
		||||
      anchor={anchor}
 | 
			
		||||
      position="Top"
 | 
			
		||||
      align="End"
 | 
			
		||||
      content={
 | 
			
		||||
        <FocusTrap
 | 
			
		||||
          focusTrapOptions={{
 | 
			
		||||
            initialFocus: false,
 | 
			
		||||
            onDeactivate: () => setAnchor(undefined),
 | 
			
		||||
            clickOutsideDeactivates: true,
 | 
			
		||||
            escapeDeactivates: stopPropagation,
 | 
			
		||||
          }}
 | 
			
		||||
        >
 | 
			
		||||
          <Menu style={{ maxHeight: '85vh', overflowY: 'auto' }}>
 | 
			
		||||
            <Header size="300" style={{ padding: `0 ${config.space.S200}` }}>
 | 
			
		||||
              <Text size="L400">Formatting</Text>
 | 
			
		||||
            </Header>
 | 
			
		||||
 | 
			
		||||
            <Box direction="Column">
 | 
			
		||||
              <Box style={categoryPadding} direction="Column">
 | 
			
		||||
                <Header size="300">
 | 
			
		||||
                  <Text size="L400">Year</Text>
 | 
			
		||||
                </Header>
 | 
			
		||||
                <Box direction="Column" tabIndex={0} gap="100">
 | 
			
		||||
                  <Text size="T300">
 | 
			
		||||
                    YY
 | 
			
		||||
                    <Text as="span" size="Inherit" priority="300">
 | 
			
		||||
                      {': '}
 | 
			
		||||
                      Two-digit year
 | 
			
		||||
                    </Text>{' '}
 | 
			
		||||
                  </Text>
 | 
			
		||||
                  <Text size="T300">
 | 
			
		||||
                    YYYY
 | 
			
		||||
                    <Text as="span" size="Inherit" priority="300">
 | 
			
		||||
                      {': '}Four-digit year
 | 
			
		||||
                    </Text>
 | 
			
		||||
                  </Text>
 | 
			
		||||
                </Box>
 | 
			
		||||
              </Box>
 | 
			
		||||
 | 
			
		||||
              <Box style={categoryPadding} direction="Column">
 | 
			
		||||
                <Header size="300">
 | 
			
		||||
                  <Text size="L400">Month</Text>
 | 
			
		||||
                </Header>
 | 
			
		||||
                <Box direction="Column" tabIndex={0} gap="100">
 | 
			
		||||
                  <Text size="T300">
 | 
			
		||||
                    M
 | 
			
		||||
                    <Text as="span" size="Inherit" priority="300">
 | 
			
		||||
                      {': '}The month
 | 
			
		||||
                    </Text>
 | 
			
		||||
                  </Text>
 | 
			
		||||
                  <Text size="T300">
 | 
			
		||||
                    MM
 | 
			
		||||
                    <Text as="span" size="Inherit" priority="300">
 | 
			
		||||
                      {': '}Two-digit month
 | 
			
		||||
                    </Text>{' '}
 | 
			
		||||
                  </Text>
 | 
			
		||||
                  <Text size="T300">
 | 
			
		||||
                    MMM
 | 
			
		||||
                    <Text as="span" size="Inherit" priority="300">
 | 
			
		||||
                      {': '}Short month name
 | 
			
		||||
                    </Text>
 | 
			
		||||
                  </Text>
 | 
			
		||||
                  <Text size="T300">
 | 
			
		||||
                    MMMM
 | 
			
		||||
                    <Text as="span" size="Inherit" priority="300">
 | 
			
		||||
                      {': '}Full month name
 | 
			
		||||
                    </Text>
 | 
			
		||||
                  </Text>
 | 
			
		||||
                </Box>
 | 
			
		||||
              </Box>
 | 
			
		||||
 | 
			
		||||
              <Box style={categoryPadding} direction="Column">
 | 
			
		||||
                <Header size="300">
 | 
			
		||||
                  <Text size="L400">Day of the Month</Text>
 | 
			
		||||
                </Header>
 | 
			
		||||
                <Box direction="Column" tabIndex={0} gap="100">
 | 
			
		||||
                  <Text size="T300">
 | 
			
		||||
                    D
 | 
			
		||||
                    <Text as="span" size="Inherit" priority="300">
 | 
			
		||||
                      {': '}Day of the month
 | 
			
		||||
                    </Text>
 | 
			
		||||
                  </Text>
 | 
			
		||||
                  <Text size="T300">
 | 
			
		||||
                    DD
 | 
			
		||||
                    <Text as="span" size="Inherit" priority="300">
 | 
			
		||||
                      {': '}Two-digit day of the month
 | 
			
		||||
                    </Text>
 | 
			
		||||
                  </Text>
 | 
			
		||||
                </Box>
 | 
			
		||||
              </Box>
 | 
			
		||||
              <Box style={categoryPadding} direction="Column">
 | 
			
		||||
                <Header size="300">
 | 
			
		||||
                  <Text size="L400">Day of the Week</Text>
 | 
			
		||||
                </Header>
 | 
			
		||||
                <Box direction="Column" tabIndex={0} gap="100">
 | 
			
		||||
                  <Text size="T300">
 | 
			
		||||
                    d
 | 
			
		||||
                    <Text as="span" size="Inherit" priority="300">
 | 
			
		||||
                      {': '}Day of the week (Sunday = 0)
 | 
			
		||||
                    </Text>
 | 
			
		||||
                  </Text>
 | 
			
		||||
                  <Text size="T300">
 | 
			
		||||
                    dd
 | 
			
		||||
                    <Text as="span" size="Inherit" priority="300">
 | 
			
		||||
                      {': '}Two-letter day name
 | 
			
		||||
                    </Text>
 | 
			
		||||
                  </Text>
 | 
			
		||||
                  <Text size="T300">
 | 
			
		||||
                    ddd
 | 
			
		||||
                    <Text as="span" size="Inherit" priority="300">
 | 
			
		||||
                      {': '}Short day name
 | 
			
		||||
                    </Text>
 | 
			
		||||
                  </Text>
 | 
			
		||||
                  <Text size="T300">
 | 
			
		||||
                    dddd
 | 
			
		||||
                    <Text as="span" size="Inherit" priority="300">
 | 
			
		||||
                      {': '}Full day name
 | 
			
		||||
                    </Text>
 | 
			
		||||
                  </Text>
 | 
			
		||||
                </Box>
 | 
			
		||||
              </Box>
 | 
			
		||||
            </Box>
 | 
			
		||||
          </Menu>
 | 
			
		||||
        </FocusTrap>
 | 
			
		||||
      }
 | 
			
		||||
    >
 | 
			
		||||
      {hasChanges ? (
 | 
			
		||||
        <IconButton
 | 
			
		||||
          tabIndex={-1}
 | 
			
		||||
          onClick={handleReset}
 | 
			
		||||
          type="reset"
 | 
			
		||||
          variant="Secondary"
 | 
			
		||||
          size="300"
 | 
			
		||||
          radii="300"
 | 
			
		||||
        >
 | 
			
		||||
          <Icon src={Icons.Cross} size="100" />
 | 
			
		||||
        </IconButton>
 | 
			
		||||
      ) : (
 | 
			
		||||
        <IconButton
 | 
			
		||||
          tabIndex={-1}
 | 
			
		||||
          onClick={handleOpenMenu}
 | 
			
		||||
          type="button"
 | 
			
		||||
          variant="Secondary"
 | 
			
		||||
          size="300"
 | 
			
		||||
          radii="300"
 | 
			
		||||
          aria-pressed={!!anchor}
 | 
			
		||||
        >
 | 
			
		||||
          <Icon style={{ opacity: config.opacity.P300 }} size="100" src={Icons.Info} />
 | 
			
		||||
        </IconButton>
 | 
			
		||||
      )}
 | 
			
		||||
    </PopOut>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type CustomDateFormatProps = {
 | 
			
		||||
  value: string;
 | 
			
		||||
  onChange: (format: string) => void;
 | 
			
		||||
};
 | 
			
		||||
function CustomDateFormat({ value, onChange }: CustomDateFormatProps) {
 | 
			
		||||
  const [dateFormatCustom, setDateFormatCustom] = useState(value);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    setDateFormatCustom(value);
 | 
			
		||||
  }, [value]);
 | 
			
		||||
 | 
			
		||||
  const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
 | 
			
		||||
    const format = evt.currentTarget.value;
 | 
			
		||||
    setDateFormatCustom(format);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleReset = () => {
 | 
			
		||||
    setDateFormatCustom(value);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
 | 
			
		||||
    evt.preventDefault();
 | 
			
		||||
 | 
			
		||||
    const target = evt.target as HTMLFormElement | undefined;
 | 
			
		||||
    const customDateFormatInput = target?.customDateFormatInput as HTMLInputElement | undefined;
 | 
			
		||||
    const format = customDateFormatInput?.value;
 | 
			
		||||
    if (!format) return;
 | 
			
		||||
 | 
			
		||||
    onChange(format);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const hasChanges = dateFormatCustom !== value;
 | 
			
		||||
  return (
 | 
			
		||||
    <SettingTile>
 | 
			
		||||
      <Box as="form" onSubmit={handleSubmit} gap="200">
 | 
			
		||||
        <Box grow="Yes" direction="Column">
 | 
			
		||||
          <Input
 | 
			
		||||
            required
 | 
			
		||||
            name="customDateFormatInput"
 | 
			
		||||
            value={dateFormatCustom}
 | 
			
		||||
            onChange={handleChange}
 | 
			
		||||
            maxLength={16}
 | 
			
		||||
            autoComplete="off"
 | 
			
		||||
            variant="Secondary"
 | 
			
		||||
            radii="300"
 | 
			
		||||
            style={{ paddingRight: config.space.S200 }}
 | 
			
		||||
            after={<DateHint hasChanges={hasChanges} handleReset={handleReset} />}
 | 
			
		||||
          />
 | 
			
		||||
        </Box>
 | 
			
		||||
        <Button
 | 
			
		||||
          size="400"
 | 
			
		||||
          variant={hasChanges ? 'Success' : 'Secondary'}
 | 
			
		||||
          fill={hasChanges ? 'Solid' : 'Soft'}
 | 
			
		||||
          outlined
 | 
			
		||||
          radii="300"
 | 
			
		||||
          disabled={!hasChanges}
 | 
			
		||||
          type="submit"
 | 
			
		||||
        >
 | 
			
		||||
          <Text size="B400">Save</Text>
 | 
			
		||||
        </Button>
 | 
			
		||||
      </Box>
 | 
			
		||||
    </SettingTile>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type PresetDateFormatProps = {
 | 
			
		||||
  value: string;
 | 
			
		||||
  onChange: (format: string) => void;
 | 
			
		||||
};
 | 
			
		||||
function PresetDateFormat({ value, onChange }: PresetDateFormatProps) {
 | 
			
		||||
  const [menuCords, setMenuCords] = useState<RectCords>();
 | 
			
		||||
  const dateFormatItems = useDateFormatItems();
 | 
			
		||||
 | 
			
		||||
  const getDisplayDate = (format: string): string =>
 | 
			
		||||
    format !== '' ? dayjs().format(format) : 'Custom';
 | 
			
		||||
 | 
			
		||||
  const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
 | 
			
		||||
    setMenuCords(evt.currentTarget.getBoundingClientRect());
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleSelect = (format: DateFormat) => {
 | 
			
		||||
    onChange(format);
 | 
			
		||||
    setMenuCords(undefined);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <Button
 | 
			
		||||
        size="300"
 | 
			
		||||
        variant="Secondary"
 | 
			
		||||
        outlined
 | 
			
		||||
        fill="Soft"
 | 
			
		||||
        radii="300"
 | 
			
		||||
        after={<Icon size="300" src={Icons.ChevronBottom} />}
 | 
			
		||||
        onClick={handleMenu}
 | 
			
		||||
      >
 | 
			
		||||
        <Text size="T300">
 | 
			
		||||
          {getDisplayDate(dateFormatItems.find((i) => i.format === value)?.format ?? value)}
 | 
			
		||||
        </Text>
 | 
			
		||||
      </Button>
 | 
			
		||||
      <PopOut
 | 
			
		||||
        anchor={menuCords}
 | 
			
		||||
        offset={5}
 | 
			
		||||
        position="Bottom"
 | 
			
		||||
        align="End"
 | 
			
		||||
        content={
 | 
			
		||||
          <FocusTrap
 | 
			
		||||
            focusTrapOptions={{
 | 
			
		||||
              initialFocus: false,
 | 
			
		||||
              onDeactivate: () => setMenuCords(undefined),
 | 
			
		||||
              clickOutsideDeactivates: true,
 | 
			
		||||
              isKeyForward: (evt: KeyboardEvent) =>
 | 
			
		||||
                evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
 | 
			
		||||
              isKeyBackward: (evt: KeyboardEvent) =>
 | 
			
		||||
                evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
 | 
			
		||||
              escapeDeactivates: stopPropagation,
 | 
			
		||||
            }}
 | 
			
		||||
          >
 | 
			
		||||
            <Menu>
 | 
			
		||||
              <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
 | 
			
		||||
                {dateFormatItems.map((item) => (
 | 
			
		||||
                  <MenuItem
 | 
			
		||||
                    key={item.format}
 | 
			
		||||
                    size="300"
 | 
			
		||||
                    variant={value === item.format ? 'Primary' : 'Surface'}
 | 
			
		||||
                    radii="300"
 | 
			
		||||
                    onClick={() => handleSelect(item.format)}
 | 
			
		||||
                  >
 | 
			
		||||
                    <Text size="T300">{getDisplayDate(item.format)}</Text>
 | 
			
		||||
                  </MenuItem>
 | 
			
		||||
                ))}
 | 
			
		||||
              </Box>
 | 
			
		||||
            </Menu>
 | 
			
		||||
          </FocusTrap>
 | 
			
		||||
        }
 | 
			
		||||
      />
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function SelectDateFormat() {
 | 
			
		||||
  const [dateFormatString, setDateFormatString] = useSetting(settingsAtom, 'dateFormatString');
 | 
			
		||||
  const [selectedDateFormat, setSelectedDateFormat] = useState(dateFormatString);
 | 
			
		||||
  const customDateFormat = selectedDateFormat === '';
 | 
			
		||||
 | 
			
		||||
  const handlePresetChange = (format: string) => {
 | 
			
		||||
    setSelectedDateFormat(format);
 | 
			
		||||
    if (format !== '') {
 | 
			
		||||
      setDateFormatString(format);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <SettingTile
 | 
			
		||||
        title="Date Format"
 | 
			
		||||
        description={customDateFormat ? dayjs().format(dateFormatString) : ''}
 | 
			
		||||
        after={<PresetDateFormat value={selectedDateFormat} onChange={handlePresetChange} />}
 | 
			
		||||
      />
 | 
			
		||||
      {customDateFormat && (
 | 
			
		||||
        <CustomDateFormat value={dateFormatString} onChange={setDateFormatString} />
 | 
			
		||||
      )}
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function DateAndTime() {
 | 
			
		||||
  const [hour24Clock, setHour24Clock] = useSetting(settingsAtom, 'hour24Clock');
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Box direction="Column" gap="100">
 | 
			
		||||
      <Text size="L400">Date & Time</Text>
 | 
			
		||||
      <SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
 | 
			
		||||
        <SettingTile
 | 
			
		||||
          title="24-Hour Time Format"
 | 
			
		||||
          after={<Switch variant="Primary" value={hour24Clock} onChange={setHour24Clock} />}
 | 
			
		||||
        />
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
 | 
			
		||||
      <SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
 | 
			
		||||
        <SelectDateFormat />
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function Editor() {
 | 
			
		||||
  const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline');
 | 
			
		||||
  const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown');
 | 
			
		||||
| 
						 | 
				
			
			@ -423,7 +781,7 @@ function SelectMessageLayout() {
 | 
			
		|||
            }}
 | 
			
		||||
          >
 | 
			
		||||
            <Menu>
 | 
			
		||||
              <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
 | 
			
		||||
              <Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
 | 
			
		||||
                {messageLayoutItems.map((item) => (
 | 
			
		||||
                  <MenuItem
 | 
			
		||||
                    key={item.layout}
 | 
			
		||||
| 
						 | 
				
			
			@ -492,7 +850,7 @@ function SelectMessageSpacing() {
 | 
			
		|||
            }}
 | 
			
		||||
          >
 | 
			
		||||
            <Menu>
 | 
			
		||||
              <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
 | 
			
		||||
              <Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
 | 
			
		||||
                {messageSpacingItems.map((item) => (
 | 
			
		||||
                  <MenuItem
 | 
			
		||||
                    key={item.spacing}
 | 
			
		||||
| 
						 | 
				
			
			@ -637,6 +995,7 @@ export function General({ requestClose }: GeneralProps) {
 | 
			
		|||
          <PageContent>
 | 
			
		||||
            <Box direction="Column" gap="700">
 | 
			
		||||
              <Appearance />
 | 
			
		||||
              <DateAndTime />
 | 
			
		||||
              <Editor />
 | 
			
		||||
              <Messages />
 | 
			
		||||
            </Box>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -92,7 +92,7 @@ export function NotificationModeSwitcher({ pushRule, onChange }: NotificationMod
 | 
			
		|||
            }}
 | 
			
		||||
          >
 | 
			
		||||
            <Menu>
 | 
			
		||||
              <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
 | 
			
		||||
              <Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
 | 
			
		||||
                {modes.map((mode) => (
 | 
			
		||||
                  <MenuItem
 | 
			
		||||
                    key={mode}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -5,7 +5,9 @@ import { SystemNotification } from './SystemNotification';
 | 
			
		|||
import { AllMessagesNotifications } from './AllMessages';
 | 
			
		||||
import { SpecialMessagesNotifications } from './SpecialMessages';
 | 
			
		||||
import { KeywordMessagesNotifications } from './KeywordMessages';
 | 
			
		||||
import { IgnoredUserList } from './IgnoredUserList';
 | 
			
		||||
import { SequenceCard } from '../../../components/sequence-card';
 | 
			
		||||
import { SequenceCardStyle } from '../styles.css';
 | 
			
		||||
import { SettingTile } from '../../../components/setting-tile';
 | 
			
		||||
 | 
			
		||||
type NotificationsProps = {
 | 
			
		||||
  requestClose: () => void;
 | 
			
		||||
| 
						 | 
				
			
			@ -35,7 +37,19 @@ export function Notifications({ requestClose }: NotificationsProps) {
 | 
			
		|||
              <AllMessagesNotifications />
 | 
			
		||||
              <SpecialMessagesNotifications />
 | 
			
		||||
              <KeywordMessagesNotifications />
 | 
			
		||||
              <IgnoredUserList />
 | 
			
		||||
              <Box direction="Column" gap="100">
 | 
			
		||||
                <Text size="L400">Block Messages</Text>
 | 
			
		||||
                <SequenceCard
 | 
			
		||||
                  className={SequenceCardStyle}
 | 
			
		||||
                  variant="SurfaceVariant"
 | 
			
		||||
                  direction="Column"
 | 
			
		||||
                  gap="400"
 | 
			
		||||
                >
 | 
			
		||||
                  <SettingTile
 | 
			
		||||
                    description={'This option has been moved to "Account > Block Users" section.'}
 | 
			
		||||
                  />
 | 
			
		||||
                </SequenceCard>
 | 
			
		||||
              </Box>
 | 
			
		||||
            </Box>
 | 
			
		||||
          </PageContent>
 | 
			
		||||
        </Scroll>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										17
									
								
								src/app/hooks/useAccountManagement.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								src/app/hooks/useAccountManagement.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,17 @@
 | 
			
		|||
import { useMemo } from 'react';
 | 
			
		||||
 | 
			
		||||
export const useAccountManagementActions = () => {
 | 
			
		||||
  const actions = useMemo(
 | 
			
		||||
    () => ({
 | 
			
		||||
      profile: 'org.matrix.profile',
 | 
			
		||||
      sessionsList: 'org.matrix.sessions_list',
 | 
			
		||||
      sessionView: 'org.matrix.session_view',
 | 
			
		||||
      sessionEnd: 'org.matrix.session_end',
 | 
			
		||||
      accountDeactivate: 'org.matrix.account_deactivate',
 | 
			
		||||
      crossSigningReset: 'org.matrix.cross_signing_reset',
 | 
			
		||||
    }),
 | 
			
		||||
    []
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return actions;
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										12
									
								
								src/app/hooks/useAuthMetadata.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								src/app/hooks/useAuthMetadata.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,12 @@
 | 
			
		|||
import { ValidatedAuthMetadata } from 'matrix-js-sdk';
 | 
			
		||||
import { createContext, useContext } from 'react';
 | 
			
		||||
 | 
			
		||||
const AuthMetadataContext = createContext<ValidatedAuthMetadata | undefined>(undefined);
 | 
			
		||||
 | 
			
		||||
export const AuthMetadataProvider = AuthMetadataContext.Provider;
 | 
			
		||||
 | 
			
		||||
export const useAuthMetadata = (): ValidatedAuthMetadata | undefined => {
 | 
			
		||||
  const metadata = useContext(AuthMetadataContext);
 | 
			
		||||
 | 
			
		||||
  return metadata;
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -1,34 +1,127 @@
 | 
			
		|||
import { MatrixClient, Room } from 'matrix-js-sdk';
 | 
			
		||||
import { Direction, IContextResponse, MatrixClient, Method, Room, RoomMember } from 'matrix-js-sdk';
 | 
			
		||||
import { RoomServerAclEventContent } from 'matrix-js-sdk/lib/types';
 | 
			
		||||
import { useMemo } from 'react';
 | 
			
		||||
import { getDMRoomFor, isRoomAlias, isRoomId, isUserId } from '../utils/matrix';
 | 
			
		||||
import {
 | 
			
		||||
  getDMRoomFor,
 | 
			
		||||
  isRoomAlias,
 | 
			
		||||
  isRoomId,
 | 
			
		||||
  isServerName,
 | 
			
		||||
  isUserId,
 | 
			
		||||
  rateLimitedActions,
 | 
			
		||||
} from '../utils/matrix';
 | 
			
		||||
import { hasDevices } from '../../util/matrixUtil';
 | 
			
		||||
import * as roomActions from '../../client/action/room';
 | 
			
		||||
import { useRoomNavigate } from './useRoomNavigate';
 | 
			
		||||
import { Membership, StateEvent } from '../../types/matrix/room';
 | 
			
		||||
import { getStateEvent } from '../utils/room';
 | 
			
		||||
import { splitWithSpace } from '../utils/common';
 | 
			
		||||
 | 
			
		||||
export const SHRUG = '¯\\_(ツ)_/¯';
 | 
			
		||||
export const TABLEFLIP = '(╯°□°)╯︵ ┻━┻';
 | 
			
		||||
export const UNFLIP = '┬─┬ノ( º_ºノ)';
 | 
			
		||||
 | 
			
		||||
export function parseUsersAndReason(payload: string): {
 | 
			
		||||
  users: string[];
 | 
			
		||||
  reason?: string;
 | 
			
		||||
} {
 | 
			
		||||
  let reason: string | undefined;
 | 
			
		||||
  let ids: string = payload;
 | 
			
		||||
const FLAG_PAT = '(?:^|\\s)-(\\w+)\\b';
 | 
			
		||||
const FLAG_REG = new RegExp(FLAG_PAT);
 | 
			
		||||
const FLAG_REG_G = new RegExp(FLAG_PAT, 'g');
 | 
			
		||||
 | 
			
		||||
  const reasonMatch = payload.match(/\s-r\s/);
 | 
			
		||||
  if (reasonMatch) {
 | 
			
		||||
    ids = payload.slice(0, reasonMatch.index);
 | 
			
		||||
    reason = payload.slice((reasonMatch.index ?? 0) + reasonMatch[0].length);
 | 
			
		||||
    if (reason.trim() === '') reason = undefined;
 | 
			
		||||
export const splitPayloadContentAndFlags = (payload: string): [string, string | undefined] => {
 | 
			
		||||
  const flagMatch = payload.match(FLAG_REG);
 | 
			
		||||
 | 
			
		||||
  if (!flagMatch) {
 | 
			
		||||
    return [payload, undefined];
 | 
			
		||||
  }
 | 
			
		||||
  const rawIds = ids.split(' ');
 | 
			
		||||
  const users = rawIds.filter((id) => isUserId(id));
 | 
			
		||||
  return {
 | 
			
		||||
    users,
 | 
			
		||||
    reason,
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
  const content = payload.slice(0, flagMatch.index);
 | 
			
		||||
  const flags = payload.slice(flagMatch.index);
 | 
			
		||||
 | 
			
		||||
  return [content, flags];
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const parseFlags = (flags: string | undefined): Record<string, string | undefined> => {
 | 
			
		||||
  const result: Record<string, string> = {};
 | 
			
		||||
  if (!flags) return result;
 | 
			
		||||
 | 
			
		||||
  const matches: { key: string; index: number; match: string }[] = [];
 | 
			
		||||
 | 
			
		||||
  for (let match = FLAG_REG_G.exec(flags); match !== null; match = FLAG_REG_G.exec(flags)) {
 | 
			
		||||
    matches.push({ key: match[1], index: match.index, match: match[0] });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  for (let i = 0; i < matches.length; i += 1) {
 | 
			
		||||
    const { key, match } = matches[i];
 | 
			
		||||
    const start = matches[i].index + match.length;
 | 
			
		||||
    const end = i + 1 < matches.length ? matches[i + 1].index : flags.length;
 | 
			
		||||
    const value = flags.slice(start, end).trim();
 | 
			
		||||
    result[key] = value;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return result;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const parseUsers = (payload: string): string[] => {
 | 
			
		||||
  const users: string[] = [];
 | 
			
		||||
 | 
			
		||||
  splitWithSpace(payload).forEach((item) => {
 | 
			
		||||
    if (isUserId(item)) {
 | 
			
		||||
      users.push(item);
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  return users;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const parseServers = (payload: string): string[] => {
 | 
			
		||||
  const servers: string[] = [];
 | 
			
		||||
 | 
			
		||||
  splitWithSpace(payload).forEach((item) => {
 | 
			
		||||
    if (isServerName(item)) {
 | 
			
		||||
      servers.push(item);
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  return servers;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const getServerMembers = (room: Room, server: string): RoomMember[] => {
 | 
			
		||||
  const members: RoomMember[] = room
 | 
			
		||||
    .getMembers()
 | 
			
		||||
    .filter((member) => member.userId.endsWith(`:${server}`));
 | 
			
		||||
 | 
			
		||||
  return members;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const parseTimestampFlag = (input: string): number | undefined => {
 | 
			
		||||
  const match = input.match(/^(\d+(?:\.\d+)?)([dhms])$/); // supports floats like 1.5d
 | 
			
		||||
 | 
			
		||||
  if (!match) {
 | 
			
		||||
    return undefined;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const value = parseFloat(match[1]); // supports decimal values
 | 
			
		||||
  const unit = match[2];
 | 
			
		||||
 | 
			
		||||
  const now = Date.now(); // in milliseconds
 | 
			
		||||
  let delta = 0;
 | 
			
		||||
 | 
			
		||||
  switch (unit) {
 | 
			
		||||
    case 'd':
 | 
			
		||||
      delta = value * 24 * 60 * 60 * 1000;
 | 
			
		||||
      break;
 | 
			
		||||
    case 'h':
 | 
			
		||||
      delta = value * 60 * 60 * 1000;
 | 
			
		||||
      break;
 | 
			
		||||
    case 'm':
 | 
			
		||||
      delta = value * 60 * 1000;
 | 
			
		||||
      break;
 | 
			
		||||
    case 's':
 | 
			
		||||
      delta = value * 1000;
 | 
			
		||||
      break;
 | 
			
		||||
    default:
 | 
			
		||||
      return undefined;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const timestamp = now - delta;
 | 
			
		||||
  return timestamp;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type CommandExe = (payload: string) => Promise<void>;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -52,6 +145,8 @@ export enum Command {
 | 
			
		|||
  ConvertToRoom = 'converttoroom',
 | 
			
		||||
  TableFlip = 'tableflip',
 | 
			
		||||
  UnFlip = 'unflip',
 | 
			
		||||
  Delete = 'delete',
 | 
			
		||||
  Acl = 'acl',
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type CommandContent = {
 | 
			
		||||
| 
						 | 
				
			
			@ -96,7 +191,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
 | 
			
		|||
        name: Command.StartDm,
 | 
			
		||||
        description: 'Start direct message with user. Example: /startdm userId1',
 | 
			
		||||
        exe: async (payload) => {
 | 
			
		||||
          const rawIds = payload.split(' ');
 | 
			
		||||
          const rawIds = splitWithSpace(payload);
 | 
			
		||||
          const userIds = rawIds.filter((id) => isUserId(id) && id !== mx.getUserId());
 | 
			
		||||
          if (userIds.length === 0) return;
 | 
			
		||||
          if (userIds.length === 1) {
 | 
			
		||||
| 
						 | 
				
			
			@ -106,7 +201,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
 | 
			
		|||
              return;
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
          const devices = await Promise.all(userIds.map(uid => hasDevices(mx, uid)));
 | 
			
		||||
          const devices = await Promise.all(userIds.map((uid) => hasDevices(mx, uid)));
 | 
			
		||||
          const isEncrypt = devices.every((hasDevice) => hasDevice);
 | 
			
		||||
          const result = await roomActions.createDM(mx, userIds, isEncrypt);
 | 
			
		||||
          navigateRoom(result.room_id);
 | 
			
		||||
| 
						 | 
				
			
			@ -116,7 +211,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
 | 
			
		|||
        name: Command.Join,
 | 
			
		||||
        description: 'Join room with address. Example: /join address1 address2',
 | 
			
		||||
        exe: async (payload) => {
 | 
			
		||||
          const rawIds = payload.split(' ');
 | 
			
		||||
          const rawIds = splitWithSpace(payload);
 | 
			
		||||
          const roomIds = rawIds.filter(
 | 
			
		||||
            (idOrAlias) => isRoomId(idOrAlias) || isRoomAlias(idOrAlias)
 | 
			
		||||
          );
 | 
			
		||||
| 
						 | 
				
			
			@ -131,7 +226,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
 | 
			
		|||
            mx.leave(room.roomId);
 | 
			
		||||
            return;
 | 
			
		||||
          }
 | 
			
		||||
          const rawIds = payload.split(' ');
 | 
			
		||||
          const rawIds = splitWithSpace(payload);
 | 
			
		||||
          const roomIds = rawIds.filter((id) => isRoomId(id));
 | 
			
		||||
          roomIds.map((id) => mx.leave(id));
 | 
			
		||||
        },
 | 
			
		||||
| 
						 | 
				
			
			@ -140,7 +235,10 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
 | 
			
		|||
        name: Command.Invite,
 | 
			
		||||
        description: 'Invite user to room. Example: /invite userId1 userId2 [-r reason]',
 | 
			
		||||
        exe: async (payload) => {
 | 
			
		||||
          const { users, reason } = parseUsersAndReason(payload);
 | 
			
		||||
          const [content, flags] = splitPayloadContentAndFlags(payload);
 | 
			
		||||
          const users = parseUsers(content);
 | 
			
		||||
          const flagToContent = parseFlags(flags);
 | 
			
		||||
          const reason = flagToContent.r;
 | 
			
		||||
          users.map((id) => mx.invite(room.roomId, id, reason));
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
| 
						 | 
				
			
			@ -148,31 +246,64 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
 | 
			
		|||
        name: Command.DisInvite,
 | 
			
		||||
        description: 'Disinvite user to room. Example: /disinvite userId1 userId2 [-r reason]',
 | 
			
		||||
        exe: async (payload) => {
 | 
			
		||||
          const { users, reason } = parseUsersAndReason(payload);
 | 
			
		||||
          const [content, flags] = splitPayloadContentAndFlags(payload);
 | 
			
		||||
          const users = parseUsers(content);
 | 
			
		||||
          const flagToContent = parseFlags(flags);
 | 
			
		||||
          const reason = flagToContent.r;
 | 
			
		||||
          users.map((id) => mx.kick(room.roomId, id, reason));
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      [Command.Kick]: {
 | 
			
		||||
        name: Command.Kick,
 | 
			
		||||
        description: 'Kick user from room. Example: /kick userId1 userId2 [-r reason]',
 | 
			
		||||
        description: 'Kick user from room. Example: /kick userId1 userId2 servername [-r reason]',
 | 
			
		||||
        exe: async (payload) => {
 | 
			
		||||
          const { users, reason } = parseUsersAndReason(payload);
 | 
			
		||||
          users.map((id) => mx.kick(room.roomId, id, reason));
 | 
			
		||||
          const [content, flags] = splitPayloadContentAndFlags(payload);
 | 
			
		||||
          const users = parseUsers(content);
 | 
			
		||||
          const servers = parseServers(content);
 | 
			
		||||
          const flagToContent = parseFlags(flags);
 | 
			
		||||
          const reason = flagToContent.r;
 | 
			
		||||
 | 
			
		||||
          const serverMembers = servers?.flatMap((server) => getServerMembers(room, server));
 | 
			
		||||
          const serverUsers = serverMembers
 | 
			
		||||
            ?.filter((m) => m.membership !== Membership.Ban)
 | 
			
		||||
            .map((m) => m.userId);
 | 
			
		||||
 | 
			
		||||
          if (Array.isArray(serverUsers)) {
 | 
			
		||||
            serverUsers.forEach((user) => {
 | 
			
		||||
              if (!users.includes(user)) users.push(user);
 | 
			
		||||
            });
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          rateLimitedActions(users, (id) => mx.kick(room.roomId, id, reason));
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      [Command.Ban]: {
 | 
			
		||||
        name: Command.Ban,
 | 
			
		||||
        description: 'Ban user from room. Example: /ban userId1 userId2 [-r reason]',
 | 
			
		||||
        description: 'Ban user from room. Example: /ban userId1 userId2 servername [-r reason]',
 | 
			
		||||
        exe: async (payload) => {
 | 
			
		||||
          const { users, reason } = parseUsersAndReason(payload);
 | 
			
		||||
          users.map((id) => mx.ban(room.roomId, id, reason));
 | 
			
		||||
          const [content, flags] = splitPayloadContentAndFlags(payload);
 | 
			
		||||
          const users = parseUsers(content);
 | 
			
		||||
          const servers = parseServers(content);
 | 
			
		||||
          const flagToContent = parseFlags(flags);
 | 
			
		||||
          const reason = flagToContent.r;
 | 
			
		||||
 | 
			
		||||
          const serverMembers = servers?.flatMap((server) => getServerMembers(room, server));
 | 
			
		||||
          const serverUsers = serverMembers?.map((m) => m.userId);
 | 
			
		||||
 | 
			
		||||
          if (Array.isArray(serverUsers)) {
 | 
			
		||||
            serverUsers.forEach((user) => {
 | 
			
		||||
              if (!users.includes(user)) users.push(user);
 | 
			
		||||
            });
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          rateLimitedActions(users, (id) => mx.ban(room.roomId, id, reason));
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      [Command.UnBan]: {
 | 
			
		||||
        name: Command.UnBan,
 | 
			
		||||
        description: 'Unban user from room. Example: /unban userId1 userId2',
 | 
			
		||||
        exe: async (payload) => {
 | 
			
		||||
          const rawIds = payload.split(' ');
 | 
			
		||||
          const rawIds = splitWithSpace(payload);
 | 
			
		||||
          const users = rawIds.filter((id) => isUserId(id));
 | 
			
		||||
          users.map((id) => mx.unban(room.roomId, id));
 | 
			
		||||
        },
 | 
			
		||||
| 
						 | 
				
			
			@ -181,7 +312,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
 | 
			
		|||
        name: Command.Ignore,
 | 
			
		||||
        description: 'Ignore user. Example: /ignore userId1 userId2',
 | 
			
		||||
        exe: async (payload) => {
 | 
			
		||||
          const rawIds = payload.split(' ');
 | 
			
		||||
          const rawIds = splitWithSpace(payload);
 | 
			
		||||
          const userIds = rawIds.filter((id) => isUserId(id));
 | 
			
		||||
          if (userIds.length > 0) roomActions.ignore(mx, userIds);
 | 
			
		||||
        },
 | 
			
		||||
| 
						 | 
				
			
			@ -190,7 +321,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
 | 
			
		|||
        name: Command.UnIgnore,
 | 
			
		||||
        description: 'Unignore user. Example: /unignore userId1 userId2',
 | 
			
		||||
        exe: async (payload) => {
 | 
			
		||||
          const rawIds = payload.split(' ');
 | 
			
		||||
          const rawIds = splitWithSpace(payload);
 | 
			
		||||
          const userIds = rawIds.filter((id) => isUserId(id));
 | 
			
		||||
          if (userIds.length > 0) roomActions.unignore(mx, userIds);
 | 
			
		||||
        },
 | 
			
		||||
| 
						 | 
				
			
			@ -227,6 +358,124 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
 | 
			
		|||
          roomActions.convertToRoom(mx, room.roomId);
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      [Command.Delete]: {
 | 
			
		||||
        name: Command.Delete,
 | 
			
		||||
        description:
 | 
			
		||||
          'Delete messages from users. Example: /delete userId1 servername -past 1d|2h|5m|30s [-t m.room.message] [-r spam]',
 | 
			
		||||
        exe: async (payload) => {
 | 
			
		||||
          const [content, flags] = splitPayloadContentAndFlags(payload);
 | 
			
		||||
          const users = parseUsers(content);
 | 
			
		||||
          const servers = parseServers(content);
 | 
			
		||||
 | 
			
		||||
          const flagToContent = parseFlags(flags);
 | 
			
		||||
          const reason = flagToContent.r;
 | 
			
		||||
          const pastContent = flagToContent.past ?? '';
 | 
			
		||||
          const msgTypeContent = flagToContent.t;
 | 
			
		||||
          const messageTypes: string[] = msgTypeContent ? splitWithSpace(msgTypeContent) : [];
 | 
			
		||||
 | 
			
		||||
          const ts = parseTimestampFlag(pastContent);
 | 
			
		||||
          if (!ts) return;
 | 
			
		||||
 | 
			
		||||
          const serverMembers = servers?.flatMap((server) => getServerMembers(room, server));
 | 
			
		||||
          const serverUsers = serverMembers?.map((m) => m.userId);
 | 
			
		||||
 | 
			
		||||
          if (Array.isArray(serverUsers)) {
 | 
			
		||||
            serverUsers.forEach((user) => {
 | 
			
		||||
              if (!users.includes(user)) users.push(user);
 | 
			
		||||
            });
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          const result = await mx.timestampToEvent(room.roomId, ts, Direction.Forward);
 | 
			
		||||
          const startEventId = result.event_id;
 | 
			
		||||
 | 
			
		||||
          const path = `/rooms/${encodeURIComponent(room.roomId)}/context/${encodeURIComponent(
 | 
			
		||||
            startEventId
 | 
			
		||||
          )}`;
 | 
			
		||||
          const eventContext = await mx.http.authedRequest<IContextResponse>(Method.Get, path, {
 | 
			
		||||
            limit: 0,
 | 
			
		||||
          });
 | 
			
		||||
 | 
			
		||||
          let token: string | undefined = eventContext.start;
 | 
			
		||||
          while (token) {
 | 
			
		||||
            // eslint-disable-next-line no-await-in-loop
 | 
			
		||||
            const response = await mx.createMessagesRequest(
 | 
			
		||||
              room.roomId,
 | 
			
		||||
              token,
 | 
			
		||||
              20,
 | 
			
		||||
              Direction.Forward,
 | 
			
		||||
              undefined
 | 
			
		||||
            );
 | 
			
		||||
            const { end, chunk } = response;
 | 
			
		||||
            // remove until the latest event;
 | 
			
		||||
            token = end;
 | 
			
		||||
 | 
			
		||||
            const eventsToDelete = chunk.filter(
 | 
			
		||||
              (roomEvent) =>
 | 
			
		||||
                (messageTypes.length > 0 ? messageTypes.includes(roomEvent.type) : true) &&
 | 
			
		||||
                users.includes(roomEvent.sender) &&
 | 
			
		||||
                roomEvent.unsigned?.redacted_because === undefined
 | 
			
		||||
            );
 | 
			
		||||
 | 
			
		||||
            const eventIds = eventsToDelete.map((roomEvent) => roomEvent.event_id);
 | 
			
		||||
 | 
			
		||||
            // eslint-disable-next-line no-await-in-loop
 | 
			
		||||
            await rateLimitedActions(eventIds, (eventId) =>
 | 
			
		||||
              mx.redactEvent(room.roomId, eventId, undefined, { reason })
 | 
			
		||||
            );
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      [Command.Acl]: {
 | 
			
		||||
        name: Command.Acl,
 | 
			
		||||
        description:
 | 
			
		||||
          'Manage server access control list. Example /acl [-a servername1] [-d servername2] [-ra servername1] [-rd servername2]',
 | 
			
		||||
        exe: async (payload) => {
 | 
			
		||||
          const [, flags] = splitPayloadContentAndFlags(payload);
 | 
			
		||||
 | 
			
		||||
          const flagToContent = parseFlags(flags);
 | 
			
		||||
          const allowFlag = flagToContent.a;
 | 
			
		||||
          const denyFlag = flagToContent.d;
 | 
			
		||||
          const removeAllowFlag = flagToContent.ra;
 | 
			
		||||
          const removeDenyFlag = flagToContent.rd;
 | 
			
		||||
 | 
			
		||||
          const allowList = allowFlag ? splitWithSpace(allowFlag) : [];
 | 
			
		||||
          const denyList = denyFlag ? splitWithSpace(denyFlag) : [];
 | 
			
		||||
          const removeAllowList = removeAllowFlag ? splitWithSpace(removeAllowFlag) : [];
 | 
			
		||||
          const removeDenyList = removeDenyFlag ? splitWithSpace(removeDenyFlag) : [];
 | 
			
		||||
 | 
			
		||||
          const serverAcl = getStateEvent(
 | 
			
		||||
            room,
 | 
			
		||||
            StateEvent.RoomServerAcl
 | 
			
		||||
          )?.getContent<RoomServerAclEventContent>();
 | 
			
		||||
 | 
			
		||||
          const aclContent: RoomServerAclEventContent = {
 | 
			
		||||
            allow: serverAcl?.allow ? [...serverAcl.allow] : [],
 | 
			
		||||
            allow_ip_literals: serverAcl?.allow_ip_literals,
 | 
			
		||||
            deny: serverAcl?.deny ? [...serverAcl.deny] : [],
 | 
			
		||||
          };
 | 
			
		||||
 | 
			
		||||
          allowList.forEach((servername) => {
 | 
			
		||||
            if (!Array.isArray(aclContent.allow) || aclContent.allow.includes(servername)) return;
 | 
			
		||||
            aclContent.allow.push(servername);
 | 
			
		||||
          });
 | 
			
		||||
          denyList.forEach((servername) => {
 | 
			
		||||
            if (!Array.isArray(aclContent.deny) || aclContent.deny.includes(servername)) return;
 | 
			
		||||
            aclContent.deny.push(servername);
 | 
			
		||||
          });
 | 
			
		||||
 | 
			
		||||
          aclContent.allow = aclContent.allow?.filter(
 | 
			
		||||
            (servername) => !removeAllowList.includes(servername)
 | 
			
		||||
          );
 | 
			
		||||
          aclContent.deny = aclContent.deny?.filter(
 | 
			
		||||
            (servername) => !removeDenyList.includes(servername)
 | 
			
		||||
          );
 | 
			
		||||
 | 
			
		||||
          aclContent.allow?.sort();
 | 
			
		||||
          aclContent.deny?.sort();
 | 
			
		||||
 | 
			
		||||
          await mx.sendStateEvent(room.roomId, StateEvent.RoomServerAcl as any, aclContent);
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
    }),
 | 
			
		||||
    [mx, room, navigateRoom]
 | 
			
		||||
  );
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										34
									
								
								src/app/hooks/useDateFormat.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								src/app/hooks/useDateFormat.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,34 @@
 | 
			
		|||
import { useMemo } from 'react';
 | 
			
		||||
import { DateFormat } from '../state/settings';
 | 
			
		||||
 | 
			
		||||
export type DateFormatItem = {
 | 
			
		||||
  name: string;
 | 
			
		||||
  format: DateFormat;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const useDateFormatItems = (): DateFormatItem[] =>
 | 
			
		||||
  useMemo(
 | 
			
		||||
    () => [
 | 
			
		||||
      {
 | 
			
		||||
        format: 'D MMM YYYY',
 | 
			
		||||
        name: 'D MMM YYYY',
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        format: 'DD/MM/YYYY',
 | 
			
		||||
        name: 'DD/MM/YYYY',
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        format: 'MM/DD/YYYY',
 | 
			
		||||
        name: 'MM/DD/YYYY',
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        format: 'YYYY/MM/DD',
 | 
			
		||||
        name: 'YYYY/MM/DD',
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        format: '',
 | 
			
		||||
        name: 'Custom',
 | 
			
		||||
      },
 | 
			
		||||
    ],
 | 
			
		||||
    []
 | 
			
		||||
  );
 | 
			
		||||
| 
						 | 
				
			
			@ -1,7 +1,7 @@
 | 
			
		|||
import { useEffect, useCallback, useMemo } from 'react';
 | 
			
		||||
import { IMyDevice } from 'matrix-js-sdk';
 | 
			
		||||
import { useQuery } from '@tanstack/react-query';
 | 
			
		||||
import { CryptoEvent, CryptoEventHandlerMap } from 'matrix-js-sdk/lib/crypto';
 | 
			
		||||
import { CryptoEvent, CryptoEventHandlerMap } from 'matrix-js-sdk/lib/crypto-api';
 | 
			
		||||
import { useMatrixClient } from './useMatrixClient';
 | 
			
		||||
 | 
			
		||||
export const useDeviceListChange = (
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										10
									
								
								src/app/hooks/useReportRoomSupported.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/app/hooks/useReportRoomSupported.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,10 @@
 | 
			
		|||
import { useSpecVersions } from './useSpecVersions';
 | 
			
		||||
 | 
			
		||||
export const useReportRoomSupported = (): boolean => {
 | 
			
		||||
  const { versions, unstable_features: unstableFeatures } = useSpecVersions();
 | 
			
		||||
 | 
			
		||||
  // report room is introduced in spec version 1.13
 | 
			
		||||
  const supported = unstableFeatures?.['org.matrix.msc4151'] || versions.includes('v1.13');
 | 
			
		||||
 | 
			
		||||
  return supported;
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -9,10 +9,12 @@ import {
 | 
			
		|||
  getSpaceRoomPath,
 | 
			
		||||
} from '../pages/pathUtils';
 | 
			
		||||
import { useMatrixClient } from './useMatrixClient';
 | 
			
		||||
import { getOrphanParents } from '../utils/room';
 | 
			
		||||
import { getOrphanParents, guessPerfectParent } from '../utils/room';
 | 
			
		||||
import { roomToParentsAtom } from '../state/room/roomToParents';
 | 
			
		||||
import { mDirectAtom } from '../state/mDirectList';
 | 
			
		||||
import { useSelectedSpace } from './router/useSelectedSpace';
 | 
			
		||||
import { settingsAtom } from '../state/settings';
 | 
			
		||||
import { useSetting } from '../state/hooks/settings';
 | 
			
		||||
 | 
			
		||||
export const useRoomNavigate = () => {
 | 
			
		||||
  const navigate = useNavigate();
 | 
			
		||||
| 
						 | 
				
			
			@ -20,6 +22,7 @@ export const useRoomNavigate = () => {
 | 
			
		|||
  const roomToParents = useAtomValue(roomToParentsAtom);
 | 
			
		||||
  const mDirects = useAtomValue(mDirectAtom);
 | 
			
		||||
  const spaceSelectedId = useSelectedSpace();
 | 
			
		||||
  const [developerTools] = useSetting(settingsAtom, 'developerTools');
 | 
			
		||||
 | 
			
		||||
  const navigateSpace = useCallback(
 | 
			
		||||
    (roomId: string) => {
 | 
			
		||||
| 
						 | 
				
			
			@ -32,16 +35,23 @@ export const useRoomNavigate = () => {
 | 
			
		|||
  const navigateRoom = useCallback(
 | 
			
		||||
    (roomId: string, eventId?: string, opts?: NavigateOptions) => {
 | 
			
		||||
      const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId);
 | 
			
		||||
      const openSpaceTimeline = developerTools && spaceSelectedId === roomId;
 | 
			
		||||
 | 
			
		||||
      const orphanParents = getOrphanParents(roomToParents, roomId);
 | 
			
		||||
      const orphanParents = openSpaceTimeline ? [roomId] : getOrphanParents(roomToParents, roomId);
 | 
			
		||||
      if (orphanParents.length > 0) {
 | 
			
		||||
        const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(
 | 
			
		||||
          mx,
 | 
			
		||||
          spaceSelectedId && orphanParents.includes(spaceSelectedId)
 | 
			
		||||
            ? spaceSelectedId
 | 
			
		||||
            : orphanParents[0]
 | 
			
		||||
        let parentSpace: string;
 | 
			
		||||
        if (spaceSelectedId && orphanParents.includes(spaceSelectedId)) {
 | 
			
		||||
          parentSpace = spaceSelectedId;
 | 
			
		||||
        } else {
 | 
			
		||||
          parentSpace = guessPerfectParent(mx, roomId, orphanParents) ?? orphanParents[0];
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(mx, parentSpace);
 | 
			
		||||
 | 
			
		||||
        navigate(
 | 
			
		||||
          getSpaceRoomPath(pSpaceIdOrAlias, openSpaceTimeline ? roomId : roomIdOrAlias, eventId),
 | 
			
		||||
          opts
 | 
			
		||||
        );
 | 
			
		||||
        navigate(getSpaceRoomPath(pSpaceIdOrAlias, roomIdOrAlias, eventId), opts);
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -52,7 +62,7 @@ export const useRoomNavigate = () => {
 | 
			
		|||
 | 
			
		||||
      navigate(getHomeRoomPath(roomIdOrAlias, eventId), opts);
 | 
			
		||||
    },
 | 
			
		||||
    [mx, navigate, spaceSelectedId, roomToParents, mDirects]
 | 
			
		||||
    [mx, navigate, spaceSelectedId, roomToParents, mDirects, developerTools]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										37
									
								
								src/app/hooks/useTimeoutToggle.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								src/app/hooks/useTimeoutToggle.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,37 @@
 | 
			
		|||
import { useCallback, useEffect, useRef, useState } from 'react';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Temporarily sets a boolean state.
 | 
			
		||||
 *
 | 
			
		||||
 * @param duration - Duration in milliseconds before resetting (default: 1500)
 | 
			
		||||
 * @param initial - Initial value (default: false)
 | 
			
		||||
 */
 | 
			
		||||
export function useTimeoutToggle(duration = 1500, initial = false): [boolean, () => void] {
 | 
			
		||||
  const [active, setActive] = useState(initial);
 | 
			
		||||
  const timeoutRef = useRef<number | null>(null);
 | 
			
		||||
 | 
			
		||||
  const clear = () => {
 | 
			
		||||
    if (timeoutRef.current !== null) {
 | 
			
		||||
      clearTimeout(timeoutRef.current);
 | 
			
		||||
      timeoutRef.current = null;
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const trigger = useCallback(() => {
 | 
			
		||||
    setActive(!initial);
 | 
			
		||||
    clear();
 | 
			
		||||
    timeoutRef.current = window.setTimeout(() => {
 | 
			
		||||
      setActive(initial);
 | 
			
		||||
      timeoutRef.current = null;
 | 
			
		||||
    }, duration);
 | 
			
		||||
  }, [duration, initial]);
 | 
			
		||||
 | 
			
		||||
  useEffect(
 | 
			
		||||
    () => () => {
 | 
			
		||||
      clear();
 | 
			
		||||
    },
 | 
			
		||||
    []
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return [active, trigger];
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -5,7 +5,7 @@ import './SpaceAddExisting.scss';
 | 
			
		|||
 | 
			
		||||
import cons from '../../../client/state/cons';
 | 
			
		||||
import navigation from '../../../client/state/navigation';
 | 
			
		||||
import { joinRuleToIconSrc, getIdServer } from '../../../util/matrixUtil';
 | 
			
		||||
import { joinRuleToIconSrc } from '../../../util/matrixUtil';
 | 
			
		||||
import { Debounce } from '../../../util/common';
 | 
			
		||||
 | 
			
		||||
import Text from '../../atoms/text/Text';
 | 
			
		||||
| 
						 | 
				
			
			@ -21,16 +21,17 @@ import Dialog from '../dialog/Dialog';
 | 
			
		|||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
 | 
			
		||||
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
 | 
			
		||||
 | 
			
		||||
import { useStore } from '../../hooks/useStore';
 | 
			
		||||
import { roomToParentsAtom } from '../../state/room/roomToParents';
 | 
			
		||||
import { useDirects, useRooms, useSpaces } from '../../state/hooks/roomList';
 | 
			
		||||
import { allRoomsAtom } from '../../state/room-list/roomList';
 | 
			
		||||
import { mDirectAtom } from '../../state/mDirectList';
 | 
			
		||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
 | 
			
		||||
import { getViaServers } from '../../plugins/via-servers';
 | 
			
		||||
import { rateLimitedActions } from '../../utils/matrix';
 | 
			
		||||
import { useAlive } from '../../hooks/useAlive';
 | 
			
		||||
 | 
			
		||||
function SpaceAddExistingContent({ roomId, spaces: onlySpaces }) {
 | 
			
		||||
  const mountStore = useStore(roomId);
 | 
			
		||||
  const alive = useAlive();
 | 
			
		||||
  const [debounce] = useState(new Debounce());
 | 
			
		||||
  const [process, setProcess] = useState(null);
 | 
			
		||||
  const [allRoomIds, setAllRoomIds] = useState([]);
 | 
			
		||||
| 
						 | 
				
			
			@ -68,14 +69,11 @@ function SpaceAddExistingContent({ roomId, spaces: onlySpaces }) {
 | 
			
		|||
  const handleAdd = async () => {
 | 
			
		||||
    setProcess(`Adding ${selected.length} items...`);
 | 
			
		||||
 | 
			
		||||
    const promises = selected.map((rId) => {
 | 
			
		||||
    await rateLimitedActions(selected, async (rId) => {
 | 
			
		||||
      const room = mx.getRoom(rId);
 | 
			
		||||
      const via = getViaServers(room);
 | 
			
		||||
      if (via.length === 0) {
 | 
			
		||||
        via.push(getIdServer(rId));
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return mx.sendStateEvent(
 | 
			
		||||
      await mx.sendStateEvent(
 | 
			
		||||
        roomId,
 | 
			
		||||
        'm.space.child',
 | 
			
		||||
        {
 | 
			
		||||
| 
						 | 
				
			
			@ -87,9 +85,7 @@ function SpaceAddExistingContent({ roomId, spaces: onlySpaces }) {
 | 
			
		|||
      );
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    mountStore.setItem(true);
 | 
			
		||||
    await Promise.allSettled(promises);
 | 
			
		||||
    if (mountStore.getItem() !== true) return;
 | 
			
		||||
    if (!alive()) return;
 | 
			
		||||
 | 
			
		||||
    const roomIds = onlySpaces ? [...spaces] : [...rooms, ...directs];
 | 
			
		||||
    const allIds = roomIds.filter(
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -273,7 +273,7 @@ function InviteUser({ isOpen, roomId, searchTerm, onRequestClose }) {
 | 
			
		|||
            searchUser(usernameRef.current.value);
 | 
			
		||||
          }}
 | 
			
		||||
        >
 | 
			
		||||
          <Input value={searchTerm} forwardRef={usernameRef} label="Name or userId" />
 | 
			
		||||
          <Input value={searchTerm} forwardRef={usernameRef} label="Name or userId" autoFocus />
 | 
			
		||||
          <Button disabled={isSearching} iconSrc={UserIC} variant="primary" type="submit">
 | 
			
		||||
            Search
 | 
			
		||||
          </Button>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -75,7 +75,7 @@ function JoinAliasContent({ term, requestClose }) {
 | 
			
		|||
 | 
			
		||||
  return (
 | 
			
		||||
    <form className="join-alias" onSubmit={handleSubmit}>
 | 
			
		||||
      <Input label="Address" value={term} name="alias" required />
 | 
			
		||||
      <Input label="Address" value={term} name="alias" required autoFocus />
 | 
			
		||||
      {error && (
 | 
			
		||||
        <Text className="join-alias__error" variant="b3">
 | 
			
		||||
          {error}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -15,7 +15,7 @@ export function AuthFooter() {
 | 
			
		|||
        target="_blank"
 | 
			
		||||
        rel="noreferrer"
 | 
			
		||||
      >
 | 
			
		||||
        v4.5.1
 | 
			
		||||
        v4.8.1
 | 
			
		||||
      </Text>
 | 
			
		||||
      <Text as="a" size="T300" href="https://twitter.com/cinnyapp" target="_blank" rel="noreferrer">
 | 
			
		||||
        Twitter
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,19 +1,21 @@
 | 
			
		|||
import { Avatar, AvatarImage, Box, Button, Text } from 'folds';
 | 
			
		||||
import { IIdentityProvider, createClient } from 'matrix-js-sdk';
 | 
			
		||||
import { IIdentityProvider, SSOAction, createClient } from 'matrix-js-sdk';
 | 
			
		||||
import React, { useMemo } from 'react';
 | 
			
		||||
import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo';
 | 
			
		||||
 | 
			
		||||
type SSOLoginProps = {
 | 
			
		||||
  providers?: IIdentityProvider[];
 | 
			
		||||
  redirectUrl: string;
 | 
			
		||||
  action?: SSOAction;
 | 
			
		||||
  saveScreenSpace?: boolean;
 | 
			
		||||
};
 | 
			
		||||
export function SSOLogin({ providers, redirectUrl, saveScreenSpace }: SSOLoginProps) {
 | 
			
		||||
export function SSOLogin({ providers, redirectUrl, action, saveScreenSpace }: SSOLoginProps) {
 | 
			
		||||
  const discovery = useAutoDiscoveryInfo();
 | 
			
		||||
  const baseUrl = discovery['m.homeserver'].base_url;
 | 
			
		||||
  const mx = useMemo(() => createClient({ baseUrl }), [baseUrl]);
 | 
			
		||||
 | 
			
		||||
  const getSSOIdUrl = (ssoId?: string): string => mx.getSsoLoginUrl(redirectUrl, 'sso', ssoId);
 | 
			
		||||
  const getSSOIdUrl = (ssoId?: string): string =>
 | 
			
		||||
    mx.getSsoLoginUrl(redirectUrl, 'sso', ssoId, action);
 | 
			
		||||
 | 
			
		||||
  const withoutIcon = providers
 | 
			
		||||
    ? providers.find(
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -108,10 +108,10 @@ export function ServerPicker({
 | 
			
		|||
                }}
 | 
			
		||||
              >
 | 
			
		||||
                <Menu>
 | 
			
		||||
                  <Header size="300" style={{ padding: `0 ${config.space.S200}` }}>
 | 
			
		||||
                  <Header size="400" style={{ padding: `0 ${config.space.S300}` }}>
 | 
			
		||||
                    <Text size="L400">Homeserver List</Text>
 | 
			
		||||
                  </Header>
 | 
			
		||||
                  <div style={{ padding: config.space.S100, paddingTop: 0 }}>
 | 
			
		||||
                  <div style={{ padding: config.space.S200, paddingTop: 0 }}>
 | 
			
		||||
                    {serverList?.map((serverName) => (
 | 
			
		||||
                      <MenuItem
 | 
			
		||||
                        key={serverName}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,6 +1,7 @@
 | 
			
		|||
import React, { useMemo } from 'react';
 | 
			
		||||
import { Box, Text, color } from 'folds';
 | 
			
		||||
import { Link, useSearchParams } from 'react-router-dom';
 | 
			
		||||
import { SSOAction } from 'matrix-js-sdk';
 | 
			
		||||
import { useAuthFlows } from '../../../hooks/useAuthFlows';
 | 
			
		||||
import { useAuthServer } from '../../../hooks/useAuthServer';
 | 
			
		||||
import { useParsedLoginFlows } from '../../../hooks/useParsedLoginFlows';
 | 
			
		||||
| 
						 | 
				
			
			@ -76,6 +77,7 @@ export function Login() {
 | 
			
		|||
          <SSOLogin
 | 
			
		||||
            providers={parsedFlows.sso.identity_providers}
 | 
			
		||||
            redirectUrl={ssoRedirectUrl}
 | 
			
		||||
            action={SSOAction.LOGIN}
 | 
			
		||||
            saveScreenSpace={parsedFlows.password !== undefined}
 | 
			
		||||
          />
 | 
			
		||||
          <span data-spacing-node />
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -59,11 +59,11 @@ function UsernameHint({ server }: { server: string }) {
 | 
			
		|||
          }}
 | 
			
		||||
        >
 | 
			
		||||
          <Menu>
 | 
			
		||||
            <Header size="300" style={{ padding: `0 ${config.space.S200}` }}>
 | 
			
		||||
            <Header size="400" style={{ padding: `0 ${config.space.S400}` }}>
 | 
			
		||||
              <Text size="L400">Hint</Text>
 | 
			
		||||
            </Header>
 | 
			
		||||
            <Box
 | 
			
		||||
              style={{ padding: config.space.S200, paddingTop: 0 }}
 | 
			
		||||
              style={{ padding: config.space.S400, paddingTop: 0 }}
 | 
			
		||||
              direction="Column"
 | 
			
		||||
              tabIndex={0}
 | 
			
		||||
              gap="100"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -73,7 +73,7 @@ export const login = async (
 | 
			
		|||
  }
 | 
			
		||||
 | 
			
		||||
  const mx = createClient({ baseUrl: url });
 | 
			
		||||
  const [err, res] = await to<LoginResponse, MatrixError>(mx.login(data.type, data));
 | 
			
		||||
  const [err, res] = await to<LoginResponse, MatrixError>(mx.loginRequest(data));
 | 
			
		||||
 | 
			
		||||
  if (err) {
 | 
			
		||||
    if (err.httpStatus === 400) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,6 +1,7 @@
 | 
			
		|||
import React, { useMemo } from 'react';
 | 
			
		||||
import { Box, Text, color } from 'folds';
 | 
			
		||||
import { Link, useSearchParams } from 'react-router-dom';
 | 
			
		||||
import { SSOAction } from 'matrix-js-sdk';
 | 
			
		||||
import { useAuthServer } from '../../../hooks/useAuthServer';
 | 
			
		||||
import { RegisterFlowStatus, useAuthFlows } from '../../../hooks/useAuthFlows';
 | 
			
		||||
import { useParsedLoginFlows } from '../../../hooks/useParsedLoginFlows';
 | 
			
		||||
| 
						 | 
				
			
			@ -83,6 +84,7 @@ export function Register() {
 | 
			
		|||
          <SSOLogin
 | 
			
		||||
            providers={sso.identity_providers}
 | 
			
		||||
            redirectUrl={ssoRedirectUrl}
 | 
			
		||||
            action={SSOAction.REGISTER}
 | 
			
		||||
            saveScreenSpace={registerFlows.status === RegisterFlowStatus.FlowRequired}
 | 
			
		||||
          />
 | 
			
		||||
          <span data-spacing-node />
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -25,7 +25,7 @@ import {
 | 
			
		|||
} from '../../../client/initMatrix';
 | 
			
		||||
import { getSecret } from '../../../client/state/auth';
 | 
			
		||||
import { SplashScreen } from '../../components/splash-screen';
 | 
			
		||||
import { CapabilitiesAndMediaConfigLoader } from '../../components/CapabilitiesAndMediaConfigLoader';
 | 
			
		||||
import { ServerConfigsLoader } from '../../components/ServerConfigsLoader';
 | 
			
		||||
import { CapabilitiesProvider } from '../../hooks/useCapabilities';
 | 
			
		||||
import { MediaConfigProvider } from '../../hooks/useMediaConfig';
 | 
			
		||||
import { MatrixClientProvider } from '../../hooks/useMatrixClient';
 | 
			
		||||
| 
						 | 
				
			
			@ -37,6 +37,7 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
 | 
			
		|||
import { useSyncState } from '../../hooks/useSyncState';
 | 
			
		||||
import { stopPropagation } from '../../utils/keyboard';
 | 
			
		||||
import { SyncStatus } from './SyncStatus';
 | 
			
		||||
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
 | 
			
		||||
 | 
			
		||||
function ClientRootLoading() {
 | 
			
		||||
  return (
 | 
			
		||||
| 
						 | 
				
			
			@ -90,7 +91,7 @@ function ClientRootOptions({ mx }: { mx?: MatrixClient }) {
 | 
			
		|||
            }}
 | 
			
		||||
          >
 | 
			
		||||
            <Menu>
 | 
			
		||||
              <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
 | 
			
		||||
              <Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
 | 
			
		||||
                {mx && (
 | 
			
		||||
                  <MenuItem onClick={() => clearCacheAndReload(mx)} size="300" radii="300">
 | 
			
		||||
                    <Text as="span" size="T300" truncate>
 | 
			
		||||
| 
						 | 
				
			
			@ -207,18 +208,20 @@ export function ClientRoot({ children }: ClientRootProps) {
 | 
			
		|||
        <ClientRootLoading />
 | 
			
		||||
      ) : (
 | 
			
		||||
        <MatrixClientProvider value={mx}>
 | 
			
		||||
          <CapabilitiesAndMediaConfigLoader>
 | 
			
		||||
            {(capabilities, mediaConfig) => (
 | 
			
		||||
              <CapabilitiesProvider value={capabilities ?? {}}>
 | 
			
		||||
                <MediaConfigProvider value={mediaConfig ?? {}}>
 | 
			
		||||
                  {children}
 | 
			
		||||
                  <Windows />
 | 
			
		||||
                  <Dialogs />
 | 
			
		||||
                  <ReusableContextMenu />
 | 
			
		||||
          <ServerConfigsLoader>
 | 
			
		||||
            {(serverConfigs) => (
 | 
			
		||||
              <CapabilitiesProvider value={serverConfigs.capabilities ?? {}}>
 | 
			
		||||
                <MediaConfigProvider value={serverConfigs.mediaConfig ?? {}}>
 | 
			
		||||
                  <AuthMetadataProvider value={serverConfigs.authMetadata}>
 | 
			
		||||
                    {children}
 | 
			
		||||
                    <Windows />
 | 
			
		||||
                    <Dialogs />
 | 
			
		||||
                    <ReusableContextMenu />
 | 
			
		||||
                  </AuthMetadataProvider>
 | 
			
		||||
                </MediaConfigProvider>
 | 
			
		||||
              </CapabilitiesProvider>
 | 
			
		||||
            )}
 | 
			
		||||
          </CapabilitiesAndMediaConfigLoader>
 | 
			
		||||
          </ServerConfigsLoader>
 | 
			
		||||
        </MatrixClientProvider>
 | 
			
		||||
      )}
 | 
			
		||||
    </SpecVersions>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -24,7 +24,7 @@ export function WelcomePage() {
 | 
			
		|||
                  target="_blank"
 | 
			
		||||
                  rel="noreferrer noopener"
 | 
			
		||||
                >
 | 
			
		||||
                  v4.5.1
 | 
			
		||||
                  v4.8.1
 | 
			
		||||
                </a>
 | 
			
		||||
              </span>
 | 
			
		||||
            }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -67,8 +67,8 @@ const DirectMenu = forwardRef<HTMLDivElement, DirectMenuProps>(({ requestClose }
 | 
			
		|||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
 | 
			
		||||
      <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
 | 
			
		||||
    <Menu ref={ref} style={{ minWidth: toRem(200) }}>
 | 
			
		||||
      <Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
 | 
			
		||||
        <MenuItem
 | 
			
		||||
          onClick={handleMarkAsRead}
 | 
			
		||||
          size="300"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -209,7 +209,7 @@ export function Explore() {
 | 
			
		|||
                    <Box as="span" grow="Yes" alignItems="Center" gap="200">
 | 
			
		||||
                      <Avatar size="200" radii="400">
 | 
			
		||||
                        <Icon
 | 
			
		||||
                          src={Icons.Category}
 | 
			
		||||
                          src={Icons.Server}
 | 
			
		||||
                          size="100"
 | 
			
		||||
                          filled={selectedServer === userServer}
 | 
			
		||||
                        />
 | 
			
		||||
| 
						 | 
				
			
			@ -243,11 +243,7 @@ export function Explore() {
 | 
			
		|||
                    <NavItemContent>
 | 
			
		||||
                      <Box as="span" grow="Yes" alignItems="Center" gap="200">
 | 
			
		||||
                        <Avatar size="200" radii="400">
 | 
			
		||||
                          <Icon
 | 
			
		||||
                            src={Icons.Category}
 | 
			
		||||
                            size="100"
 | 
			
		||||
                            filled={server === selectedServer}
 | 
			
		||||
                          />
 | 
			
		||||
                          <Icon src={Icons.Server} size="100" filled={server === selectedServer} />
 | 
			
		||||
                        </Avatar>
 | 
			
		||||
                        <Box as="span" grow="Yes">
 | 
			
		||||
                          <Text as="span" size="Inherit" truncate>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
Some files were not shown because too many files have changed in this diff Show more
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue