mirror of
https://github.com/cinnyapp/cinny.git
synced 2025-11-05 06:50: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
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4.2.0
|
uses: actions/checkout@v4.2.0
|
||||||
- name: Setup node
|
- name: Setup node
|
||||||
uses: actions/setup-node@v4.3.0
|
uses: actions/setup-node@v4.4.0
|
||||||
with:
|
with:
|
||||||
node-version: 20.12.2
|
node-version: 20.12.2
|
||||||
cache: 'npm'
|
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' }}
|
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||||
steps:
|
steps:
|
||||||
- name: Download pr number
|
- name: Download pr number
|
||||||
uses: dawidd6/action-download-artifact@07ab29fd4a977ae4d2b275087cf67563dfdf0295
|
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5
|
||||||
with:
|
with:
|
||||||
workflow: ${{ github.event.workflow.id }}
|
workflow: ${{ github.event.workflow.id }}
|
||||||
run_id: ${{ github.event.workflow_run.id }}
|
run_id: ${{ github.event.workflow_run.id }}
|
||||||
|
|
@ -24,7 +24,7 @@ jobs:
|
||||||
id: pr
|
id: pr
|
||||||
run: echo "id=$(<pr.txt)" >> $GITHUB_OUTPUT
|
run: echo "id=$(<pr.txt)" >> $GITHUB_OUTPUT
|
||||||
- name: Download artifact
|
- name: Download artifact
|
||||||
uses: dawidd6/action-download-artifact@07ab29fd4a977ae4d2b275087cf67563dfdf0295
|
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5
|
||||||
with:
|
with:
|
||||||
workflow: ${{ github.event.workflow.id }}
|
workflow: ${{ github.event.workflow.id }}
|
||||||
run_id: ${{ github.event.workflow_run.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
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4.2.0
|
uses: actions/checkout@v4.2.0
|
||||||
- name: Build Docker image
|
- name: Build Docker image
|
||||||
uses: docker/build-push-action@v6.15.0
|
uses: docker/build-push-action@v6.18.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: false
|
push: false
|
||||||
|
|
|
||||||
2
.github/workflows/netlify-dev.yml
vendored
2
.github/workflows/netlify-dev.yml
vendored
|
|
@ -13,7 +13,7 @@ jobs:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4.2.0
|
uses: actions/checkout@v4.2.0
|
||||||
- name: Setup node
|
- name: Setup node
|
||||||
uses: actions/setup-node@v4.3.0
|
uses: actions/setup-node@v4.4.0
|
||||||
with:
|
with:
|
||||||
node-version: 20.12.2
|
node-version: 20.12.2
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
|
||||||
6
.github/workflows/prod-deploy.yml
vendored
6
.github/workflows/prod-deploy.yml
vendored
|
|
@ -12,7 +12,7 @@ jobs:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4.2.0
|
uses: actions/checkout@v4.2.0
|
||||||
- name: Setup node
|
- name: Setup node
|
||||||
uses: actions/setup-node@v4.3.0
|
uses: actions/setup-node@v4.4.0
|
||||||
with:
|
with:
|
||||||
node-version: 20.12.2
|
node-version: 20.12.2
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
@ -52,7 +52,7 @@ jobs:
|
||||||
gpg --export | xxd -p
|
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
|
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
|
- name: Upload tagged release
|
||||||
uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda
|
uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8
|
||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
cinny-${{ steps.vars.outputs.tag }}.tar.gz
|
cinny-${{ steps.vars.outputs.tag }}.tar.gz
|
||||||
|
|
@ -90,7 +90,7 @@ jobs:
|
||||||
${{ secrets.DOCKER_USERNAME }}/cinny
|
${{ secrets.DOCKER_USERNAME }}/cinny
|
||||||
ghcr.io/${{ github.repository }}
|
ghcr.io/${{ github.repository }}
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v6.15.0
|
uses: docker/build-push-action@v6.18.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ RUN npm run build
|
||||||
|
|
||||||
|
|
||||||
## App
|
## App
|
||||||
FROM nginx:1.27.4-alpine
|
FROM nginx:1.29.0-alpine
|
||||||
|
|
||||||
COPY --from=builder /src/dist /app
|
COPY --from=builder /src/dist /app
|
||||||
COPY --from=builder /src/docker-nginx.conf /etc/nginx/conf.d/default.conf
|
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 ^/config.json$ /config.json break;
|
||||||
rewrite ^/manifest.json$ /manifest.json break;
|
rewrite ^/manifest.json$ /manifest.json break;
|
||||||
|
|
||||||
rewrite ^.*/olm.wasm$ /olm.wasm break;
|
|
||||||
rewrite ^/sw.js$ /sw.js break;
|
rewrite ^/sw.js$ /sw.js break;
|
||||||
rewrite ^/pdf.worker.min.js$ /pdf.worker.min.js break;
|
rewrite ^/pdf.worker.min.js$ /pdf.worker.min.js break;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ server {
|
||||||
rewrite ^/config.json$ /config.json break;
|
rewrite ^/config.json$ /config.json break;
|
||||||
rewrite ^/manifest.json$ /manifest.json break;
|
rewrite ^/manifest.json$ /manifest.json break;
|
||||||
|
|
||||||
rewrite ^.*/olm.wasm$ /olm.wasm break;
|
|
||||||
rewrite ^/sw.js$ /sw.js break;
|
rewrite ^/sw.js$ /sw.js break;
|
||||||
rewrite ^/pdf.worker.min.js$ /pdf.worker.min.js break;
|
rewrite ^/pdf.worker.min.js$ /pdf.worker.min.js break;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,6 @@
|
||||||
to = "/sw.js"
|
to = "/sw.js"
|
||||||
status = 200
|
status = 200
|
||||||
|
|
||||||
[[redirects]]
|
|
||||||
from = "*/olm.wasm"
|
|
||||||
to = "/olm.wasm"
|
|
||||||
status = 200
|
|
||||||
force = true
|
|
||||||
|
|
||||||
[[redirects]]
|
[[redirects]]
|
||||||
from = "/pdf.worker.min.js"
|
from = "/pdf.worker.min.js"
|
||||||
|
|
|
||||||
115
package-lock.json
generated
115
package-lock.json
generated
|
|
@ -1,19 +1,18 @@
|
||||||
{
|
{
|
||||||
"name": "cinny",
|
"name": "cinny",
|
||||||
"version": "4.5.1",
|
"version": "4.8.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "cinny",
|
"name": "cinny",
|
||||||
"version": "4.5.1",
|
"version": "4.8.1",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atlaskit/pragmatic-drag-and-drop": "1.1.6",
|
"@atlaskit/pragmatic-drag-and-drop": "1.1.6",
|
||||||
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
|
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
|
||||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
|
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
|
||||||
"@fontsource/inter": "4.5.14",
|
"@fontsource/inter": "4.5.14",
|
||||||
"@matrix-org/olm": "3.2.15",
|
|
||||||
"@tanstack/react-query": "5.24.1",
|
"@tanstack/react-query": "5.24.1",
|
||||||
"@tanstack/react-query-devtools": "5.24.1",
|
"@tanstack/react-query-devtools": "5.24.1",
|
||||||
"@tanstack/react-virtual": "3.2.0",
|
"@tanstack/react-virtual": "3.2.0",
|
||||||
|
|
@ -22,6 +21,7 @@
|
||||||
"@vanilla-extract/recipes": "0.3.0",
|
"@vanilla-extract/recipes": "0.3.0",
|
||||||
"@vanilla-extract/vite-plugin": "3.7.1",
|
"@vanilla-extract/vite-plugin": "3.7.1",
|
||||||
"await-to-js": "3.0.0",
|
"await-to-js": "3.0.0",
|
||||||
|
"badwords-list": "2.0.1-4",
|
||||||
"blurhash": "2.0.4",
|
"blurhash": "2.0.4",
|
||||||
"browser-encrypt-attachment": "0.3.0",
|
"browser-encrypt-attachment": "0.3.0",
|
||||||
"chroma-js": "3.1.2",
|
"chroma-js": "3.1.2",
|
||||||
|
|
@ -34,7 +34,7 @@
|
||||||
"file-saver": "2.0.5",
|
"file-saver": "2.0.5",
|
||||||
"flux": "4.0.3",
|
"flux": "4.0.3",
|
||||||
"focus-trap-react": "10.0.2",
|
"focus-trap-react": "10.0.2",
|
||||||
"folds": "2.1.0",
|
"folds": "2.2.0",
|
||||||
"formik": "2.4.6",
|
"formik": "2.4.6",
|
||||||
"html-dom-parser": "4.0.0",
|
"html-dom-parser": "4.0.0",
|
||||||
"html-react-parser": "4.2.0",
|
"html-react-parser": "4.2.0",
|
||||||
|
|
@ -46,7 +46,7 @@
|
||||||
"jotai": "2.6.0",
|
"jotai": "2.6.0",
|
||||||
"linkify-react": "4.1.3",
|
"linkify-react": "4.1.3",
|
||||||
"linkifyjs": "4.1.3",
|
"linkifyjs": "4.1.3",
|
||||||
"matrix-js-sdk": "35.0.0",
|
"matrix-js-sdk": "37.5.0",
|
||||||
"millify": "6.1.0",
|
"millify": "6.1.0",
|
||||||
"pdfjs-dist": "4.2.67",
|
"pdfjs-dist": "4.2.67",
|
||||||
"prismjs": "1.30.0",
|
"prismjs": "1.30.0",
|
||||||
|
|
@ -99,7 +99,7 @@
|
||||||
"prettier": "2.8.1",
|
"prettier": "2.8.1",
|
||||||
"sass": "1.56.2",
|
"sass": "1.56.2",
|
||||||
"typescript": "4.9.4",
|
"typescript": "4.9.4",
|
||||||
"vite": "5.4.15",
|
"vite": "5.4.19",
|
||||||
"vite-plugin-pwa": "0.20.5",
|
"vite-plugin-pwa": "0.20.5",
|
||||||
"vite-plugin-static-copy": "1.0.4",
|
"vite-plugin-static-copy": "1.0.4",
|
||||||
"vite-plugin-top-level-await": "1.4.4"
|
"vite-plugin-top-level-await": "1.4.4"
|
||||||
|
|
@ -2264,17 +2264,19 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
||||||
"version": "11.1.0",
|
"version": "14.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-11.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-14.1.0.tgz",
|
||||||
"integrity": "sha512-JPuO9RCVDklDjbFzMvZfQb7PuiFkLY72bniRSu81lRzkkrcbZtmKqBFMm9H4f2FSz+tHVkDnmsvn12I4sdJJ5A==",
|
"integrity": "sha512-vcSxHJIr6lP0Fgo8jl0sTHg+OZxZn+skGjiyB62erfgw/R2QqJl0ZVSY8SRcbk9LtHo/ZGld1tnaOyjL2e3cLQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10"
|
"node": ">= 18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@matrix-org/olm": {
|
"node_modules/@matrix-org/olm": {
|
||||||
"version": "3.2.15",
|
"version": "3.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/@matrix-org/olm/-/olm-3.2.15.tgz",
|
"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": {
|
"node_modules/@nodelib/fs.scandir": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
|
|
@ -4590,7 +4592,8 @@
|
||||||
"node_modules/@types/events": {
|
"node_modules/@types/events": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.3.tgz",
|
"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": {
|
"node_modules/@types/file-saver": {
|
||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
|
|
@ -4679,7 +4682,8 @@
|
||||||
"node_modules/@types/retry": {
|
"node_modules/@types/retry": {
|
||||||
"version": "0.12.0",
|
"version": "0.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz",
|
"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": {
|
"node_modules/@types/sanitize-html": {
|
||||||
"version": "2.9.0",
|
"version": "2.9.0",
|
||||||
|
|
@ -5089,7 +5093,8 @@
|
||||||
"node_modules/another-json": {
|
"node_modules/another-json": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/another-json/-/another-json-0.2.0.tgz",
|
"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": {
|
"node_modules/ansi-regex": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
|
|
@ -5432,6 +5437,12 @@
|
||||||
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
|
"@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": {
|
"node_modules/balanced-match": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
|
|
@ -5439,9 +5450,10 @@
|
||||||
"devOptional": true
|
"devOptional": true
|
||||||
},
|
},
|
||||||
"node_modules/base-x": {
|
"node_modules/base-x": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz",
|
||||||
"integrity": "sha512-sMW3VGSX1QWVFA6l8U62MLKz29rRfpTlYdCqLdpLo1/Yd4zZwSbnUaDfciIAowAqvq7YFnWq9hrhdg1KYgc1lQ=="
|
"integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==",
|
||||||
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/base64-js": {
|
"node_modules/base64-js": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
|
|
@ -5547,6 +5559,7 @@
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz",
|
||||||
"integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==",
|
"integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"base-x": "^5.0.0"
|
"base-x": "^5.0.0"
|
||||||
}
|
}
|
||||||
|
|
@ -5849,6 +5862,7 @@
|
||||||
"version": "1.0.5",
|
"version": "1.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||||
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
|
|
@ -7000,6 +7014,7 @@
|
||||||
"version": "3.3.0",
|
"version": "3.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||||
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.8.x"
|
"node": ">=0.8.x"
|
||||||
}
|
}
|
||||||
|
|
@ -7250,15 +7265,16 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/folds": {
|
"node_modules/folds": {
|
||||||
"version": "2.1.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/folds/-/folds-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/folds/-/folds-2.2.0.tgz",
|
||||||
"integrity": "sha512-KwAG8bH3jsyZ9FKPMg+6ABV2YOcpp4nL0cCelsalnaPeRThkc5fgG1Xj5mhmdffYKjEXpEbERi5qmGbepgJryg==",
|
"integrity": "sha512-uOfck5eWEIK11rhOAEdSoPIvMXwv+D1Go03pxSAKezWVb+uRoBdmE6LEqiLOF+ac4DGmZRMPvpdDsXCg7EVNIg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@vanilla-extract/css": "^1.9.2",
|
"@vanilla-extract/css": "1.9.2",
|
||||||
"@vanilla-extract/recipes": "^0.3.0",
|
"@vanilla-extract/recipes": "0.3.0",
|
||||||
"classnames": "^2.3.2",
|
"classnames": "2.3.2",
|
||||||
"react": "^17.0.0",
|
"react": "17.0.0",
|
||||||
"react-dom": "^17.0.0"
|
"react-dom": "17.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/for-each": {
|
"node_modules/for-each": {
|
||||||
|
|
@ -8558,6 +8574,7 @@
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
|
||||||
"integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==",
|
"integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==",
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
|
|
@ -8690,6 +8707,7 @@
|
||||||
"version": "1.9.2",
|
"version": "1.9.2",
|
||||||
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz",
|
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz",
|
||||||
"integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==",
|
"integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==",
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6.0"
|
"node": ">= 0.6.0"
|
||||||
},
|
},
|
||||||
|
|
@ -8765,21 +8783,23 @@
|
||||||
"node_modules/matrix-events-sdk": {
|
"node_modules/matrix-events-sdk": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/matrix-events-sdk/-/matrix-events-sdk-0.0.1.tgz",
|
"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": {
|
"node_modules/matrix-js-sdk": {
|
||||||
"version": "35.0.0",
|
"version": "37.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-35.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-37.5.0.tgz",
|
||||||
"integrity": "sha512-X8hIsd/8x1SC9vRr8DiNKQxmdrfRujtvEWPz8mY4FxVDJG8HEGDHvqUmaSy2jrtnOUn4oHzGQVLFO3DnhsSf8w==",
|
"integrity": "sha512-5tyuAi5hnKud1UkVq8Z2/3c22hWGELBZzErJPZkE6Hju2uGUfGtrIx6uj6puv0ZjvsUU3X6Qgm8vdReKO1PGig==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.12.5",
|
"@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",
|
"@matrix-org/olm": "3.2.15",
|
||||||
"another-json": "^0.2.0",
|
"another-json": "^0.2.0",
|
||||||
"bs58": "^6.0.0",
|
"bs58": "^6.0.0",
|
||||||
"content-type": "^1.0.4",
|
"content-type": "^1.0.4",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
"loglevel": "^1.7.1",
|
"loglevel": "^1.9.2",
|
||||||
"matrix-events-sdk": "0.0.1",
|
"matrix-events-sdk": "0.0.1",
|
||||||
"matrix-widget-api": "^1.10.0",
|
"matrix-widget-api": "^1.10.0",
|
||||||
"oidc-client-ts": "^3.0.1",
|
"oidc-client-ts": "^3.0.1",
|
||||||
|
|
@ -8793,21 +8813,23 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/matrix-js-sdk/node_modules/uuid": {
|
"node_modules/matrix-js-sdk/node_modules/uuid": {
|
||||||
"version": "11.0.5",
|
"version": "11.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
||||||
"integrity": "sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==",
|
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
|
||||||
"funding": [
|
"funding": [
|
||||||
"https://github.com/sponsors/broofa",
|
"https://github.com/sponsors/broofa",
|
||||||
"https://github.com/sponsors/ctavan"
|
"https://github.com/sponsors/ctavan"
|
||||||
],
|
],
|
||||||
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"uuid": "dist/esm/bin/uuid"
|
"uuid": "dist/esm/bin/uuid"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/matrix-widget-api": {
|
"node_modules/matrix-widget-api": {
|
||||||
"version": "1.12.0",
|
"version": "1.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.13.1.tgz",
|
||||||
"integrity": "sha512-6JRd9fJGGvuBRhcTg9wX+Skn/Q1wox3jdp5yYQKJ6pPw4urW9bkTR90APBKVDB1vorJKT44jml+lCzkDMRBjww==",
|
"integrity": "sha512-mkOHUVzaN018TCbObfGOSaMW2GoUxOfcxNNlTVx5/HeMk3OSQPQM0C9oEME5Liiv/dBUoSrEB64V8wF7e/gb1w==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/events": "^3.0.0",
|
"@types/events": "^3.0.0",
|
||||||
"events": "^3.2.0"
|
"events": "^3.2.0"
|
||||||
|
|
@ -9199,9 +9221,10 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/oidc-client-ts": {
|
"node_modules/oidc-client-ts": {
|
||||||
"version": "3.1.0",
|
"version": "3.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.2.1.tgz",
|
||||||
"integrity": "sha512-IDopEXjiwjkmJLYZo6BTlvwOtnlSniWZkKZoXforC/oLZHC9wkIxd25Kwtmo5yKFMMVcsp3JY6bhcNJqdYk8+g==",
|
"integrity": "sha512-hS5AJ5s/x4bXhHvNJT1v+GGvzHUwdRWqNQQbSrp10L1IRmzfRGKQ3VWN3dstJb+oF3WtAyKezwD2+dTEIyBiAA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"jwt-decode": "^4.0.0"
|
"jwt-decode": "^4.0.0"
|
||||||
},
|
},
|
||||||
|
|
@ -9289,6 +9312,7 @@
|
||||||
"version": "4.6.2",
|
"version": "4.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz",
|
||||||
"integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==",
|
"integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/retry": "0.12.0",
|
"@types/retry": "0.12.0",
|
||||||
"retry": "^0.13.1"
|
"retry": "^0.13.1"
|
||||||
|
|
@ -10052,6 +10076,7 @@
|
||||||
"version": "0.13.1",
|
"version": "0.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
|
||||||
"integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==",
|
"integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==",
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
|
|
@ -10265,6 +10290,7 @@
|
||||||
"version": "2.15.0",
|
"version": "2.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz",
|
||||||
"integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==",
|
"integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==",
|
||||||
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"sdp-verify": "checker.js"
|
"sdp-verify": "checker.js"
|
||||||
}
|
}
|
||||||
|
|
@ -11173,7 +11199,8 @@
|
||||||
"node_modules/unhomoglyph": {
|
"node_modules/unhomoglyph": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/unhomoglyph/-/unhomoglyph-1.0.6.tgz",
|
"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": {
|
"node_modules/unicode-canonical-property-names-ecmascript": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
|
|
@ -11304,9 +11331,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "5.4.15",
|
"version": "5.4.19",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.15.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz",
|
||||||
"integrity": "sha512-6ANcZRivqL/4WtwPGTKNaosuNJr5tWiftOC7liM7G9+rMb8+oeJeyzymDu4rTN93seySBmbjSfsS3Vzr19KNtA==",
|
"integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.21.3",
|
"esbuild": "^0.21.3",
|
||||||
|
|
|
||||||
10
package.json
10
package.json
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "cinny",
|
"name": "cinny",
|
||||||
"version": "4.5.1",
|
"version": "4.8.1",
|
||||||
"description": "Yet another matrix client",
|
"description": "Yet another matrix client",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
@ -24,7 +24,6 @@
|
||||||
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
|
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
|
||||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
|
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
|
||||||
"@fontsource/inter": "4.5.14",
|
"@fontsource/inter": "4.5.14",
|
||||||
"@matrix-org/olm": "3.2.15",
|
|
||||||
"@tanstack/react-query": "5.24.1",
|
"@tanstack/react-query": "5.24.1",
|
||||||
"@tanstack/react-query-devtools": "5.24.1",
|
"@tanstack/react-query-devtools": "5.24.1",
|
||||||
"@tanstack/react-virtual": "3.2.0",
|
"@tanstack/react-virtual": "3.2.0",
|
||||||
|
|
@ -33,6 +32,7 @@
|
||||||
"@vanilla-extract/recipes": "0.3.0",
|
"@vanilla-extract/recipes": "0.3.0",
|
||||||
"@vanilla-extract/vite-plugin": "3.7.1",
|
"@vanilla-extract/vite-plugin": "3.7.1",
|
||||||
"await-to-js": "3.0.0",
|
"await-to-js": "3.0.0",
|
||||||
|
"badwords-list": "2.0.1-4",
|
||||||
"blurhash": "2.0.4",
|
"blurhash": "2.0.4",
|
||||||
"browser-encrypt-attachment": "0.3.0",
|
"browser-encrypt-attachment": "0.3.0",
|
||||||
"chroma-js": "3.1.2",
|
"chroma-js": "3.1.2",
|
||||||
|
|
@ -45,7 +45,7 @@
|
||||||
"file-saver": "2.0.5",
|
"file-saver": "2.0.5",
|
||||||
"flux": "4.0.3",
|
"flux": "4.0.3",
|
||||||
"focus-trap-react": "10.0.2",
|
"focus-trap-react": "10.0.2",
|
||||||
"folds": "2.1.0",
|
"folds": "2.2.0",
|
||||||
"formik": "2.4.6",
|
"formik": "2.4.6",
|
||||||
"html-dom-parser": "4.0.0",
|
"html-dom-parser": "4.0.0",
|
||||||
"html-react-parser": "4.2.0",
|
"html-react-parser": "4.2.0",
|
||||||
|
|
@ -57,7 +57,7 @@
|
||||||
"jotai": "2.6.0",
|
"jotai": "2.6.0",
|
||||||
"linkify-react": "4.1.3",
|
"linkify-react": "4.1.3",
|
||||||
"linkifyjs": "4.1.3",
|
"linkifyjs": "4.1.3",
|
||||||
"matrix-js-sdk": "35.0.0",
|
"matrix-js-sdk": "37.5.0",
|
||||||
"millify": "6.1.0",
|
"millify": "6.1.0",
|
||||||
"pdfjs-dist": "4.2.67",
|
"pdfjs-dist": "4.2.67",
|
||||||
"prismjs": "1.30.0",
|
"prismjs": "1.30.0",
|
||||||
|
|
@ -110,7 +110,7 @@
|
||||||
"prettier": "2.8.1",
|
"prettier": "2.8.1",
|
||||||
"sass": "1.56.2",
|
"sass": "1.56.2",
|
||||||
"typescript": "4.9.4",
|
"typescript": "4.9.4",
|
||||||
"vite": "5.4.15",
|
"vite": "5.4.19",
|
||||||
"vite-plugin-pwa": "0.20.5",
|
"vite-plugin-pwa": "0.20.5",
|
||||||
"vite-plugin-static-copy": "1.0.4",
|
"vite-plugin-static-copy": "1.0.4",
|
||||||
"vite-plugin-top-level-await": "1.4.4"
|
"vite-plugin-top-level-await": "1.4.4"
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,25 @@ import PropTypes from 'prop-types';
|
||||||
import dateFormat from 'dateformat';
|
import dateFormat from 'dateformat';
|
||||||
import { isInSameDay } from '../../../util/common';
|
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 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;
|
let formattedDate = formattedFullTime;
|
||||||
|
|
||||||
if (!fullTime) {
|
if (!fullTime) {
|
||||||
|
|
@ -16,17 +31,19 @@ function Time({ timestamp, fullTime }) {
|
||||||
compareDate.setDate(compareDate.getDate() - 1);
|
compareDate.setDate(compareDate.getDate() - 1);
|
||||||
const isYesterday = isInSameDay(date, compareDate);
|
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) {
|
if (isYesterday) {
|
||||||
formattedDate = `Yesterday, ${formattedDate}`;
|
formattedDate = `Yesterday, ${formattedDate}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<time
|
<time dateTime={date.toISOString()} title={formattedFullTime}>
|
||||||
dateTime={date.toISOString()}
|
|
||||||
title={formattedFullTime}
|
|
||||||
>
|
|
||||||
{formattedDate}
|
{formattedDate}
|
||||||
</time>
|
</time>
|
||||||
);
|
);
|
||||||
|
|
@ -39,6 +56,8 @@ Time.defaultProps = {
|
||||||
Time.propTypes = {
|
Time.propTypes = {
|
||||||
timestamp: PropTypes.number.isRequired,
|
timestamp: PropTypes.number.isRequired,
|
||||||
fullTime: PropTypes.bool,
|
fullTime: PropTypes.bool,
|
||||||
|
hour24Clock: PropTypes.bool.isRequired,
|
||||||
|
dateFormatString: PropTypes.string.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Time;
|
export default Time;
|
||||||
|
|
|
||||||
|
|
@ -203,12 +203,8 @@ export function BackupRestoreTile({ crypto }: BackupRestoreTileProps) {
|
||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Menu
|
<Menu style={{ padding: config.space.S200 }}>
|
||||||
style={{
|
<Box direction="Column" gap="200">
|
||||||
padding: config.space.S100,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box direction="Column" gap="100">
|
|
||||||
<Box direction="Column" gap="200">
|
<Box direction="Column" gap="200">
|
||||||
<InfoCard
|
<InfoCard
|
||||||
variant="SurfaceVariant"
|
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
|
<Menu
|
||||||
style={{
|
style={{
|
||||||
padding: config.space.S100,
|
padding: config.space.S200,
|
||||||
borderRadius: config.radii.R500,
|
borderRadius: config.radii.R500,
|
||||||
overflow: 'initial',
|
overflow: 'initial',
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -17,12 +17,16 @@ import { JoinRule } from 'matrix-js-sdk';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import { stopPropagation } from '../utils/keyboard';
|
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 =>
|
export const useRoomJoinRuleIcon = (): JoinRuleIcons =>
|
||||||
useMemo(
|
useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
[JoinRule.Invite]: Icons.HashLock,
|
[JoinRule.Invite]: Icons.HashLock,
|
||||||
[JoinRule.Knock]: Icons.HashLock,
|
[JoinRule.Knock]: Icons.HashLock,
|
||||||
|
knock_restricted: Icons.Hash,
|
||||||
[JoinRule.Restricted]: Icons.Hash,
|
[JoinRule.Restricted]: Icons.Hash,
|
||||||
[JoinRule.Public]: Icons.HashGlobe,
|
[JoinRule.Public]: Icons.HashGlobe,
|
||||||
[JoinRule.Private]: Icons.HashLock,
|
[JoinRule.Private]: Icons.HashLock,
|
||||||
|
|
@ -34,6 +38,7 @@ export const useSpaceJoinRuleIcon = (): JoinRuleIcons =>
|
||||||
() => ({
|
() => ({
|
||||||
[JoinRule.Invite]: Icons.SpaceLock,
|
[JoinRule.Invite]: Icons.SpaceLock,
|
||||||
[JoinRule.Knock]: Icons.SpaceLock,
|
[JoinRule.Knock]: Icons.SpaceLock,
|
||||||
|
knock_restricted: Icons.Space,
|
||||||
[JoinRule.Restricted]: Icons.Space,
|
[JoinRule.Restricted]: Icons.Space,
|
||||||
[JoinRule.Public]: Icons.SpaceGlobe,
|
[JoinRule.Public]: Icons.SpaceGlobe,
|
||||||
[JoinRule.Private]: Icons.SpaceLock,
|
[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 =>
|
export const useRoomJoinRuleLabel = (): JoinRuleLabels =>
|
||||||
useMemo(
|
useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
[JoinRule.Invite]: 'Invite Only',
|
[JoinRule.Invite]: 'Invite Only',
|
||||||
[JoinRule.Knock]: 'Knock & Invite',
|
[JoinRule.Knock]: 'Knock & Invite',
|
||||||
|
knock_restricted: 'Space Members or Knock',
|
||||||
[JoinRule.Restricted]: 'Space Members',
|
[JoinRule.Restricted]: 'Space Members',
|
||||||
[JoinRule.Public]: 'Public',
|
[JoinRule.Public]: 'Public',
|
||||||
[JoinRule.Private]: 'Invite Only',
|
[JoinRule.Private]: 'Invite Only',
|
||||||
|
|
@ -54,7 +60,7 @@ export const useRoomJoinRuleLabel = (): JoinRuleLabels =>
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
type JoinRulesSwitcherProps<T extends JoinRule[]> = {
|
type JoinRulesSwitcherProps<T extends ExtendedJoinRules[]> = {
|
||||||
icons: JoinRuleIcons;
|
icons: JoinRuleIcons;
|
||||||
labels: JoinRuleLabels;
|
labels: JoinRuleLabels;
|
||||||
rules: T;
|
rules: T;
|
||||||
|
|
@ -63,7 +69,7 @@ type JoinRulesSwitcherProps<T extends JoinRule[]> = {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
changing?: boolean;
|
changing?: boolean;
|
||||||
};
|
};
|
||||||
export function JoinRulesSwitcher<T extends JoinRule[]>({
|
export function JoinRulesSwitcher<T extends ExtendedJoinRules[]>({
|
||||||
icons,
|
icons,
|
||||||
labels,
|
labels,
|
||||||
rules,
|
rules,
|
||||||
|
|
@ -79,7 +85,7 @@ export function JoinRulesSwitcher<T extends JoinRule[]>({
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChange = useCallback(
|
const handleChange = useCallback(
|
||||||
(selectedRule: JoinRule) => {
|
(selectedRule: ExtendedJoinRules) => {
|
||||||
setCords(undefined);
|
setCords(undefined);
|
||||||
onChange(selectedRule);
|
onChange(selectedRule);
|
||||||
},
|
},
|
||||||
|
|
@ -103,7 +109,7 @@ export function JoinRulesSwitcher<T extends JoinRule[]>({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Menu>
|
<Menu>
|
||||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
<Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
|
||||||
{rules.map((rule) => (
|
{rules.map((rule) => (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
key={rule}
|
key={rule}
|
||||||
|
|
@ -131,7 +137,7 @@ export function JoinRulesSwitcher<T extends JoinRule[]>({
|
||||||
fill="Soft"
|
fill="Soft"
|
||||||
radii="300"
|
radii="300"
|
||||||
outlined
|
outlined
|
||||||
before={<Icon size="100" src={icons[value]} />}
|
before={<Icon size="100" src={icons[value] ?? icons[JoinRule.Restricted]} />}
|
||||||
after={
|
after={
|
||||||
changing ? (
|
changing ? (
|
||||||
<Spinner size="100" variant="Secondary" fill="Soft" />
|
<Spinner size="100" variant="Secondary" fill="Soft" />
|
||||||
|
|
@ -142,7 +148,7 @@ export function JoinRulesSwitcher<T extends JoinRule[]>({
|
||||||
onClick={handleOpenMenu}
|
onClick={handleOpenMenu}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<Text size="B300">{labels[value]}</Text>
|
<Text size="B300">{labels[value] ?? 'Unsupported'}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</PopOut>
|
</PopOut>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ export function ManualVerificationMethodSwitcher({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Menu>
|
<Menu>
|
||||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
<Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
size="300"
|
size="300"
|
||||||
variant="Surface"
|
variant="Surface"
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ export function MemberSortMenu({ selected, onSelect, requestClose }: MemberSortM
|
||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Menu style={{ padding: config.space.S100 }}>
|
<Menu style={{ padding: config.space.S200 }}>
|
||||||
{memberSortMenu.map((menuItem, index) => (
|
{memberSortMenu.map((menuItem, index) => (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
key={menuItem.name}
|
key={menuItem.name}
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ export function MembershipFilterMenu({
|
||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Menu style={{ padding: config.space.S100 }}>
|
<Menu style={{ padding: config.space.S200 }}>
|
||||||
{membershipFilterMenu.map((menuItem, index) => (
|
{membershipFilterMenu.map((menuItem, index) => (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
key={menuItem.name}
|
key={menuItem.name}
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ export function RoomNotificationModeSwitcher({
|
||||||
return (
|
return (
|
||||||
<PopOut
|
<PopOut
|
||||||
anchor={menuCords}
|
anchor={menuCords}
|
||||||
offset={5}
|
offset={8}
|
||||||
position="Right"
|
position="Right"
|
||||||
align="Start"
|
align="Start"
|
||||||
content={
|
content={
|
||||||
|
|
@ -86,7 +86,7 @@ export function RoomNotificationModeSwitcher({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Menu>
|
<Menu>
|
||||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
<Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
|
||||||
{modes.map((mode) => (
|
{modes.map((mode) => (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
key={mode}
|
key={mode}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import React, { FormEventHandler, useCallback } from 'react';
|
import React, { FormEventHandler, useCallback } from 'react';
|
||||||
import { Box, Text, Button, Spinner, color } from 'folds';
|
import { Box, Text, Button, Spinner, color } from 'folds';
|
||||||
import { decodeRecoveryKey } from 'matrix-js-sdk/lib/crypto-api';
|
import { decodeRecoveryKey, deriveRecoveryKeyFromPassphrase } from 'matrix-js-sdk/lib/crypto-api';
|
||||||
import { deriveKey } from 'matrix-js-sdk/lib/crypto/key_passphrase';
|
|
||||||
import { PasswordInput } from './password-input';
|
import { PasswordInput } from './password-input';
|
||||||
import {
|
import {
|
||||||
SecretStorageKeyContent,
|
SecretStorageKeyContent,
|
||||||
|
|
@ -29,11 +28,16 @@ export function SecretStorageRecoveryPassphrase({
|
||||||
const [driveKeyState, submitPassphrase] = useAsyncCallback<
|
const [driveKeyState, submitPassphrase] = useAsyncCallback<
|
||||||
Uint8Array,
|
Uint8Array,
|
||||||
Error,
|
Error,
|
||||||
Parameters<typeof deriveKey>
|
Parameters<typeof deriveRecoveryKeyFromPassphrase>
|
||||||
>(
|
>(
|
||||||
useCallback(
|
useCallback(
|
||||||
async (passphrase, salt, iterations, bits) => {
|
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);
|
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}>
|
<Text as="pre" className={css.CodeBlock} {...attributes}>
|
||||||
<Scroll
|
<Scroll
|
||||||
direction="Horizontal"
|
direction="Horizontal"
|
||||||
variant="Secondary"
|
variant="SurfaceVariant"
|
||||||
size="300"
|
size="300"
|
||||||
visibility="Hover"
|
visibility="Hover"
|
||||||
hideTrack
|
hideTrack
|
||||||
|
|
|
||||||
|
|
@ -155,7 +155,7 @@ export function HeadingBlockButton() {
|
||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Menu style={{ padding: config.space.S100 }}>
|
<Menu style={{ padding: config.space.S200 }}>
|
||||||
<Box gap="100">
|
<Box gap="100">
|
||||||
<TooltipProvider
|
<TooltipProvider
|
||||||
tooltip={<BtnTooltip text="Heading 1" shortCode={`${modKey} + 1`} />}
|
tooltip={<BtnTooltip text="Heading 1" shortCode={`${modKey} + 1`} />}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { getDirectRoomAvatarUrl } from '../../../utils/room';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { AutocompleteQuery } from './autocompleteQuery';
|
import { AutocompleteQuery } from './autocompleteQuery';
|
||||||
import { AutocompleteMenu } from './AutocompleteMenu';
|
import { AutocompleteMenu } from './AutocompleteMenu';
|
||||||
import { getMxIdServer, validMxId } from '../../../utils/matrix';
|
import { getMxIdServer, isRoomAlias } from '../../../utils/matrix';
|
||||||
import { UseAsyncSearchOptions, useAsyncSearch } from '../../../hooks/useAsyncSearch';
|
import { UseAsyncSearchOptions, useAsyncSearch } from '../../../hooks/useAsyncSearch';
|
||||||
import { onTabPress } from '../../../utils/keyboard';
|
import { onTabPress } from '../../../utils/keyboard';
|
||||||
import { useKeyDown } from '../../../hooks/useKeyDown';
|
import { useKeyDown } from '../../../hooks/useKeyDown';
|
||||||
|
|
@ -22,7 +22,7 @@ import { getViaServers } from '../../../plugins/via-servers';
|
||||||
type MentionAutoCompleteHandler = (roomAliasOrId: string, name: string) => void;
|
type MentionAutoCompleteHandler = (roomAliasOrId: string, name: string) => void;
|
||||||
|
|
||||||
const roomAliasFromQueryText = (mx: MatrixClient, text: string) =>
|
const roomAliasFromQueryText = (mx: MatrixClient, text: string) =>
|
||||||
validMxId(`#${text}`)
|
isRoomAlias(`#${text}`)
|
||||||
? `#${text}`
|
? `#${text}`
|
||||||
: `#${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`;
|
: `#${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import {
|
||||||
import { onTabPress } from '../../../utils/keyboard';
|
import { onTabPress } from '../../../utils/keyboard';
|
||||||
import { createMentionElement, moveCursor, replaceWithElement } from '../utils';
|
import { createMentionElement, moveCursor, replaceWithElement } from '../utils';
|
||||||
import { useKeyDown } from '../../../hooks/useKeyDown';
|
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 { getMemberDisplayName, getMemberSearchStr } from '../../../utils/room';
|
||||||
import { UserAvatar } from '../../user-avatar';
|
import { UserAvatar } from '../../user-avatar';
|
||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
|
|
@ -24,7 +24,7 @@ import { Membership } from '../../../../types/matrix/room';
|
||||||
type MentionAutoCompleteHandler = (userId: string, name: string) => void;
|
type MentionAutoCompleteHandler = (userId: string, name: string) => void;
|
||||||
|
|
||||||
const userIdFromQueryText = (mx: MatrixClient, text: string) =>
|
const userIdFromQueryText = (mx: MatrixClient, text: string) =>
|
||||||
validMxId(`@${text}`)
|
isUserId(`@${text}`)
|
||||||
? `@${text}`
|
? `@${text}`
|
||||||
: `@${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`;
|
: `@${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ export function UsageSelector({ selected, onChange }: UsageSelectorProps) {
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
<Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
|
||||||
{allUsages.map((usage) => (
|
{allUsages.map((usage) => (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
key={getUsageStr(usage)}
|
key={getUsageStr(usage)}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ export const ReplyBend = style({
|
||||||
|
|
||||||
export const ThreadIndicator = style({
|
export const ThreadIndicator = style({
|
||||||
opacity: config.opacity.P300,
|
opacity: config.opacity.P300,
|
||||||
gap: toRem(2),
|
|
||||||
|
|
||||||
selectors: {
|
selectors: {
|
||||||
'button&': {
|
'button&': {
|
||||||
|
|
@ -19,11 +18,6 @@ export const ThreadIndicator = style({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ThreadIndicatorIcon = style({
|
|
||||||
width: toRem(14),
|
|
||||||
height: toRem(14),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const Reply = style({
|
export const Reply = style({
|
||||||
marginBottom: toRem(1),
|
marginBottom: toRem(1),
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
|
|
|
||||||
|
|
@ -38,9 +38,16 @@ export const ReplyLayout = as<'div', ReplyLayoutProps>(
|
||||||
);
|
);
|
||||||
|
|
||||||
export const ThreadIndicator = as<'div'>(({ ...props }, ref) => (
|
export const ThreadIndicator = as<'div'>(({ ...props }, ref) => (
|
||||||
<Box className={css.ThreadIndicator} alignItems="Center" {...props} ref={ref}>
|
<Box
|
||||||
<Icon className={css.ThreadIndicatorIcon} src={Icons.Message} />
|
shrink="No"
|
||||||
<Text size="T200">Threaded reply</Text>
|
className={css.ThreadIndicator}
|
||||||
|
alignItems="Center"
|
||||||
|
gap="100"
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<Icon size="50" src={Icons.Thread} />
|
||||||
|
<Text size="L400">Thread</Text>
|
||||||
</Box>
|
</Box>
|
||||||
));
|
));
|
||||||
|
|
||||||
|
|
@ -97,7 +104,7 @@ export const Reply = as<'div', ReplyProps>(
|
||||||
const bodyJSX = body ? scaleSystemEmoji(trimReplyFromBody(body)) : fallbackBody;
|
const bodyJSX = body ? scaleSystemEmoji(trimReplyFromBody(body)) : fallbackBody;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" alignItems="Start" {...props} ref={ref}>
|
<Box direction="Row" gap="200" alignItems="Center" {...props} ref={ref}>
|
||||||
{threadRootId && (
|
{threadRootId && (
|
||||||
<ThreadIndicator as="button" data-event-id={threadRootId} onClick={onClick} />
|
<ThreadIndicator as="button" data-event-id={threadRootId} onClick={onClick} />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -5,19 +5,35 @@ import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../utils/ti
|
||||||
export type TimeProps = {
|
export type TimeProps = {
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
ts: number;
|
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>>(
|
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 = '';
|
let time = '';
|
||||||
if (compact) {
|
if (compact) {
|
||||||
time = timeHourMinute(ts);
|
time = formattedTime;
|
||||||
} else if (today(ts)) {
|
} else if (today(ts)) {
|
||||||
time = timeHourMinute(ts);
|
time = formattedTime;
|
||||||
} else if (yesterday(ts)) {
|
} else if (yesterday(ts)) {
|
||||||
time = `Yesterday ${timeHourMinute(ts)}`;
|
time = `Yesterday ${formattedTime}`;
|
||||||
} else {
|
} else {
|
||||||
time = `${timeDayMonYear(ts)} ${timeHourMinute(ts)}`;
|
time = `${timeDayMonYear(ts, dateFormatString)} ${formattedTime}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import { stopPropagation } from '../../../utils/keyboard';
|
||||||
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../utils/matrix';
|
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../utils/matrix';
|
||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
import { ModalWide } from '../../../styles/Modal.css';
|
import { ModalWide } from '../../../styles/Modal.css';
|
||||||
|
import { validBlurHash } from '../../../utils/blurHash';
|
||||||
|
|
||||||
type RenderViewerProps = {
|
type RenderViewerProps = {
|
||||||
src: string;
|
src: string;
|
||||||
|
|
@ -77,7 +78,7 @@ export const ImageContent = as<'div', ImageContentProps>(
|
||||||
) => {
|
) => {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
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 [load, setLoad] = useState(false);
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import {
|
||||||
mxcUrlToHttp,
|
mxcUrlToHttp,
|
||||||
} from '../../../utils/matrix';
|
} from '../../../utils/matrix';
|
||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
|
import { validBlurHash } from '../../../utils/blurHash';
|
||||||
|
|
||||||
type RenderVideoProps = {
|
type RenderVideoProps = {
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -68,7 +69,7 @@ export const VideoContent = as<'div', VideoContentProps>(
|
||||||
) => {
|
) => {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
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 [load, setLoad] = useState(false);
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ export const AbsoluteContainer = style([
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
|
zIndex: 1,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,20 @@ export const PageContent = as<'div'>(({ className, ...props }, ref) => (
|
||||||
<div className={classNames(css.PageContent, className)} {...props} ref={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>>(
|
export const PageHeroSection = as<'div', ComponentProps<typeof Box>>(
|
||||||
({ className, ...props }, ref) => (
|
({ className, ...props }, ref) => (
|
||||||
<Box
|
<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([
|
export const PageHeroSection = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -16,13 +16,14 @@ export const PowerSelector = forwardRef<HTMLDivElement, PowerSelectorProps>(
|
||||||
ref={ref}
|
ref={ref}
|
||||||
style={{
|
style={{
|
||||||
maxHeight: '75vh',
|
maxHeight: '75vh',
|
||||||
maxWidth: toRem(300),
|
maxWidth: toRem(200),
|
||||||
|
width: '100vw',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box grow="Yes">
|
<Box grow="Yes">
|
||||||
<Scroll size="0" hideTrack visibility="Hover">
|
<Scroll size="0" hideTrack visibility="Hover">
|
||||||
<div style={{ padding: config.space.S100 }}>
|
<div style={{ padding: config.space.S200 }}>
|
||||||
{getPowers(powerLevelTags).map((power) => {
|
{getPowers(powerLevelTags).map((power) => {
|
||||||
const selected = value === power;
|
const selected = value === power;
|
||||||
const tag = powerLevelTags[power];
|
const tag = powerLevelTags[power];
|
||||||
|
|
|
||||||
|
|
@ -273,7 +273,7 @@ export const RoomCard = as<'div', RoomCardProps>(
|
||||||
variant="Secondary"
|
variant="Secondary"
|
||||||
size="300"
|
size="300"
|
||||||
disabled={joining}
|
disabled={joining}
|
||||||
before={joining && <Spinner size="50" variant="Secondary" fill="Soft" />}
|
before={joining && <Spinner size="50" variant="Secondary" fill="Solid" />}
|
||||||
>
|
>
|
||||||
<Text size="B300" truncate>
|
<Text size="B300" truncate>
|
||||||
{joining ? 'Joining' : 'Join'}
|
{joining ? 'Joining' : 'Join'}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ import { nameInitials } from '../../utils/common';
|
||||||
import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
|
import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
|
||||||
import { mDirectAtom } from '../../state/mDirectList';
|
import { mDirectAtom } from '../../state/mDirectList';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
|
import { settingsAtom } from '../../state/settings';
|
||||||
|
|
||||||
export type RoomIntroProps = {
|
export type RoomIntroProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
|
|
@ -43,6 +45,8 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
|
||||||
useCallback(async (roomId: string) => mx.joinRoom(roomId), [mx])
|
useCallback(async (roomId: string) => mx.joinRoom(roomId), [mx])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" grow="Yes" gap="500" {...props} ref={ref}>
|
<Box direction="Column" grow="Yes" gap="500" {...props} ref={ref}>
|
||||||
<Box>
|
<Box>
|
||||||
|
|
@ -67,7 +71,7 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
|
||||||
<Text size="T200" priority="300">
|
<Text size="T200" priority="300">
|
||||||
{'Created by '}
|
{'Created by '}
|
||||||
<b>@{creatorName}</b>
|
<b>@{creatorName}</b>
|
||||||
{` on ${timeDayMonthYear(ts)} ${timeHourMinute(ts)}`}
|
{` on ${timeDayMonthYear(ts)} ${timeHourMinute(ts, hour24Clock)}`}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ export const TextViewerContent = forwardRef<HTMLPreElement, TextViewerContentPro
|
||||||
>
|
>
|
||||||
<ErrorBoundary fallback={<code>{text}</code>}>
|
<ErrorBoundary fallback={<code>{text}</code>}>
|
||||||
<Suspense 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>
|
</Suspense>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</Text>
|
</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,
|
escapeDeactivates: stopPropagation,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Menu style={{ padding: config.space.S100 }}>
|
<Menu style={{ padding: config.space.S200 }}>
|
||||||
{visibilityMenu.map((visibility) => (
|
{visibilityMenu.map((visibility) => (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
key={visibility}
|
key={visibility}
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,10 @@ import React, { useCallback, useMemo } from 'react';
|
||||||
import { color, Text } from 'folds';
|
import { color, Text } from 'folds';
|
||||||
import { JoinRule, MatrixError, RestrictedAllowType } from 'matrix-js-sdk';
|
import { JoinRule, MatrixError, RestrictedAllowType } from 'matrix-js-sdk';
|
||||||
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
|
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels';
|
import { IPowerLevels, powerLevelAPI } from '../../../hooks/usePowerLevels';
|
||||||
import {
|
import {
|
||||||
|
ExtendedJoinRules,
|
||||||
JoinRulesSwitcher,
|
JoinRulesSwitcher,
|
||||||
useRoomJoinRuleIcon,
|
useRoomJoinRuleIcon,
|
||||||
useRoomJoinRuleLabel,
|
useRoomJoinRuleLabel,
|
||||||
|
|
@ -19,6 +21,12 @@ import { useStateEvent } from '../../../hooks/useStateEvent';
|
||||||
import { useSpaceOptionally } from '../../../hooks/useSpace';
|
import { useSpaceOptionally } from '../../../hooks/useSpace';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
import { getStateEvents } from '../../../utils/room';
|
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 = {
|
type RestrictedRoomAllowContent = {
|
||||||
room_id: string;
|
room_id: string;
|
||||||
|
|
@ -32,9 +40,14 @@ export function RoomJoinRules({ powerLevels }: RoomJoinRulesProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const room = useRoom();
|
const room = useRoom();
|
||||||
const roomVersion = parseInt(room.getVersion(), 10);
|
const roomVersion = parseInt(room.getVersion(), 10);
|
||||||
|
const allowKnockRestricted = roomVersion >= 10;
|
||||||
const allowRestricted = roomVersion >= 8;
|
const allowRestricted = roomVersion >= 8;
|
||||||
const allowKnock = roomVersion >= 7;
|
const allowKnock = roomVersion >= 7;
|
||||||
|
|
||||||
|
const roomIdToParents = useAtomValue(roomToParentsAtom);
|
||||||
const space = useSpaceOptionally();
|
const space = useSpaceOptionally();
|
||||||
|
const subspacesScope = useRecursiveChildSpaceScopeFactory(mx, roomIdToParents);
|
||||||
|
const subspaces = useSpaceChildren(allRoomsAtom, space?.roomId ?? '', subspacesScope);
|
||||||
|
|
||||||
const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId());
|
const userPowerLevel = powerLevelAPI.getPowerLevel(powerLevels, mx.getSafeUserId());
|
||||||
const canEdit = powerLevelAPI.canSendStateEvent(
|
const canEdit = powerLevelAPI.canSendStateEvent(
|
||||||
|
|
@ -47,18 +60,21 @@ export function RoomJoinRules({ powerLevels }: RoomJoinRulesProps) {
|
||||||
const content = joinRuleEvent?.getContent<RoomJoinRulesEventContent>();
|
const content = joinRuleEvent?.getContent<RoomJoinRulesEventContent>();
|
||||||
const rule: JoinRule = content?.join_rule ?? JoinRule.Invite;
|
const rule: JoinRule = content?.join_rule ?? JoinRule.Invite;
|
||||||
|
|
||||||
const joinRules: Array<JoinRule> = useMemo(() => {
|
const joinRules: Array<ExtendedJoinRules> = useMemo(() => {
|
||||||
const r: JoinRule[] = [JoinRule.Invite];
|
const r: ExtendedJoinRules[] = [JoinRule.Invite];
|
||||||
if (allowKnock) {
|
if (allowKnock) {
|
||||||
r.push(JoinRule.Knock);
|
r.push(JoinRule.Knock);
|
||||||
}
|
}
|
||||||
if (allowRestricted && space) {
|
if (allowRestricted && space) {
|
||||||
r.push(JoinRule.Restricted);
|
r.push(JoinRule.Restricted);
|
||||||
}
|
}
|
||||||
|
if (allowKnockRestricted && space) {
|
||||||
|
r.push('knock_restricted');
|
||||||
|
}
|
||||||
r.push(JoinRule.Public);
|
r.push(JoinRule.Public);
|
||||||
|
|
||||||
return r;
|
return r;
|
||||||
}, [allowRestricted, allowKnock, space]);
|
}, [allowKnockRestricted, allowRestricted, allowKnock, space]);
|
||||||
|
|
||||||
const icons = useRoomJoinRuleIcon();
|
const icons = useRoomJoinRuleIcon();
|
||||||
const spaceIcons = useSpaceJoinRuleIcon();
|
const spaceIcons = useSpaceJoinRuleIcon();
|
||||||
|
|
@ -66,12 +82,25 @@ export function RoomJoinRules({ powerLevels }: RoomJoinRulesProps) {
|
||||||
|
|
||||||
const [submitState, submit] = useAsyncCallback(
|
const [submitState, submit] = useAsyncCallback(
|
||||||
useCallback(
|
useCallback(
|
||||||
async (joinRule: JoinRule) => {
|
async (joinRule: ExtendedJoinRules) => {
|
||||||
const allow: RestrictedRoomAllowContent[] = [];
|
const allow: RestrictedRoomAllowContent[] = [];
|
||||||
if (joinRule === JoinRule.Restricted) {
|
if (joinRule === JoinRule.Restricted || joinRule === 'knock_restricted') {
|
||||||
const parents = getStateEvents(room, StateEvent.SpaceParent).map((event) =>
|
const roomParents = roomIdToParents.get(room.roomId);
|
||||||
event.getStateKey()
|
|
||||||
);
|
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) => {
|
parents.forEach((parentRoomId) => {
|
||||||
if (!parentRoomId) return;
|
if (!parentRoomId) return;
|
||||||
allow.push({
|
allow.push({
|
||||||
|
|
@ -82,12 +111,12 @@ export function RoomJoinRules({ powerLevels }: RoomJoinRulesProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const c: RoomJoinRulesEventContent = {
|
const c: RoomJoinRulesEventContent = {
|
||||||
join_rule: joinRule,
|
join_rule: joinRule as JoinRule,
|
||||||
};
|
};
|
||||||
if (allow.length > 0) c.allow = allow;
|
if (allow.length > 0) c.allow = allow;
|
||||||
await mx.sendStateEvent(room.roomId, StateEvent.RoomJoinRules as any, c);
|
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}>
|
<Box grow="Yes" tabIndex={0}>
|
||||||
<Scroll size="0" hideTrack visibility="Hover">
|
<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) => (
|
{permissionGroups.map((group, groupIndex) => (
|
||||||
<Box key={groupIndex} direction="Column" gap="100">
|
<Box key={groupIndex} direction="Column" gap="100">
|
||||||
<Text size="L400">{group.name}</Text>
|
<Text size="L400">{group.name}</Text>
|
||||||
|
|
|
||||||
|
|
@ -234,9 +234,9 @@ export function HierarchyItemMenu({
|
||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Menu style={{ maxWidth: toRem(150), width: '100vw' }}>
|
<Menu style={{ minWidth: toRem(200) }}>
|
||||||
{joined && (
|
{joined && (
|
||||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
<Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
|
||||||
{onTogglePin && (
|
{onTogglePin && (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
size="300"
|
size="300"
|
||||||
|
|
@ -296,7 +296,7 @@ export function HierarchyItemMenu({
|
||||||
<Line size="300" variant="Surface" direction="Horizontal" />
|
<Line size="300" variant="Surface" direction="Horizontal" />
|
||||||
)}
|
)}
|
||||||
{canEditChild && (
|
{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} />
|
<SuggestMenuItem item={item} requestClose={handleRequestClose} />
|
||||||
<RemoveMenuItem item={item} requestClose={handleRequestClose} />
|
<RemoveMenuItem item={item} requestClose={handleRequestClose} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { MouseEventHandler, useCallback, useMemo, useRef, useState } from 'react';
|
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 { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { useAtom, useAtomValue } from 'jotai';
|
import { useAtom, useAtomValue } from 'jotai';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
@ -36,7 +36,7 @@ import { makeLobbyCategoryId } from '../../state/closedLobbyCategories';
|
||||||
import { useCategoryHandler } from '../../hooks/useCategoryHandler';
|
import { useCategoryHandler } from '../../hooks/useCategoryHandler';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { allRoomsAtom } from '../../state/room-list/roomList';
|
import { allRoomsAtom } from '../../state/room-list/roomList';
|
||||||
import { getCanonicalAliasOrRoomId } from '../../utils/matrix';
|
import { getCanonicalAliasOrRoomId, rateLimitedActions } from '../../utils/matrix';
|
||||||
import { getSpaceRoomPath } from '../../pages/pathUtils';
|
import { getSpaceRoomPath } from '../../pages/pathUtils';
|
||||||
import { StateEvent } from '../../../types/matrix/room';
|
import { StateEvent } from '../../../types/matrix/room';
|
||||||
import { CanDropCallback, useDnDMonitor } from './DnD';
|
import { CanDropCallback, useDnDMonitor } from './DnD';
|
||||||
|
|
@ -53,6 +53,95 @@ import { roomToParentsAtom } from '../../state/room/roomToParents';
|
||||||
import { AccountDataEvent } from '../../../types/matrix/accountData';
|
import { AccountDataEvent } from '../../../types/matrix/accountData';
|
||||||
import { useRoomMembers } from '../../hooks/useRoomMembers';
|
import { useRoomMembers } from '../../hooks/useRoomMembers';
|
||||||
import { SpaceHierarchy } from './SpaceHierarchy';
|
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() {
|
export function Lobby() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
@ -92,15 +181,7 @@ export function Lobby() {
|
||||||
useCallback((w, height) => setHeroSectionHeight(height), [])
|
useCallback((w, height) => setHeroSectionHeight(height), [])
|
||||||
);
|
);
|
||||||
|
|
||||||
const getRoom = useCallback(
|
const getRoom = useGetRoom(allJoinedRooms);
|
||||||
(rId: string) => {
|
|
||||||
if (allJoinedRooms.has(rId)) {
|
|
||||||
return mx.getRoom(rId) ?? undefined;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
},
|
|
||||||
[mx, allJoinedRooms]
|
|
||||||
);
|
|
||||||
|
|
||||||
const canEditSpaceChild = useCallback(
|
const canEditSpaceChild = useCallback(
|
||||||
(powerLevels: IPowerLevels) =>
|
(powerLevels: IPowerLevels) =>
|
||||||
|
|
@ -150,64 +231,16 @@ export function Lobby() {
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const canDrop: CanDropCallback = useCallback(
|
const canDrop: CanDropCallback = useCanDropLobbyItem(
|
||||||
(item, container): boolean => {
|
space,
|
||||||
const restrictedItem = mx.getRoom(item.roomId)?.getJoinRule() === JoinRule.Restricted;
|
roomsPowerLevels,
|
||||||
if (item.roomId === container.item.roomId || item.roomId === container.nextRoomId) {
|
getRoom,
|
||||||
// can not drop before or after itself
|
canEditSpaceChild
|
||||||
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 reorderSpace = useCallback(
|
const [reorderSpaceState, reorderSpace] = useAsyncCallback(
|
||||||
(item: HierarchyItemSpace, containerItem: HierarchyItem) => {
|
useCallback(
|
||||||
|
async (item: HierarchyItemSpace, containerItem: HierarchyItem) => {
|
||||||
if (!item.parentId) return;
|
if (!item.parentId) return;
|
||||||
|
|
||||||
const itemSpaces: HierarchyItemSpace[] = hierarchy
|
const itemSpaces: HierarchyItemSpace[] = hierarchy
|
||||||
|
|
@ -231,26 +264,38 @@ export function Lobby() {
|
||||||
|
|
||||||
const newOrders = orderKeys(lex, currentOrders);
|
const newOrders = orderKeys(lex, currentOrders);
|
||||||
|
|
||||||
newOrders?.forEach((orderKey, index) => {
|
const reorders = newOrders
|
||||||
const itm = itemSpaces[index];
|
?.map((orderKey, index) => ({
|
||||||
if (!itm || !itm.parentId) return;
|
item: itemSpaces[index],
|
||||||
const parentPL = roomsPowerLevels.get(itm.parentId);
|
orderKey,
|
||||||
|
}))
|
||||||
|
.filter((reorder, index) => {
|
||||||
|
if (!reorder.item.parentId) return false;
|
||||||
|
const parentPL = roomsPowerLevels.get(reorder.item.parentId);
|
||||||
const canEdit = parentPL && canEditSpaceChild(parentPL);
|
const canEdit = parentPL && canEditSpaceChild(parentPL);
|
||||||
if (canEdit && orderKey !== currentOrders[index]) {
|
return canEdit && reorder.orderKey !== currentOrders[index];
|
||||||
mx.sendStateEvent(
|
|
||||||
itm.parentId,
|
|
||||||
StateEvent.SpaceChild as any,
|
|
||||||
{ ...itm.content, order: orderKey },
|
|
||||||
itm.roomId
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[mx, hierarchy, lex, roomsPowerLevels, canEditSpaceChild]
|
[mx, hierarchy, lex, roomsPowerLevels, canEditSpaceChild]
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
const reorderingSpace = reorderSpaceState.status === AsyncStatus.Loading;
|
||||||
|
|
||||||
const reorderRoom = useCallback(
|
const [reorderRoomState, reorderRoom] = useAsyncCallback(
|
||||||
(item: HierarchyItem, containerItem: HierarchyItem): void => {
|
useCallback(
|
||||||
|
async (item: HierarchyItem, containerItem: HierarchyItem) => {
|
||||||
const itemRoom = mx.getRoom(item.roomId);
|
const itemRoom = mx.getRoom(item.roomId);
|
||||||
if (!item.parentId) {
|
if (!item.parentId) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -259,6 +304,7 @@ export function Lobby() {
|
||||||
'space' in containerItem ? containerItem.roomId : containerItem.parentId;
|
'space' in containerItem ? containerItem.roomId : containerItem.parentId;
|
||||||
const itemContent = item.content;
|
const itemContent = item.content;
|
||||||
|
|
||||||
|
// remove from current space
|
||||||
if (item.parentId !== containerParentId) {
|
if (item.parentId !== containerParentId) {
|
||||||
mx.sendStateEvent(item.parentId, StateEvent.SpaceChild as any, {}, item.roomId);
|
mx.sendStateEvent(item.parentId, StateEvent.SpaceChild as any, {}, item.roomId);
|
||||||
}
|
}
|
||||||
|
|
@ -277,7 +323,8 @@ export function Lobby() {
|
||||||
|
|
||||||
if (joinRuleContent) {
|
if (joinRuleContent) {
|
||||||
const allow =
|
const allow =
|
||||||
joinRuleContent.allow?.filter((allowRule) => allowRule.room_id !== item.parentId) ?? [];
|
joinRuleContent.allow?.filter((allowRule) => allowRule.room_id !== item.parentId) ??
|
||||||
|
[];
|
||||||
allow.push({ type: RestrictedAllowType.RoomMembership, room_id: containerParentId });
|
allow.push({ type: RestrictedAllowType.RoomMembership, room_id: containerParentId });
|
||||||
mx.sendStateEvent(itemRoom.roomId, StateEvent.RoomJoinRules as any, {
|
mx.sendStateEvent(itemRoom.roomId, StateEvent.RoomJoinRules as any, {
|
||||||
...joinRuleContent,
|
...joinRuleContent,
|
||||||
|
|
@ -310,20 +357,29 @@ export function Lobby() {
|
||||||
|
|
||||||
const newOrders = orderKeys(lex, currentOrders);
|
const newOrders = orderKeys(lex, currentOrders);
|
||||||
|
|
||||||
newOrders?.forEach((orderKey, index) => {
|
const reorders = newOrders
|
||||||
const itm = itemSpaces[index];
|
?.map((orderKey, index) => ({
|
||||||
if (itm && orderKey !== currentOrders[index]) {
|
item: itemSpaces[index],
|
||||||
mx.sendStateEvent(
|
orderKey,
|
||||||
|
}))
|
||||||
|
.filter((reorder, index) => reorder.item && reorder.orderKey !== currentOrders[index]);
|
||||||
|
|
||||||
|
if (reorders) {
|
||||||
|
await rateLimitedActions(reorders, async (reorder) => {
|
||||||
|
await mx.sendStateEvent(
|
||||||
containerParentId,
|
containerParentId,
|
||||||
StateEvent.SpaceChild as any,
|
StateEvent.SpaceChild as any,
|
||||||
{ ...itm.content, order: orderKey },
|
{ ...reorder.item.content, order: reorder.orderKey },
|
||||||
itm.roomId
|
reorder.item.roomId
|
||||||
);
|
);
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[mx, hierarchy, lex]
|
[mx, hierarchy, lex]
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
const reorderingRoom = reorderRoomState.status === AsyncStatus.Loading;
|
||||||
|
const reordering = reorderingRoom || reorderingSpace;
|
||||||
|
|
||||||
useDnDMonitor(
|
useDnDMonitor(
|
||||||
scrollRef,
|
scrollRef,
|
||||||
|
|
@ -449,6 +505,7 @@ export function Lobby() {
|
||||||
draggingItem={draggingItem}
|
draggingItem={draggingItem}
|
||||||
onDragging={setDraggingItem}
|
onDragging={setDraggingItem}
|
||||||
canDrop={canDrop}
|
canDrop={canDrop}
|
||||||
|
disabledReorder={reordering}
|
||||||
nextSpaceId={nextSpaceId}
|
nextSpaceId={nextSpaceId}
|
||||||
getRoom={getRoom}
|
getRoom={getRoom}
|
||||||
pinned={sidebarSpaces.has(item.space.roomId)}
|
pinned={sidebarSpaces.has(item.space.roomId)}
|
||||||
|
|
@ -460,6 +517,28 @@ export function Lobby() {
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</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>
|
</PageContentCenter>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
</Scroll>
|
</Scroll>
|
||||||
|
|
|
||||||
|
|
@ -60,8 +60,8 @@ const LobbyMenu = forwardRef<HTMLDivElement, LobbyMenuProps>(
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
|
<Menu ref={ref} style={{ minWidth: toRem(200) }}>
|
||||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
<Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={handleInvite}
|
onClick={handleInvite}
|
||||||
variant="Primary"
|
variant="Primary"
|
||||||
|
|
@ -87,7 +87,7 @@ const LobbyMenu = forwardRef<HTMLDivElement, LobbyMenuProps>(
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</Box>
|
</Box>
|
||||||
<Line variant="Surface" size="300" />
|
<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}>
|
<UseStateProvider initial={false}>
|
||||||
{(promptLeave, setPromptLeave) => (
|
{(promptLeave, setPromptLeave) => (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ type SpaceHierarchyProps = {
|
||||||
draggingItem?: HierarchyItem;
|
draggingItem?: HierarchyItem;
|
||||||
onDragging: (item?: HierarchyItem) => void;
|
onDragging: (item?: HierarchyItem) => void;
|
||||||
canDrop: CanDropCallback;
|
canDrop: CanDropCallback;
|
||||||
|
disabledReorder?: boolean;
|
||||||
nextSpaceId?: string;
|
nextSpaceId?: string;
|
||||||
getRoom: (roomId: string) => Room | undefined;
|
getRoom: (roomId: string) => Room | undefined;
|
||||||
pinned: boolean;
|
pinned: boolean;
|
||||||
|
|
@ -54,6 +55,7 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
|
||||||
draggingItem,
|
draggingItem,
|
||||||
onDragging,
|
onDragging,
|
||||||
canDrop,
|
canDrop,
|
||||||
|
disabledReorder,
|
||||||
nextSpaceId,
|
nextSpaceId,
|
||||||
getRoom,
|
getRoom,
|
||||||
pinned,
|
pinned,
|
||||||
|
|
@ -116,7 +118,9 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
|
||||||
handleClose={handleClose}
|
handleClose={handleClose}
|
||||||
getRoom={getRoom}
|
getRoom={getRoom}
|
||||||
canEditChild={canEditSpaceChild(spacePowerLevels)}
|
canEditChild={canEditSpaceChild(spacePowerLevels)}
|
||||||
canReorder={parentPowerLevels ? canEditSpaceChild(parentPowerLevels) : false}
|
canReorder={
|
||||||
|
parentPowerLevels && !disabledReorder ? canEditSpaceChild(parentPowerLevels) : false
|
||||||
|
}
|
||||||
options={
|
options={
|
||||||
parentId &&
|
parentId &&
|
||||||
parentPowerLevels && (
|
parentPowerLevels && (
|
||||||
|
|
@ -174,7 +178,7 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
|
||||||
dm={mDirects.has(roomItem.roomId)}
|
dm={mDirects.has(roomItem.roomId)}
|
||||||
onOpen={onOpenRoom}
|
onOpen={onOpenRoom}
|
||||||
getRoom={getRoom}
|
getRoom={getRoom}
|
||||||
canReorder={canEditSpaceChild(spacePowerLevels)}
|
canReorder={canEditSpaceChild(spacePowerLevels) && !disabledReorder}
|
||||||
options={
|
options={
|
||||||
<HierarchyItemMenu
|
<HierarchyItemMenu
|
||||||
item={roomItem}
|
item={roomItem}
|
||||||
|
|
|
||||||
|
|
@ -271,7 +271,7 @@ function AddRoomButton({ item }: { item: HierarchyItem }) {
|
||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Menu style={{ padding: config.space.S100 }}>
|
<Menu style={{ padding: config.space.S200 }}>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
size="300"
|
size="300"
|
||||||
radii="300"
|
radii="300"
|
||||||
|
|
@ -333,7 +333,7 @@ function AddSpaceButton({ item }: { item: HierarchyItem }) {
|
||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Menu style={{ padding: config.space.S100 }}>
|
<Menu style={{ padding: config.space.S200 }}>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
size="300"
|
size="300"
|
||||||
radii="300"
|
radii="300"
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { SearchOrderBy } from 'matrix-js-sdk';
|
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 { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { _SearchPathSearchParams } from '../../pages/paths';
|
import { _SearchPathSearchParams } from '../../pages/paths';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
|
|
@ -57,6 +57,9 @@ export function MessageSearch({
|
||||||
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
|
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
|
||||||
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
|
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
|
||||||
|
|
||||||
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
|
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||||
|
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
const scrollTopAnchorRef = useRef<HTMLDivElement>(null);
|
const scrollTopAnchorRef = useRef<HTMLDivElement>(null);
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
@ -222,18 +225,7 @@ export function MessageSearch({
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{!msgSearchParams.term && status === 'pending' && (
|
{!msgSearchParams.term && status === 'pending' && (
|
||||||
<Box
|
<PageHeroEmpty>
|
||||||
className={ContainerColor({ variant: 'SurfaceVariant' })}
|
|
||||||
style={{
|
|
||||||
padding: config.space.S400,
|
|
||||||
borderRadius: config.radii.R400,
|
|
||||||
minHeight: toRem(450),
|
|
||||||
}}
|
|
||||||
direction="Column"
|
|
||||||
alignItems="Center"
|
|
||||||
justifyContent="Center"
|
|
||||||
gap="200"
|
|
||||||
>
|
|
||||||
<PageHeroSection>
|
<PageHeroSection>
|
||||||
<PageHero
|
<PageHero
|
||||||
icon={<Icon size="600" src={Icons.Message} />}
|
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."
|
subTitle="Find helpful messages in your community by searching with related keywords."
|
||||||
/>
|
/>
|
||||||
</PageHeroSection>
|
</PageHeroSection>
|
||||||
</Box>
|
</PageHeroEmpty>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{msgSearchParams.term && groups.length === 0 && status === 'success' && (
|
{msgSearchParams.term && groups.length === 0 && status === 'success' && (
|
||||||
|
|
@ -300,6 +292,8 @@ export function MessageSearch({
|
||||||
urlPreview={urlPreview}
|
urlPreview={urlPreview}
|
||||||
onOpen={navigateRoom}
|
onOpen={navigateRoom}
|
||||||
legacyUsernameColor={legacyUsernameColor || mDirects.has(groupRoom.roomId)}
|
legacyUsernameColor={legacyUsernameColor || mDirects.has(groupRoom.roomId)}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
/>
|
/>
|
||||||
</VirtualTile>
|
</VirtualTile>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -74,8 +74,7 @@ function OrderButton({ order, onChange }: OrderButtonProps) {
|
||||||
<Header size="300" variant="Surface" style={{ padding: `0 ${config.space.S300}` }}>
|
<Header size="300" variant="Surface" style={{ padding: `0 ${config.space.S300}` }}>
|
||||||
<Text size="L400">Sort by</Text>
|
<Text size="L400">Sort by</Text>
|
||||||
</Header>
|
</Header>
|
||||||
<Line variant="Surface" size="300" />
|
<div style={{ padding: config.space.S200, paddingTop: 0 }}>
|
||||||
<div style={{ padding: config.space.S100 }}>
|
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={() => setOrder()}
|
onClick={() => setOrder()}
|
||||||
variant="Surface"
|
variant="Surface"
|
||||||
|
|
@ -291,7 +290,7 @@ function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButto
|
||||||
</Box>
|
</Box>
|
||||||
</Scroll>
|
</Scroll>
|
||||||
<Line variant="Surface" size="300" />
|
<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}>
|
<Button size="300" variant="Secondary" radii="300" onClick={handleSave}>
|
||||||
{localSelected && localSelected.length > 0 ? (
|
{localSelected && localSelected.length > 0 ? (
|
||||||
<Text size="B300">Save ({localSelected.length})</Text>
|
<Text size="B300">Save ({localSelected.length})</Text>
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ export function SearchInput({ active, loading, searchInputRef, onSearch, onReset
|
||||||
ref={searchInputRef}
|
ref={searchInputRef}
|
||||||
style={{ paddingRight: config.space.S300 }}
|
style={{ paddingRight: config.space.S300 }}
|
||||||
name="searchInput"
|
name="searchInput"
|
||||||
|
autoFocus
|
||||||
size="500"
|
size="500"
|
||||||
variant="Background"
|
variant="Background"
|
||||||
placeholder="Search for keyword"
|
placeholder="Search for keyword"
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,8 @@ type SearchResultGroupProps = {
|
||||||
urlPreview?: boolean;
|
urlPreview?: boolean;
|
||||||
onOpen: (roomId: string, eventId: string) => void;
|
onOpen: (roomId: string, eventId: string) => void;
|
||||||
legacyUsernameColor?: boolean;
|
legacyUsernameColor?: boolean;
|
||||||
|
hour24Clock: boolean;
|
||||||
|
dateFormatString: string;
|
||||||
};
|
};
|
||||||
export function SearchResultGroup({
|
export function SearchResultGroup({
|
||||||
room,
|
room,
|
||||||
|
|
@ -66,6 +68,8 @@ export function SearchResultGroup({
|
||||||
urlPreview,
|
urlPreview,
|
||||||
onOpen,
|
onOpen,
|
||||||
legacyUsernameColor,
|
legacyUsernameColor,
|
||||||
|
hour24Clock,
|
||||||
|
dateFormatString,
|
||||||
}: SearchResultGroupProps) {
|
}: SearchResultGroupProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
|
@ -275,7 +279,11 @@ export function SearchResultGroup({
|
||||||
</Username>
|
</Username>
|
||||||
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
|
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
|
||||||
</Box>
|
</Box>
|
||||||
<Time ts={event.origin_server_ts} />
|
<Time
|
||||||
|
ts={event.origin_server_ts}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Box shrink="No" gap="200" alignItems="Center">
|
<Box shrink="No" gap="200" alignItems="Center">
|
||||||
<Chip
|
<Chip
|
||||||
|
|
|
||||||
|
|
@ -89,8 +89,8 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
|
<Menu ref={ref} style={{ minWidth: toRem(200) }}>
|
||||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
<Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={handleMarkAsRead}
|
onClick={handleMarkAsRead}
|
||||||
size="300"
|
size="300"
|
||||||
|
|
@ -125,7 +125,7 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
||||||
</RoomNotificationModeSwitcher>
|
</RoomNotificationModeSwitcher>
|
||||||
</Box>
|
</Box>
|
||||||
<Line variant="Surface" size="300" />
|
<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
|
<MenuItem
|
||||||
onClick={handleInvite}
|
onClick={handleInvite}
|
||||||
variant="Primary"
|
variant="Primary"
|
||||||
|
|
@ -161,7 +161,7 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</Box>
|
</Box>
|
||||||
<Line variant="Surface" size="300" />
|
<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}>
|
<UseStateProvider initial={false}>
|
||||||
{(promptLeave, setPromptLeave) => (
|
{(promptLeave, setPromptLeave) => (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import React, { KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo } from 'react';
|
import React, { KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo } from 'react';
|
||||||
import { Editor } from 'slate';
|
import { Editor } from 'slate';
|
||||||
import { Box, MenuItem, Text } from 'folds';
|
import { Box, config, MenuItem, Text } from 'folds';
|
||||||
import { Room } from 'matrix-js-sdk';
|
import { Room } from 'matrix-js-sdk';
|
||||||
import { Command, useCommands } from '../../hooks/useCommands';
|
import { Command, useCommands } from '../../hooks/useCommands';
|
||||||
import {
|
import {
|
||||||
|
|
@ -75,9 +75,6 @@ export function CommandAutocomplete({
|
||||||
headerContent={
|
headerContent={
|
||||||
<Box grow="Yes" direction="Row" gap="200" justifyContent="SpaceBetween">
|
<Box grow="Yes" direction="Row" gap="200" justifyContent="SpaceBetween">
|
||||||
<Text size="L400">Commands</Text>
|
<Text size="L400">Commands</Text>
|
||||||
<Text size="T200" priority="300" truncate>
|
|
||||||
Begin your message with command
|
|
||||||
</Text>
|
|
||||||
</Box>
|
</Box>
|
||||||
}
|
}
|
||||||
requestClose={requestClose}
|
requestClose={requestClose}
|
||||||
|
|
@ -87,17 +84,22 @@ export function CommandAutocomplete({
|
||||||
key={commandName}
|
key={commandName}
|
||||||
as="button"
|
as="button"
|
||||||
radii="300"
|
radii="300"
|
||||||
|
style={{ height: 'unset' }}
|
||||||
onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) =>
|
onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) =>
|
||||||
onTabPress(evt, () => handleAutocomplete(commandName))
|
onTabPress(evt, () => handleAutocomplete(commandName))
|
||||||
}
|
}
|
||||||
onClick={() => handleAutocomplete(commandName)}
|
onClick={() => handleAutocomplete(commandName)}
|
||||||
>
|
>
|
||||||
<Box grow="Yes" direction="Row" gap="200" justifyContent="SpaceBetween">
|
<Box
|
||||||
<Box shrink="No">
|
style={{ padding: `${config.space.S300} 0` }}
|
||||||
|
grow="Yes"
|
||||||
|
direction="Column"
|
||||||
|
gap="100"
|
||||||
|
justifyContent="SpaceBetween"
|
||||||
|
>
|
||||||
<Text style={{ flexGrow: 1 }} size="B400" truncate>
|
<Text style={{ flexGrow: 1 }} size="B400" truncate>
|
||||||
{`/${commandName}`}
|
{`/${commandName}`}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
|
||||||
<Text truncate priority="300" size="T200">
|
<Text truncate priority="300" size="T200">
|
||||||
{commands[commandName].description}
|
{commands[commandName].description}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
|
||||||
|
|
@ -543,7 +543,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
>
|
>
|
||||||
<Icon src={Icons.Cross} size="50" />
|
<Icon src={Icons.Cross} size="50" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<Box direction="Column">
|
<Box direction="Row" gap="200" alignItems="Center">
|
||||||
{replyDraft.relation?.rel_type === RelationType.Thread && <ThreadIndicator />}
|
{replyDraft.relation?.rel_type === RelationType.Thread && <ThreadIndicator />}
|
||||||
<ReplyLayout
|
<ReplyLayout
|
||||||
userColor={replyUsernameColor}
|
userColor={replyUsernameColor}
|
||||||
|
|
|
||||||
|
|
@ -448,6 +448,10 @@ export function RoomTimeline({
|
||||||
const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
|
const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
|
||||||
const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
|
const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
|
||||||
const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
|
const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
|
||||||
|
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
|
||||||
|
|
||||||
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
|
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||||
|
|
||||||
const ignoredUsersList = useIgnoredUsers();
|
const ignoredUsersList = useIgnoredUsers();
|
||||||
const ignoredUsersSet = useMemo(() => new Set(ignoredUsersList), [ignoredUsersList]);
|
const ignoredUsersSet = useMemo(() => new Set(ignoredUsersList), [ignoredUsersList]);
|
||||||
|
|
@ -932,7 +936,7 @@ export function RoomTimeline({
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleReplyClick: MouseEventHandler<HTMLButtonElement> = useCallback(
|
const handleReplyClick: MouseEventHandler<HTMLButtonElement> = useCallback(
|
||||||
(evt) => {
|
(evt, startThread = false) => {
|
||||||
const replyId = evt.currentTarget.getAttribute('data-event-id');
|
const replyId = evt.currentTarget.getAttribute('data-event-id');
|
||||||
if (!replyId) {
|
if (!replyId) {
|
||||||
console.warn('Button should have "data-event-id" attribute!');
|
console.warn('Button should have "data-event-id" attribute!');
|
||||||
|
|
@ -943,7 +947,9 @@ export function RoomTimeline({
|
||||||
const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet());
|
const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet());
|
||||||
const content: IContent = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
|
const content: IContent = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
|
||||||
const { body, formatted_body: formattedBody } = content;
|
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();
|
const senderId = replyEvt.getSender();
|
||||||
if (senderId && typeof body === 'string') {
|
if (senderId && typeof body === 'string') {
|
||||||
setReplyDraft({
|
setReplyDraft({
|
||||||
|
|
@ -1065,9 +1071,12 @@ export function RoomTimeline({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
hideReadReceipts={hideActivity}
|
hideReadReceipts={hideActivity}
|
||||||
|
showDeveloperTools={showDeveloperTools}
|
||||||
powerLevelTag={getPowerLevelTag(senderPowerLevel)}
|
powerLevelTag={getPowerLevelTag(senderPowerLevel)}
|
||||||
accessibleTagColors={accessibleTagColors}
|
accessibleTagColors={accessibleTagColors}
|
||||||
legacyUsernameColor={legacyUsernameColor || direct}
|
legacyUsernameColor={legacyUsernameColor || direct}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
>
|
>
|
||||||
{mEvent.isRedacted() ? (
|
{mEvent.isRedacted() ? (
|
||||||
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
|
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
|
||||||
|
|
@ -1146,9 +1155,12 @@ export function RoomTimeline({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
hideReadReceipts={hideActivity}
|
hideReadReceipts={hideActivity}
|
||||||
|
showDeveloperTools={showDeveloperTools}
|
||||||
powerLevelTag={getPowerLevelTag(senderPowerLevel)}
|
powerLevelTag={getPowerLevelTag(senderPowerLevel)}
|
||||||
accessibleTagColors={accessibleTagColors}
|
accessibleTagColors={accessibleTagColors}
|
||||||
legacyUsernameColor={legacyUsernameColor || direct}
|
legacyUsernameColor={legacyUsernameColor || direct}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
>
|
>
|
||||||
<EncryptedContent mEvent={mEvent}>
|
<EncryptedContent mEvent={mEvent}>
|
||||||
{() => {
|
{() => {
|
||||||
|
|
@ -1247,9 +1259,12 @@ export function RoomTimeline({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
hideReadReceipts={hideActivity}
|
hideReadReceipts={hideActivity}
|
||||||
|
showDeveloperTools={showDeveloperTools}
|
||||||
powerLevelTag={getPowerLevelTag(senderPowerLevel)}
|
powerLevelTag={getPowerLevelTag(senderPowerLevel)}
|
||||||
accessibleTagColors={accessibleTagColors}
|
accessibleTagColors={accessibleTagColors}
|
||||||
legacyUsernameColor={legacyUsernameColor || direct}
|
legacyUsernameColor={legacyUsernameColor || direct}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
>
|
>
|
||||||
{mEvent.isRedacted() ? (
|
{mEvent.isRedacted() ? (
|
||||||
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
|
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
|
||||||
|
|
@ -1278,7 +1293,12 @@ export function RoomTimeline({
|
||||||
const parsed = parseMemberEvent(mEvent);
|
const parsed = parseMemberEvent(mEvent);
|
||||||
|
|
||||||
const timeJSX = (
|
const timeJSX = (
|
||||||
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
|
<Time
|
||||||
|
ts={mEvent.getTs()}
|
||||||
|
compact={messageLayout === MessageLayout.Compact}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -1292,6 +1312,7 @@ export function RoomTimeline({
|
||||||
messageSpacing={messageSpacing}
|
messageSpacing={messageSpacing}
|
||||||
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
||||||
hideReadReceipts={hideActivity}
|
hideReadReceipts={hideActivity}
|
||||||
|
showDeveloperTools={showDeveloperTools}
|
||||||
>
|
>
|
||||||
<EventContent
|
<EventContent
|
||||||
messageLayout={messageLayout}
|
messageLayout={messageLayout}
|
||||||
|
|
@ -1314,7 +1335,12 @@ export function RoomTimeline({
|
||||||
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
||||||
|
|
||||||
const timeJSX = (
|
const timeJSX = (
|
||||||
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
|
<Time
|
||||||
|
ts={mEvent.getTs()}
|
||||||
|
compact={messageLayout === MessageLayout.Compact}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -1328,6 +1354,7 @@ export function RoomTimeline({
|
||||||
messageSpacing={messageSpacing}
|
messageSpacing={messageSpacing}
|
||||||
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
||||||
hideReadReceipts={hideActivity}
|
hideReadReceipts={hideActivity}
|
||||||
|
showDeveloperTools={showDeveloperTools}
|
||||||
>
|
>
|
||||||
<EventContent
|
<EventContent
|
||||||
messageLayout={messageLayout}
|
messageLayout={messageLayout}
|
||||||
|
|
@ -1351,7 +1378,12 @@ export function RoomTimeline({
|
||||||
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
||||||
|
|
||||||
const timeJSX = (
|
const timeJSX = (
|
||||||
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
|
<Time
|
||||||
|
ts={mEvent.getTs()}
|
||||||
|
compact={messageLayout === MessageLayout.Compact}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -1365,6 +1397,7 @@ export function RoomTimeline({
|
||||||
messageSpacing={messageSpacing}
|
messageSpacing={messageSpacing}
|
||||||
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
||||||
hideReadReceipts={hideActivity}
|
hideReadReceipts={hideActivity}
|
||||||
|
showDeveloperTools={showDeveloperTools}
|
||||||
>
|
>
|
||||||
<EventContent
|
<EventContent
|
||||||
messageLayout={messageLayout}
|
messageLayout={messageLayout}
|
||||||
|
|
@ -1388,7 +1421,12 @@ export function RoomTimeline({
|
||||||
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
||||||
|
|
||||||
const timeJSX = (
|
const timeJSX = (
|
||||||
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
|
<Time
|
||||||
|
ts={mEvent.getTs()}
|
||||||
|
compact={messageLayout === MessageLayout.Compact}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -1402,6 +1440,7 @@ export function RoomTimeline({
|
||||||
messageSpacing={messageSpacing}
|
messageSpacing={messageSpacing}
|
||||||
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
||||||
hideReadReceipts={hideActivity}
|
hideReadReceipts={hideActivity}
|
||||||
|
showDeveloperTools={showDeveloperTools}
|
||||||
>
|
>
|
||||||
<EventContent
|
<EventContent
|
||||||
messageLayout={messageLayout}
|
messageLayout={messageLayout}
|
||||||
|
|
@ -1427,7 +1466,12 @@ export function RoomTimeline({
|
||||||
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
||||||
|
|
||||||
const timeJSX = (
|
const timeJSX = (
|
||||||
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
|
<Time
|
||||||
|
ts={mEvent.getTs()}
|
||||||
|
compact={messageLayout === MessageLayout.Compact}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -1441,6 +1485,7 @@ export function RoomTimeline({
|
||||||
messageSpacing={messageSpacing}
|
messageSpacing={messageSpacing}
|
||||||
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
||||||
hideReadReceipts={hideActivity}
|
hideReadReceipts={hideActivity}
|
||||||
|
showDeveloperTools={showDeveloperTools}
|
||||||
>
|
>
|
||||||
<EventContent
|
<EventContent
|
||||||
messageLayout={messageLayout}
|
messageLayout={messageLayout}
|
||||||
|
|
@ -1471,7 +1516,12 @@ export function RoomTimeline({
|
||||||
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
||||||
|
|
||||||
const timeJSX = (
|
const timeJSX = (
|
||||||
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
|
<Time
|
||||||
|
ts={mEvent.getTs()}
|
||||||
|
compact={messageLayout === MessageLayout.Compact}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -1485,6 +1535,7 @@ export function RoomTimeline({
|
||||||
messageSpacing={messageSpacing}
|
messageSpacing={messageSpacing}
|
||||||
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
||||||
hideReadReceipts={hideActivity}
|
hideReadReceipts={hideActivity}
|
||||||
|
showDeveloperTools={showDeveloperTools}
|
||||||
>
|
>
|
||||||
<EventContent
|
<EventContent
|
||||||
messageLayout={messageLayout}
|
messageLayout={messageLayout}
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,8 @@ import {
|
||||||
getRoomNotificationModeIcon,
|
getRoomNotificationModeIcon,
|
||||||
useRoomsNotificationPreferencesContext,
|
useRoomsNotificationPreferencesContext,
|
||||||
} from '../../hooks/useRoomsNotificationPreferences';
|
} from '../../hooks/useRoomsNotificationPreferences';
|
||||||
|
import { JumpToTime } from './jump-to-time';
|
||||||
|
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||||
|
|
||||||
type RoomMenuProps = {
|
type RoomMenuProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
|
|
@ -79,6 +81,7 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
|
||||||
const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? ''));
|
const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? ''));
|
||||||
const notificationPreferences = useRoomsNotificationPreferencesContext();
|
const notificationPreferences = useRoomsNotificationPreferencesContext();
|
||||||
const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId);
|
const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId);
|
||||||
|
const { navigateRoom } = useRoomNavigate();
|
||||||
|
|
||||||
const handleMarkAsRead = () => {
|
const handleMarkAsRead = () => {
|
||||||
markAsRead(mx, room.roomId, hideActivity);
|
markAsRead(mx, room.roomId, hideActivity);
|
||||||
|
|
@ -105,8 +108,8 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
|
<Menu ref={ref} style={{ minWidth: toRem(200) }}>
|
||||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
<Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={handleMarkAsRead}
|
onClick={handleMarkAsRead}
|
||||||
size="300"
|
size="300"
|
||||||
|
|
@ -141,7 +144,7 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
|
||||||
</RoomNotificationModeSwitcher>
|
</RoomNotificationModeSwitcher>
|
||||||
</Box>
|
</Box>
|
||||||
<Line variant="Surface" size="300" />
|
<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
|
<MenuItem
|
||||||
onClick={handleInvite}
|
onClick={handleInvite}
|
||||||
variant="Primary"
|
variant="Primary"
|
||||||
|
|
@ -175,9 +178,36 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
|
||||||
Room Settings
|
Room Settings
|
||||||
</Text>
|
</Text>
|
||||||
</MenuItem>
|
</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>
|
</Box>
|
||||||
<Line variant="Surface" size="300" />
|
<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}>
|
<UseStateProvider initial={false}>
|
||||||
{(promptLeave, setPromptLeave) => (
|
{(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,
|
as,
|
||||||
color,
|
color,
|
||||||
config,
|
config,
|
||||||
|
toRem,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import React, {
|
import React, {
|
||||||
FormEventHandler,
|
FormEventHandler,
|
||||||
|
|
@ -94,10 +95,10 @@ export const MessageQuickReactions = as<'div', MessageQuickReactionsProps>(
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Box
|
<Box
|
||||||
style={{ padding: config.space.S200 }}
|
style={{ padding: config.space.S300 }}
|
||||||
alignItems="Center"
|
alignItems="Center"
|
||||||
justifyContent="Center"
|
justifyContent="Center"
|
||||||
gap="200"
|
gap="300"
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
>
|
>
|
||||||
|
|
@ -669,15 +670,21 @@ export type MessageProps = {
|
||||||
messageSpacing: MessageSpacing;
|
messageSpacing: MessageSpacing;
|
||||||
onUserClick: MouseEventHandler<HTMLButtonElement>;
|
onUserClick: MouseEventHandler<HTMLButtonElement>;
|
||||||
onUsernameClick: MouseEventHandler<HTMLButtonElement>;
|
onUsernameClick: MouseEventHandler<HTMLButtonElement>;
|
||||||
onReplyClick: MouseEventHandler<HTMLButtonElement>;
|
onReplyClick: (
|
||||||
|
ev: Parameters<MouseEventHandler<HTMLButtonElement>>[0],
|
||||||
|
startThread?: boolean
|
||||||
|
) => void;
|
||||||
onEditId?: (eventId?: string) => void;
|
onEditId?: (eventId?: string) => void;
|
||||||
onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void;
|
onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void;
|
||||||
reply?: ReactNode;
|
reply?: ReactNode;
|
||||||
reactions?: ReactNode;
|
reactions?: ReactNode;
|
||||||
hideReadReceipts?: boolean;
|
hideReadReceipts?: boolean;
|
||||||
|
showDeveloperTools?: boolean;
|
||||||
powerLevelTag?: PowerLevelTag;
|
powerLevelTag?: PowerLevelTag;
|
||||||
accessibleTagColors?: Map<string, string>;
|
accessibleTagColors?: Map<string, string>;
|
||||||
legacyUsernameColor?: boolean;
|
legacyUsernameColor?: boolean;
|
||||||
|
hour24Clock: boolean;
|
||||||
|
dateFormatString: string;
|
||||||
};
|
};
|
||||||
export const Message = as<'div', MessageProps>(
|
export const Message = as<'div', MessageProps>(
|
||||||
(
|
(
|
||||||
|
|
@ -703,9 +710,12 @@ export const Message = as<'div', MessageProps>(
|
||||||
reply,
|
reply,
|
||||||
reactions,
|
reactions,
|
||||||
hideReadReceipts,
|
hideReadReceipts,
|
||||||
|
showDeveloperTools,
|
||||||
powerLevelTag,
|
powerLevelTag,
|
||||||
accessibleTagColors,
|
accessibleTagColors,
|
||||||
legacyUsernameColor,
|
legacyUsernameColor,
|
||||||
|
hour24Clock,
|
||||||
|
dateFormatString,
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
|
|
@ -770,7 +780,12 @@ export const Message = as<'div', MessageProps>(
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Time ts={mEvent.getTs()} compact={messageLayout === MessageLayout.Compact} />
|
<Time
|
||||||
|
ts={mEvent.getTs()}
|
||||||
|
compact={messageLayout === MessageLayout.Compact}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
@ -857,6 +872,8 @@ export const Message = as<'div', MessageProps>(
|
||||||
}, 100);
|
}, 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isThreadedMessage = mEvent.threadRootId !== undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MessageBase
|
<MessageBase
|
||||||
className={classNames(css.MessageBase, className)}
|
className={classNames(css.MessageBase, className)}
|
||||||
|
|
@ -919,6 +936,17 @@ export const Message = as<'div', MessageProps>(
|
||||||
>
|
>
|
||||||
<Icon src={Icons.ReplyArrow} size="100" />
|
<Icon src={Icons.ReplyArrow} size="100" />
|
||||||
</IconButton>
|
</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 && (
|
{canEditEvent(mx, mEvent) && onEditId && (
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={() => onEditId(mEvent.getId())}
|
onClick={() => onEditId(mEvent.getId())}
|
||||||
|
|
@ -945,7 +973,7 @@ export const Message = as<'div', MessageProps>(
|
||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Menu>
|
<Menu style={{ minWidth: toRem(200) }}>
|
||||||
{canSendReaction && (
|
{canSendReaction && (
|
||||||
<MessageQuickReactions
|
<MessageQuickReactions
|
||||||
onReaction={(key, shortcode) => {
|
onReaction={(key, shortcode) => {
|
||||||
|
|
@ -998,6 +1026,27 @@ export const Message = as<'div', MessageProps>(
|
||||||
Reply
|
Reply
|
||||||
</Text>
|
</Text>
|
||||||
</MenuItem>
|
</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 && (
|
{canEditEvent(mx, mEvent) && onEditId && (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
size="300"
|
size="300"
|
||||||
|
|
@ -1026,7 +1075,13 @@ export const Message = as<'div', MessageProps>(
|
||||||
onClose={closeMenu}
|
onClose={closeMenu}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<MessageSourceCodeItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
{showDeveloperTools && (
|
||||||
|
<MessageSourceCodeItem
|
||||||
|
room={room}
|
||||||
|
mEvent={mEvent}
|
||||||
|
onClose={closeMenu}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
||||||
{canPinEvent && (
|
{canPinEvent && (
|
||||||
<MessagePinItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
<MessagePinItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
||||||
|
|
@ -1101,6 +1156,7 @@ export type EventProps = {
|
||||||
canDelete?: boolean;
|
canDelete?: boolean;
|
||||||
messageSpacing: MessageSpacing;
|
messageSpacing: MessageSpacing;
|
||||||
hideReadReceipts?: boolean;
|
hideReadReceipts?: boolean;
|
||||||
|
showDeveloperTools?: boolean;
|
||||||
};
|
};
|
||||||
export const Event = as<'div', EventProps>(
|
export const Event = as<'div', EventProps>(
|
||||||
(
|
(
|
||||||
|
|
@ -1112,7 +1168,9 @@ export const Event = as<'div', EventProps>(
|
||||||
canDelete,
|
canDelete,
|
||||||
messageSpacing,
|
messageSpacing,
|
||||||
hideReadReceipts,
|
hideReadReceipts,
|
||||||
|
showDeveloperTools,
|
||||||
children,
|
children,
|
||||||
|
style,
|
||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
|
|
@ -1179,7 +1237,7 @@ export const Event = as<'div', EventProps>(
|
||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Menu {...props} ref={ref}>
|
<Menu style={{ minWidth: toRem(200), ...style }} {...props} ref={ref}>
|
||||||
<Box direction="Column" gap="100" className={css.MessageMenuGroup}>
|
<Box direction="Column" gap="100" className={css.MessageMenuGroup}>
|
||||||
{!hideReadReceipts && (
|
{!hideReadReceipts && (
|
||||||
<MessageReadReceiptItem
|
<MessageReadReceiptItem
|
||||||
|
|
@ -1188,7 +1246,13 @@ export const Event = as<'div', EventProps>(
|
||||||
onClose={closeMenu}
|
onClose={closeMenu}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<MessageSourceCodeItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
{showDeveloperTools && (
|
||||||
|
<MessageSourceCodeItem
|
||||||
|
room={room}
|
||||||
|
mEvent={mEvent}
|
||||||
|
onClose={closeMenu}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
||||||
</Box>
|
</Box>
|
||||||
{((!mEvent.isRedacted() && canDelete && !stateEvent) ||
|
{((!mEvent.isRedacted() && canDelete && !stateEvent) ||
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ export const MessageQuickReaction = style({
|
||||||
});
|
});
|
||||||
|
|
||||||
export const MessageMenuGroup = style({
|
export const MessageMenuGroup = style({
|
||||||
padding: config.space.S100,
|
padding: config.space.S200,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const MessageMenuItemText = style({
|
export const MessageMenuItemText = style({
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,9 @@ function PinnedMessage({ room, eventId, renderContent, onOpen, canPinEvent }: Pi
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const accessibleTagColors = useAccessibleTagColors(theme.kind, powerLevelTags);
|
const accessibleTagColors = useAccessibleTagColors(theme.kind, powerLevelTags);
|
||||||
|
|
||||||
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
|
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||||
|
|
||||||
const [unpinState, unpin] = useAsyncCallback(
|
const [unpinState, unpin] = useAsyncCallback(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
const pinEvent = getStateEvent(room, StateEvent.RoomPinnedEvents);
|
const pinEvent = getStateEvent(room, StateEvent.RoomPinnedEvents);
|
||||||
|
|
@ -205,7 +208,11 @@ function PinnedMessage({ room, eventId, renderContent, onOpen, canPinEvent }: Pi
|
||||||
</Username>
|
</Username>
|
||||||
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
|
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
|
||||||
</Box>
|
</Box>
|
||||||
<Time ts={pinnedEvent.getTs()} />
|
<Time
|
||||||
|
ts={pinnedEvent.getTs()}
|
||||||
|
hour24Clock={hour24Clock}
|
||||||
|
dateFormatString={dateFormatString}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
{renderOptions()}
|
{renderOptions()}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ const useSettingsMenuItems = (): SettingsMenuItem[] =>
|
||||||
{
|
{
|
||||||
page: SettingsPages.DevicesPage,
|
page: SettingsPages.DevicesPage,
|
||||||
name: 'Devices',
|
name: 'Devices',
|
||||||
icon: Icons.Category,
|
icon: Icons.Monitor,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
page: SettingsPages.EmojisStickersPage,
|
page: SettingsPages.EmojisStickersPage,
|
||||||
|
|
|
||||||
|
|
@ -1,396 +1,10 @@
|
||||||
import React, {
|
import React from 'react';
|
||||||
ChangeEventHandler,
|
import { Box, Text, IconButton, Icon, Icons, Scroll } from 'folds';
|
||||||
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 { Page, PageContent, PageHeader } from '../../../components/page';
|
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||||
import { SequenceCard } from '../../../components/sequence-card';
|
import { MatrixId } from './MatrixId';
|
||||||
import { SequenceCardStyle } from '../styles.css';
|
import { Profile } from './Profile';
|
||||||
import { SettingTile } from '../../../components/setting-tile';
|
import { ContactInformation } from './ContactInfo';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { IgnoredUserList } from './IgnoredUserList';
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type AccountProps = {
|
type AccountProps = {
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
|
|
@ -419,6 +33,7 @@ export function Account({ requestClose }: AccountProps) {
|
||||||
<Profile />
|
<Profile />
|
||||||
<MatrixId />
|
<MatrixId />
|
||||||
<ContactInformation />
|
<ContactInformation />
|
||||||
|
<IgnoredUserList />
|
||||||
</Box>
|
</Box>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
</Scroll>
|
</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 { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
import { isUserId } from '../../../utils/matrix';
|
import { isUserId } from '../../../utils/matrix';
|
||||||
import { useIgnoredUsers } from '../../../hooks/useIgnoredUsers';
|
import { useIgnoredUsers } from '../../../hooks/useIgnoredUsers';
|
||||||
|
import { useAlive } from '../../../hooks/useAlive';
|
||||||
|
|
||||||
function IgnoreUserInput({ userList }: { userList: string[] }) {
|
function IgnoreUserInput({ userList }: { userList: string[] }) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const [userId, setUserId] = useState<string>('');
|
const [userId, setUserId] = useState<string>('');
|
||||||
|
const alive = useAlive();
|
||||||
|
|
||||||
const [ignoreState, ignore] = useAsyncCallback(
|
const [ignoreState, ignore] = useAsyncCallback(
|
||||||
useCallback(
|
useCallback(
|
||||||
async (uId: string) => {
|
async (uId: string) => {
|
||||||
mx.setIgnoredUsers([...userList, uId]);
|
await mx.setIgnoredUsers([...userList, uId]);
|
||||||
setUserId('');
|
|
||||||
},
|
},
|
||||||
[mx, userList]
|
[mx, userList]
|
||||||
)
|
)
|
||||||
|
|
@ -43,7 +44,11 @@ function IgnoreUserInput({ userList }: { userList: string[] }) {
|
||||||
|
|
||||||
if (!isUserId(uId)) return;
|
if (!isUserId(uId)) return;
|
||||||
|
|
||||||
ignore(uId);
|
ignore(uId).then(() => {
|
||||||
|
if (alive()) {
|
||||||
|
setUserId('');
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -129,7 +134,7 @@ export function IgnoredUserList() {
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
||||||
<Text size="L400">Block Messages</Text>
|
<Text size="L400">Blocked Users</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<SequenceCard
|
<SequenceCard
|
||||||
className={SequenceCardStyle}
|
className={SequenceCardStyle}
|
||||||
|
|
@ -139,13 +144,13 @@ export function IgnoredUserList() {
|
||||||
>
|
>
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Select User"
|
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">
|
<Box direction="Column" gap="300">
|
||||||
<IgnoreUserInput userList={ignoredUsers} />
|
<IgnoreUserInput userList={ignoredUsers} />
|
||||||
{ignoredUsers.length > 0 && (
|
{ignoredUsers.length > 0 && (
|
||||||
<Box direction="Inherit" gap="100">
|
<Box direction="Inherit" gap="100">
|
||||||
<Text size="L400">Blocklist</Text>
|
<Text size="L400">Users</Text>
|
||||||
<Box wrap="Wrap" gap="200">
|
<Box wrap="Wrap" gap="200">
|
||||||
{ignoredUsers.map((userId) => (
|
{ignoredUsers.map((userId) => (
|
||||||
<IgnoredUserChip key={userId} userId={userId} userList={ignoredUsers} />
|
<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 { SequenceCardStyle } from '../styles.css';
|
||||||
import { LogoutDialog } from '../../../components/LogoutDialog';
|
import { LogoutDialog } from '../../../components/LogoutDialog';
|
||||||
import { stopPropagation } from '../../../utils/keyboard';
|
import { stopPropagation } from '../../../utils/keyboard';
|
||||||
|
import { useSetting } from '../../../state/hooks/settings';
|
||||||
|
import { settingsAtom } from '../../../state/settings';
|
||||||
|
|
||||||
export function DeviceTilePlaceholder() {
|
export function DeviceTilePlaceholder() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -41,6 +43,9 @@ export function DeviceTilePlaceholder() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function DeviceActiveTime({ ts }: { ts: number }) {
|
function DeviceActiveTime({ ts }: { ts: number }) {
|
||||||
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
|
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Text className={BreakWord} size="T200">
|
<Text className={BreakWord} size="T200">
|
||||||
<Text size="Inherit" as="span" priority="300">
|
<Text size="Inherit" as="span" priority="300">
|
||||||
|
|
@ -49,7 +54,8 @@ function DeviceActiveTime({ ts }: { ts: number }) {
|
||||||
<>
|
<>
|
||||||
{today(ts) && 'Today'}
|
{today(ts) && 'Today'}
|
||||||
{yesterday(ts) && 'Yesterday'}
|
{yesterday(ts) && 'Yesterday'}
|
||||||
{!today(ts) && !yesterday(ts) && timeDayMonYear(ts)} {timeHourMinute(ts)}
|
{!today(ts) && !yesterday(ts) && timeDayMonYear(ts, dateFormatString)}{' '}
|
||||||
|
{timeHourMinute(ts, hour24Clock)}
|
||||||
</>
|
</>
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,10 @@ import { useUIAMatrixError } from '../../../hooks/useUIAFlows';
|
||||||
import { DeviceVerificationStatus } from '../../../components/DeviceVerificationStatus';
|
import { DeviceVerificationStatus } from '../../../components/DeviceVerificationStatus';
|
||||||
import { VerifyOtherDeviceTile } from './Verification';
|
import { VerifyOtherDeviceTile } from './Verification';
|
||||||
import { VerificationStatus } from '../../../hooks/useDeviceVerificationStatus';
|
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 = {
|
type OtherDevicesProps = {
|
||||||
devices: IMyDevice[];
|
devices: IMyDevice[];
|
||||||
|
|
@ -20,8 +24,39 @@ type OtherDevicesProps = {
|
||||||
export function OtherDevices({ devices, refreshDeviceList, showVerification }: OtherDevicesProps) {
|
export function OtherDevices({ devices, refreshDeviceList, showVerification }: OtherDevicesProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const crypto = mx.getCrypto();
|
const crypto = mx.getCrypto();
|
||||||
|
const authMetadata = useAuthMetadata();
|
||||||
|
const accountManagementActions = useAccountManagementActions();
|
||||||
|
|
||||||
const [deleted, setDeleted] = useState<Set<string>>(new Set());
|
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) => {
|
const handleToggleDelete = useCallback((deviceId: string) => {
|
||||||
setDeleted((deviceIds) => {
|
setDeleted((deviceIds) => {
|
||||||
const newIds = new Set(deviceIds);
|
const newIds = new Set(deviceIds);
|
||||||
|
|
@ -70,6 +105,31 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O
|
||||||
<>
|
<>
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">Others</Text>
|
<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
|
{devices
|
||||||
.sort((d1, d2) => {
|
.sort((d1, d2) => {
|
||||||
if (!d1.last_seen_ts || !d2.last_seen_ts) return 0;
|
if (!d1.last_seen_ts || !d2.last_seen_ts) return 0;
|
||||||
|
|
@ -89,12 +149,20 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O
|
||||||
refreshDeviceList={refreshDeviceList}
|
refreshDeviceList={refreshDeviceList}
|
||||||
disabled={deleting}
|
disabled={deleting}
|
||||||
options={
|
options={
|
||||||
|
authMetadata ? (
|
||||||
|
<DeviceDeleteBtn
|
||||||
|
deviceId={device.device_id}
|
||||||
|
deleted={false}
|
||||||
|
onDeleteToggle={handleDeleteOIDC}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<DeviceDeleteBtn
|
<DeviceDeleteBtn
|
||||||
deviceId={device.device_id}
|
deviceId={device.device_id}
|
||||||
deleted={deleted.has(device.device_id)}
|
deleted={deleted.has(device.device_id)}
|
||||||
onDeleteToggle={handleToggleDelete}
|
onDeleteToggle={handleToggleDelete}
|
||||||
disabled={deleting}
|
disabled={deleting}
|
||||||
/>
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{showVerification && crypto && (
|
{showVerification && crypto && (
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,9 @@ import {
|
||||||
DeviceVerificationSetup,
|
DeviceVerificationSetup,
|
||||||
} from '../../../components/DeviceVerificationSetup';
|
} from '../../../components/DeviceVerificationSetup';
|
||||||
import { stopPropagation } from '../../../utils/keyboard';
|
import { stopPropagation } from '../../../utils/keyboard';
|
||||||
|
import { useAuthMetadata } from '../../../hooks/useAuthMetadata';
|
||||||
|
import { withSearchParam } from '../../../pages/pathUtils';
|
||||||
|
import { useAccountManagementActions } from '../../../hooks/useAccountManagement';
|
||||||
|
|
||||||
type VerificationStatusBadgeProps = {
|
type VerificationStatusBadgeProps = {
|
||||||
verificationStatus: VerificationStatus;
|
verificationStatus: VerificationStatus;
|
||||||
|
|
@ -252,6 +255,8 @@ export function EnableVerification({ visible }: EnableVerificationProps) {
|
||||||
|
|
||||||
export function DeviceVerificationOptions() {
|
export function DeviceVerificationOptions() {
|
||||||
const [menuCords, setMenuCords] = useState<RectCords>();
|
const [menuCords, setMenuCords] = useState<RectCords>();
|
||||||
|
const authMetadata = useAuthMetadata();
|
||||||
|
const accountManagementActions = useAccountManagementActions();
|
||||||
|
|
||||||
const [reset, setReset] = useState(false);
|
const [reset, setReset] = useState(false);
|
||||||
|
|
||||||
|
|
@ -265,6 +270,18 @@ export function DeviceVerificationOptions() {
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
setMenuCords(undefined);
|
setMenuCords(undefined);
|
||||||
|
|
||||||
|
if (authMetadata) {
|
||||||
|
const authUrl = authMetadata.account_management_uri ?? authMetadata.issuer;
|
||||||
|
window.open(
|
||||||
|
withSearchParam(authUrl, {
|
||||||
|
action: accountManagementActions.crossSigningReset,
|
||||||
|
}),
|
||||||
|
'_blank'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setReset(true);
|
setReset(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -298,7 +315,7 @@ export function DeviceVerificationOptions() {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Menu>
|
<Menu>
|
||||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
<Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
variant="Critical"
|
variant="Critical"
|
||||||
onClick={handleReset}
|
onClick={handleReset}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,19 @@
|
||||||
import React, {
|
import React, {
|
||||||
ChangeEventHandler,
|
ChangeEventHandler,
|
||||||
|
FormEventHandler,
|
||||||
KeyboardEventHandler,
|
KeyboardEventHandler,
|
||||||
MouseEventHandler,
|
MouseEventHandler,
|
||||||
|
useEffect,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
import {
|
import {
|
||||||
as,
|
as,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Chip,
|
Chip,
|
||||||
config,
|
config,
|
||||||
|
Header,
|
||||||
Icon,
|
Icon,
|
||||||
IconButton,
|
IconButton,
|
||||||
Icons,
|
Icons,
|
||||||
|
|
@ -28,7 +32,7 @@ import FocusTrap from 'focus-trap-react';
|
||||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||||
import { SequenceCard } from '../../../components/sequence-card';
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
import { useSetting } from '../../../state/hooks/settings';
|
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 { SettingTile } from '../../../components/setting-tile';
|
||||||
import { KeySymbol } from '../../../utils/key-symbol';
|
import { KeySymbol } from '../../../utils/key-symbol';
|
||||||
import { isMacOS } from '../../../utils/user-agent';
|
import { isMacOS } from '../../../utils/user-agent';
|
||||||
|
|
@ -44,6 +48,7 @@ import {
|
||||||
import { stopPropagation } from '../../../utils/keyboard';
|
import { stopPropagation } from '../../../utils/keyboard';
|
||||||
import { useMessageLayoutItems } from '../../../hooks/useMessageLayout';
|
import { useMessageLayoutItems } from '../../../hooks/useMessageLayout';
|
||||||
import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing';
|
import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing';
|
||||||
|
import { useDateFormatItems } from '../../../hooks/useDateFormat';
|
||||||
import { SequenceCardStyle } from '../styles.css';
|
import { SequenceCardStyle } from '../styles.css';
|
||||||
|
|
||||||
type ThemeSelectorProps = {
|
type ThemeSelectorProps = {
|
||||||
|
|
@ -55,7 +60,7 @@ type ThemeSelectorProps = {
|
||||||
const ThemeSelector = as<'div', ThemeSelectorProps>(
|
const ThemeSelector = as<'div', ThemeSelectorProps>(
|
||||||
({ themeNames, themes, selected, onSelect, ...props }, ref) => (
|
({ themeNames, themes, selected, onSelect, ...props }, ref) => (
|
||||||
<Menu {...props} ref={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) => (
|
{themes.map((theme) => (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
key={theme.id}
|
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() {
|
function Editor() {
|
||||||
const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
||||||
const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
||||||
|
|
@ -423,7 +781,7 @@ function SelectMessageLayout() {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Menu>
|
<Menu>
|
||||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
<Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
|
||||||
{messageLayoutItems.map((item) => (
|
{messageLayoutItems.map((item) => (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
key={item.layout}
|
key={item.layout}
|
||||||
|
|
@ -492,7 +850,7 @@ function SelectMessageSpacing() {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Menu>
|
<Menu>
|
||||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
<Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
|
||||||
{messageSpacingItems.map((item) => (
|
{messageSpacingItems.map((item) => (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
key={item.spacing}
|
key={item.spacing}
|
||||||
|
|
@ -637,6 +995,7 @@ export function General({ requestClose }: GeneralProps) {
|
||||||
<PageContent>
|
<PageContent>
|
||||||
<Box direction="Column" gap="700">
|
<Box direction="Column" gap="700">
|
||||||
<Appearance />
|
<Appearance />
|
||||||
|
<DateAndTime />
|
||||||
<Editor />
|
<Editor />
|
||||||
<Messages />
|
<Messages />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@ export function NotificationModeSwitcher({ pushRule, onChange }: NotificationMod
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Menu>
|
<Menu>
|
||||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
<Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
|
||||||
{modes.map((mode) => (
|
{modes.map((mode) => (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
key={mode}
|
key={mode}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,9 @@ import { SystemNotification } from './SystemNotification';
|
||||||
import { AllMessagesNotifications } from './AllMessages';
|
import { AllMessagesNotifications } from './AllMessages';
|
||||||
import { SpecialMessagesNotifications } from './SpecialMessages';
|
import { SpecialMessagesNotifications } from './SpecialMessages';
|
||||||
import { KeywordMessagesNotifications } from './KeywordMessages';
|
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 = {
|
type NotificationsProps = {
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
|
|
@ -35,7 +37,19 @@ export function Notifications({ requestClose }: NotificationsProps) {
|
||||||
<AllMessagesNotifications />
|
<AllMessagesNotifications />
|
||||||
<SpecialMessagesNotifications />
|
<SpecialMessagesNotifications />
|
||||||
<KeywordMessagesNotifications />
|
<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>
|
</Box>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
</Scroll>
|
</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 { 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 { hasDevices } from '../../util/matrixUtil';
|
||||||
import * as roomActions from '../../client/action/room';
|
import * as roomActions from '../../client/action/room';
|
||||||
import { useRoomNavigate } from './useRoomNavigate';
|
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 SHRUG = '¯\\_(ツ)_/¯';
|
||||||
export const TABLEFLIP = '(╯°□°)╯︵ ┻━┻';
|
export const TABLEFLIP = '(╯°□°)╯︵ ┻━┻';
|
||||||
export const UNFLIP = '┬─┬ノ( º_ºノ)';
|
export const UNFLIP = '┬─┬ノ( º_ºノ)';
|
||||||
|
|
||||||
export function parseUsersAndReason(payload: string): {
|
const FLAG_PAT = '(?:^|\\s)-(\\w+)\\b';
|
||||||
users: string[];
|
const FLAG_REG = new RegExp(FLAG_PAT);
|
||||||
reason?: string;
|
const FLAG_REG_G = new RegExp(FLAG_PAT, 'g');
|
||||||
} {
|
|
||||||
let reason: string | undefined;
|
|
||||||
let ids: string = payload;
|
|
||||||
|
|
||||||
const reasonMatch = payload.match(/\s-r\s/);
|
export const splitPayloadContentAndFlags = (payload: string): [string, string | undefined] => {
|
||||||
if (reasonMatch) {
|
const flagMatch = payload.match(FLAG_REG);
|
||||||
ids = payload.slice(0, reasonMatch.index);
|
|
||||||
reason = payload.slice((reasonMatch.index ?? 0) + reasonMatch[0].length);
|
if (!flagMatch) {
|
||||||
if (reason.trim() === '') reason = undefined;
|
return [payload, undefined];
|
||||||
}
|
}
|
||||||
const rawIds = ids.split(' ');
|
const content = payload.slice(0, flagMatch.index);
|
||||||
const users = rawIds.filter((id) => isUserId(id));
|
const flags = payload.slice(flagMatch.index);
|
||||||
return {
|
|
||||||
users,
|
return [content, flags];
|
||||||
reason,
|
};
|
||||||
};
|
|
||||||
}
|
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>;
|
export type CommandExe = (payload: string) => Promise<void>;
|
||||||
|
|
||||||
|
|
@ -52,6 +145,8 @@ export enum Command {
|
||||||
ConvertToRoom = 'converttoroom',
|
ConvertToRoom = 'converttoroom',
|
||||||
TableFlip = 'tableflip',
|
TableFlip = 'tableflip',
|
||||||
UnFlip = 'unflip',
|
UnFlip = 'unflip',
|
||||||
|
Delete = 'delete',
|
||||||
|
Acl = 'acl',
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CommandContent = {
|
export type CommandContent = {
|
||||||
|
|
@ -96,7 +191,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
|
||||||
name: Command.StartDm,
|
name: Command.StartDm,
|
||||||
description: 'Start direct message with user. Example: /startdm userId1',
|
description: 'Start direct message with user. Example: /startdm userId1',
|
||||||
exe: async (payload) => {
|
exe: async (payload) => {
|
||||||
const rawIds = payload.split(' ');
|
const rawIds = splitWithSpace(payload);
|
||||||
const userIds = rawIds.filter((id) => isUserId(id) && id !== mx.getUserId());
|
const userIds = rawIds.filter((id) => isUserId(id) && id !== mx.getUserId());
|
||||||
if (userIds.length === 0) return;
|
if (userIds.length === 0) return;
|
||||||
if (userIds.length === 1) {
|
if (userIds.length === 1) {
|
||||||
|
|
@ -106,7 +201,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
|
||||||
return;
|
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 isEncrypt = devices.every((hasDevice) => hasDevice);
|
||||||
const result = await roomActions.createDM(mx, userIds, isEncrypt);
|
const result = await roomActions.createDM(mx, userIds, isEncrypt);
|
||||||
navigateRoom(result.room_id);
|
navigateRoom(result.room_id);
|
||||||
|
|
@ -116,7 +211,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
|
||||||
name: Command.Join,
|
name: Command.Join,
|
||||||
description: 'Join room with address. Example: /join address1 address2',
|
description: 'Join room with address. Example: /join address1 address2',
|
||||||
exe: async (payload) => {
|
exe: async (payload) => {
|
||||||
const rawIds = payload.split(' ');
|
const rawIds = splitWithSpace(payload);
|
||||||
const roomIds = rawIds.filter(
|
const roomIds = rawIds.filter(
|
||||||
(idOrAlias) => isRoomId(idOrAlias) || isRoomAlias(idOrAlias)
|
(idOrAlias) => isRoomId(idOrAlias) || isRoomAlias(idOrAlias)
|
||||||
);
|
);
|
||||||
|
|
@ -131,7 +226,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
|
||||||
mx.leave(room.roomId);
|
mx.leave(room.roomId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const rawIds = payload.split(' ');
|
const rawIds = splitWithSpace(payload);
|
||||||
const roomIds = rawIds.filter((id) => isRoomId(id));
|
const roomIds = rawIds.filter((id) => isRoomId(id));
|
||||||
roomIds.map((id) => mx.leave(id));
|
roomIds.map((id) => mx.leave(id));
|
||||||
},
|
},
|
||||||
|
|
@ -140,7 +235,10 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
|
||||||
name: Command.Invite,
|
name: Command.Invite,
|
||||||
description: 'Invite user to room. Example: /invite userId1 userId2 [-r reason]',
|
description: 'Invite user to room. Example: /invite userId1 userId2 [-r reason]',
|
||||||
exe: async (payload) => {
|
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));
|
users.map((id) => mx.invite(room.roomId, id, reason));
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -148,31 +246,64 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
|
||||||
name: Command.DisInvite,
|
name: Command.DisInvite,
|
||||||
description: 'Disinvite user to room. Example: /disinvite userId1 userId2 [-r reason]',
|
description: 'Disinvite user to room. Example: /disinvite userId1 userId2 [-r reason]',
|
||||||
exe: async (payload) => {
|
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));
|
users.map((id) => mx.kick(room.roomId, id, reason));
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
[Command.Kick]: {
|
[Command.Kick]: {
|
||||||
name: 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) => {
|
exe: async (payload) => {
|
||||||
const { users, reason } = parseUsersAndReason(payload);
|
const [content, flags] = splitPayloadContentAndFlags(payload);
|
||||||
users.map((id) => mx.kick(room.roomId, id, reason));
|
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]: {
|
[Command.Ban]: {
|
||||||
name: 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) => {
|
exe: async (payload) => {
|
||||||
const { users, reason } = parseUsersAndReason(payload);
|
const [content, flags] = splitPayloadContentAndFlags(payload);
|
||||||
users.map((id) => mx.ban(room.roomId, id, reason));
|
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]: {
|
[Command.UnBan]: {
|
||||||
name: Command.UnBan,
|
name: Command.UnBan,
|
||||||
description: 'Unban user from room. Example: /unban userId1 userId2',
|
description: 'Unban user from room. Example: /unban userId1 userId2',
|
||||||
exe: async (payload) => {
|
exe: async (payload) => {
|
||||||
const rawIds = payload.split(' ');
|
const rawIds = splitWithSpace(payload);
|
||||||
const users = rawIds.filter((id) => isUserId(id));
|
const users = rawIds.filter((id) => isUserId(id));
|
||||||
users.map((id) => mx.unban(room.roomId, id));
|
users.map((id) => mx.unban(room.roomId, id));
|
||||||
},
|
},
|
||||||
|
|
@ -181,7 +312,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
|
||||||
name: Command.Ignore,
|
name: Command.Ignore,
|
||||||
description: 'Ignore user. Example: /ignore userId1 userId2',
|
description: 'Ignore user. Example: /ignore userId1 userId2',
|
||||||
exe: async (payload) => {
|
exe: async (payload) => {
|
||||||
const rawIds = payload.split(' ');
|
const rawIds = splitWithSpace(payload);
|
||||||
const userIds = rawIds.filter((id) => isUserId(id));
|
const userIds = rawIds.filter((id) => isUserId(id));
|
||||||
if (userIds.length > 0) roomActions.ignore(mx, userIds);
|
if (userIds.length > 0) roomActions.ignore(mx, userIds);
|
||||||
},
|
},
|
||||||
|
|
@ -190,7 +321,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
|
||||||
name: Command.UnIgnore,
|
name: Command.UnIgnore,
|
||||||
description: 'Unignore user. Example: /unignore userId1 userId2',
|
description: 'Unignore user. Example: /unignore userId1 userId2',
|
||||||
exe: async (payload) => {
|
exe: async (payload) => {
|
||||||
const rawIds = payload.split(' ');
|
const rawIds = splitWithSpace(payload);
|
||||||
const userIds = rawIds.filter((id) => isUserId(id));
|
const userIds = rawIds.filter((id) => isUserId(id));
|
||||||
if (userIds.length > 0) roomActions.unignore(mx, userIds);
|
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);
|
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]
|
[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 { useEffect, useCallback, useMemo } from 'react';
|
||||||
import { IMyDevice } from 'matrix-js-sdk';
|
import { IMyDevice } from 'matrix-js-sdk';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
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';
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
|
|
||||||
export const useDeviceListChange = (
|
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,
|
getSpaceRoomPath,
|
||||||
} from '../pages/pathUtils';
|
} from '../pages/pathUtils';
|
||||||
import { useMatrixClient } from './useMatrixClient';
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
import { getOrphanParents } from '../utils/room';
|
import { getOrphanParents, guessPerfectParent } from '../utils/room';
|
||||||
import { roomToParentsAtom } from '../state/room/roomToParents';
|
import { roomToParentsAtom } from '../state/room/roomToParents';
|
||||||
import { mDirectAtom } from '../state/mDirectList';
|
import { mDirectAtom } from '../state/mDirectList';
|
||||||
import { useSelectedSpace } from './router/useSelectedSpace';
|
import { useSelectedSpace } from './router/useSelectedSpace';
|
||||||
|
import { settingsAtom } from '../state/settings';
|
||||||
|
import { useSetting } from '../state/hooks/settings';
|
||||||
|
|
||||||
export const useRoomNavigate = () => {
|
export const useRoomNavigate = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
@ -20,6 +22,7 @@ export const useRoomNavigate = () => {
|
||||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||||
const mDirects = useAtomValue(mDirectAtom);
|
const mDirects = useAtomValue(mDirectAtom);
|
||||||
const spaceSelectedId = useSelectedSpace();
|
const spaceSelectedId = useSelectedSpace();
|
||||||
|
const [developerTools] = useSetting(settingsAtom, 'developerTools');
|
||||||
|
|
||||||
const navigateSpace = useCallback(
|
const navigateSpace = useCallback(
|
||||||
(roomId: string) => {
|
(roomId: string) => {
|
||||||
|
|
@ -32,16 +35,23 @@ export const useRoomNavigate = () => {
|
||||||
const navigateRoom = useCallback(
|
const navigateRoom = useCallback(
|
||||||
(roomId: string, eventId?: string, opts?: NavigateOptions) => {
|
(roomId: string, eventId?: string, opts?: NavigateOptions) => {
|
||||||
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId);
|
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) {
|
if (orphanParents.length > 0) {
|
||||||
const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(
|
let parentSpace: string;
|
||||||
mx,
|
if (spaceSelectedId && orphanParents.includes(spaceSelectedId)) {
|
||||||
spaceSelectedId && orphanParents.includes(spaceSelectedId)
|
parentSpace = spaceSelectedId;
|
||||||
? spaceSelectedId
|
} else {
|
||||||
: orphanParents[0]
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -52,7 +62,7 @@ export const useRoomNavigate = () => {
|
||||||
|
|
||||||
navigate(getHomeRoomPath(roomIdOrAlias, eventId), opts);
|
navigate(getHomeRoomPath(roomIdOrAlias, eventId), opts);
|
||||||
},
|
},
|
||||||
[mx, navigate, spaceSelectedId, roomToParents, mDirects]
|
[mx, navigate, spaceSelectedId, roomToParents, mDirects, developerTools]
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
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 cons from '../../../client/state/cons';
|
||||||
import navigation from '../../../client/state/navigation';
|
import navigation from '../../../client/state/navigation';
|
||||||
import { joinRuleToIconSrc, getIdServer } from '../../../util/matrixUtil';
|
import { joinRuleToIconSrc } from '../../../util/matrixUtil';
|
||||||
import { Debounce } from '../../../util/common';
|
import { Debounce } from '../../../util/common';
|
||||||
|
|
||||||
import Text from '../../atoms/text/Text';
|
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 CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||||
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
|
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
|
||||||
|
|
||||||
import { useStore } from '../../hooks/useStore';
|
|
||||||
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
||||||
import { useDirects, useRooms, useSpaces } from '../../state/hooks/roomList';
|
import { useDirects, useRooms, useSpaces } from '../../state/hooks/roomList';
|
||||||
import { allRoomsAtom } from '../../state/room-list/roomList';
|
import { allRoomsAtom } from '../../state/room-list/roomList';
|
||||||
import { mDirectAtom } from '../../state/mDirectList';
|
import { mDirectAtom } from '../../state/mDirectList';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { getViaServers } from '../../plugins/via-servers';
|
import { getViaServers } from '../../plugins/via-servers';
|
||||||
|
import { rateLimitedActions } from '../../utils/matrix';
|
||||||
|
import { useAlive } from '../../hooks/useAlive';
|
||||||
|
|
||||||
function SpaceAddExistingContent({ roomId, spaces: onlySpaces }) {
|
function SpaceAddExistingContent({ roomId, spaces: onlySpaces }) {
|
||||||
const mountStore = useStore(roomId);
|
const alive = useAlive();
|
||||||
const [debounce] = useState(new Debounce());
|
const [debounce] = useState(new Debounce());
|
||||||
const [process, setProcess] = useState(null);
|
const [process, setProcess] = useState(null);
|
||||||
const [allRoomIds, setAllRoomIds] = useState([]);
|
const [allRoomIds, setAllRoomIds] = useState([]);
|
||||||
|
|
@ -68,14 +69,11 @@ function SpaceAddExistingContent({ roomId, spaces: onlySpaces }) {
|
||||||
const handleAdd = async () => {
|
const handleAdd = async () => {
|
||||||
setProcess(`Adding ${selected.length} items...`);
|
setProcess(`Adding ${selected.length} items...`);
|
||||||
|
|
||||||
const promises = selected.map((rId) => {
|
await rateLimitedActions(selected, async (rId) => {
|
||||||
const room = mx.getRoom(rId);
|
const room = mx.getRoom(rId);
|
||||||
const via = getViaServers(room);
|
const via = getViaServers(room);
|
||||||
if (via.length === 0) {
|
|
||||||
via.push(getIdServer(rId));
|
|
||||||
}
|
|
||||||
|
|
||||||
return mx.sendStateEvent(
|
await mx.sendStateEvent(
|
||||||
roomId,
|
roomId,
|
||||||
'm.space.child',
|
'm.space.child',
|
||||||
{
|
{
|
||||||
|
|
@ -87,9 +85,7 @@ function SpaceAddExistingContent({ roomId, spaces: onlySpaces }) {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
mountStore.setItem(true);
|
if (!alive()) return;
|
||||||
await Promise.allSettled(promises);
|
|
||||||
if (mountStore.getItem() !== true) return;
|
|
||||||
|
|
||||||
const roomIds = onlySpaces ? [...spaces] : [...rooms, ...directs];
|
const roomIds = onlySpaces ? [...spaces] : [...rooms, ...directs];
|
||||||
const allIds = roomIds.filter(
|
const allIds = roomIds.filter(
|
||||||
|
|
|
||||||
|
|
@ -273,7 +273,7 @@ function InviteUser({ isOpen, roomId, searchTerm, onRequestClose }) {
|
||||||
searchUser(usernameRef.current.value);
|
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">
|
<Button disabled={isSearching} iconSrc={UserIC} variant="primary" type="submit">
|
||||||
Search
|
Search
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@ function JoinAliasContent({ term, requestClose }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="join-alias" onSubmit={handleSubmit}>
|
<form className="join-alias" onSubmit={handleSubmit}>
|
||||||
<Input label="Address" value={term} name="alias" required />
|
<Input label="Address" value={term} name="alias" required autoFocus />
|
||||||
{error && (
|
{error && (
|
||||||
<Text className="join-alias__error" variant="b3">
|
<Text className="join-alias__error" variant="b3">
|
||||||
{error}
|
{error}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ export function AuthFooter() {
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
>
|
>
|
||||||
v4.5.1
|
v4.8.1
|
||||||
</Text>
|
</Text>
|
||||||
<Text as="a" size="T300" href="https://twitter.com/cinnyapp" target="_blank" rel="noreferrer">
|
<Text as="a" size="T300" href="https://twitter.com/cinnyapp" target="_blank" rel="noreferrer">
|
||||||
Twitter
|
Twitter
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,21 @@
|
||||||
import { Avatar, AvatarImage, Box, Button, Text } from 'folds';
|
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 React, { useMemo } from 'react';
|
||||||
import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo';
|
import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo';
|
||||||
|
|
||||||
type SSOLoginProps = {
|
type SSOLoginProps = {
|
||||||
providers?: IIdentityProvider[];
|
providers?: IIdentityProvider[];
|
||||||
redirectUrl: string;
|
redirectUrl: string;
|
||||||
|
action?: SSOAction;
|
||||||
saveScreenSpace?: boolean;
|
saveScreenSpace?: boolean;
|
||||||
};
|
};
|
||||||
export function SSOLogin({ providers, redirectUrl, saveScreenSpace }: SSOLoginProps) {
|
export function SSOLogin({ providers, redirectUrl, action, saveScreenSpace }: SSOLoginProps) {
|
||||||
const discovery = useAutoDiscoveryInfo();
|
const discovery = useAutoDiscoveryInfo();
|
||||||
const baseUrl = discovery['m.homeserver'].base_url;
|
const baseUrl = discovery['m.homeserver'].base_url;
|
||||||
const mx = useMemo(() => createClient({ baseUrl }), [baseUrl]);
|
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
|
const withoutIcon = providers
|
||||||
? providers.find(
|
? providers.find(
|
||||||
|
|
|
||||||
|
|
@ -108,10 +108,10 @@ export function ServerPicker({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Menu>
|
<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>
|
<Text size="L400">Homeserver List</Text>
|
||||||
</Header>
|
</Header>
|
||||||
<div style={{ padding: config.space.S100, paddingTop: 0 }}>
|
<div style={{ padding: config.space.S200, paddingTop: 0 }}>
|
||||||
{serverList?.map((serverName) => (
|
{serverList?.map((serverName) => (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
key={serverName}
|
key={serverName}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { Box, Text, color } from 'folds';
|
import { Box, Text, color } from 'folds';
|
||||||
import { Link, useSearchParams } from 'react-router-dom';
|
import { Link, useSearchParams } from 'react-router-dom';
|
||||||
|
import { SSOAction } from 'matrix-js-sdk';
|
||||||
import { useAuthFlows } from '../../../hooks/useAuthFlows';
|
import { useAuthFlows } from '../../../hooks/useAuthFlows';
|
||||||
import { useAuthServer } from '../../../hooks/useAuthServer';
|
import { useAuthServer } from '../../../hooks/useAuthServer';
|
||||||
import { useParsedLoginFlows } from '../../../hooks/useParsedLoginFlows';
|
import { useParsedLoginFlows } from '../../../hooks/useParsedLoginFlows';
|
||||||
|
|
@ -76,6 +77,7 @@ export function Login() {
|
||||||
<SSOLogin
|
<SSOLogin
|
||||||
providers={parsedFlows.sso.identity_providers}
|
providers={parsedFlows.sso.identity_providers}
|
||||||
redirectUrl={ssoRedirectUrl}
|
redirectUrl={ssoRedirectUrl}
|
||||||
|
action={SSOAction.LOGIN}
|
||||||
saveScreenSpace={parsedFlows.password !== undefined}
|
saveScreenSpace={parsedFlows.password !== undefined}
|
||||||
/>
|
/>
|
||||||
<span data-spacing-node />
|
<span data-spacing-node />
|
||||||
|
|
|
||||||
|
|
@ -59,11 +59,11 @@ function UsernameHint({ server }: { server: string }) {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Menu>
|
<Menu>
|
||||||
<Header size="300" style={{ padding: `0 ${config.space.S200}` }}>
|
<Header size="400" style={{ padding: `0 ${config.space.S400}` }}>
|
||||||
<Text size="L400">Hint</Text>
|
<Text size="L400">Hint</Text>
|
||||||
</Header>
|
</Header>
|
||||||
<Box
|
<Box
|
||||||
style={{ padding: config.space.S200, paddingTop: 0 }}
|
style={{ padding: config.space.S400, paddingTop: 0 }}
|
||||||
direction="Column"
|
direction="Column"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
gap="100"
|
gap="100"
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,7 @@ export const login = async (
|
||||||
}
|
}
|
||||||
|
|
||||||
const mx = createClient({ baseUrl: url });
|
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) {
|
||||||
if (err.httpStatus === 400) {
|
if (err.httpStatus === 400) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { Box, Text, color } from 'folds';
|
import { Box, Text, color } from 'folds';
|
||||||
import { Link, useSearchParams } from 'react-router-dom';
|
import { Link, useSearchParams } from 'react-router-dom';
|
||||||
|
import { SSOAction } from 'matrix-js-sdk';
|
||||||
import { useAuthServer } from '../../../hooks/useAuthServer';
|
import { useAuthServer } from '../../../hooks/useAuthServer';
|
||||||
import { RegisterFlowStatus, useAuthFlows } from '../../../hooks/useAuthFlows';
|
import { RegisterFlowStatus, useAuthFlows } from '../../../hooks/useAuthFlows';
|
||||||
import { useParsedLoginFlows } from '../../../hooks/useParsedLoginFlows';
|
import { useParsedLoginFlows } from '../../../hooks/useParsedLoginFlows';
|
||||||
|
|
@ -83,6 +84,7 @@ export function Register() {
|
||||||
<SSOLogin
|
<SSOLogin
|
||||||
providers={sso.identity_providers}
|
providers={sso.identity_providers}
|
||||||
redirectUrl={ssoRedirectUrl}
|
redirectUrl={ssoRedirectUrl}
|
||||||
|
action={SSOAction.REGISTER}
|
||||||
saveScreenSpace={registerFlows.status === RegisterFlowStatus.FlowRequired}
|
saveScreenSpace={registerFlows.status === RegisterFlowStatus.FlowRequired}
|
||||||
/>
|
/>
|
||||||
<span data-spacing-node />
|
<span data-spacing-node />
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ import {
|
||||||
} from '../../../client/initMatrix';
|
} from '../../../client/initMatrix';
|
||||||
import { getSecret } from '../../../client/state/auth';
|
import { getSecret } from '../../../client/state/auth';
|
||||||
import { SplashScreen } from '../../components/splash-screen';
|
import { SplashScreen } from '../../components/splash-screen';
|
||||||
import { CapabilitiesAndMediaConfigLoader } from '../../components/CapabilitiesAndMediaConfigLoader';
|
import { ServerConfigsLoader } from '../../components/ServerConfigsLoader';
|
||||||
import { CapabilitiesProvider } from '../../hooks/useCapabilities';
|
import { CapabilitiesProvider } from '../../hooks/useCapabilities';
|
||||||
import { MediaConfigProvider } from '../../hooks/useMediaConfig';
|
import { MediaConfigProvider } from '../../hooks/useMediaConfig';
|
||||||
import { MatrixClientProvider } from '../../hooks/useMatrixClient';
|
import { MatrixClientProvider } from '../../hooks/useMatrixClient';
|
||||||
|
|
@ -37,6 +37,7 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
import { useSyncState } from '../../hooks/useSyncState';
|
import { useSyncState } from '../../hooks/useSyncState';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import { SyncStatus } from './SyncStatus';
|
import { SyncStatus } from './SyncStatus';
|
||||||
|
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
|
||||||
|
|
||||||
function ClientRootLoading() {
|
function ClientRootLoading() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -90,7 +91,7 @@ function ClientRootOptions({ mx }: { mx?: MatrixClient }) {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Menu>
|
<Menu>
|
||||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
<Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
|
||||||
{mx && (
|
{mx && (
|
||||||
<MenuItem onClick={() => clearCacheAndReload(mx)} size="300" radii="300">
|
<MenuItem onClick={() => clearCacheAndReload(mx)} size="300" radii="300">
|
||||||
<Text as="span" size="T300" truncate>
|
<Text as="span" size="T300" truncate>
|
||||||
|
|
@ -207,18 +208,20 @@ export function ClientRoot({ children }: ClientRootProps) {
|
||||||
<ClientRootLoading />
|
<ClientRootLoading />
|
||||||
) : (
|
) : (
|
||||||
<MatrixClientProvider value={mx}>
|
<MatrixClientProvider value={mx}>
|
||||||
<CapabilitiesAndMediaConfigLoader>
|
<ServerConfigsLoader>
|
||||||
{(capabilities, mediaConfig) => (
|
{(serverConfigs) => (
|
||||||
<CapabilitiesProvider value={capabilities ?? {}}>
|
<CapabilitiesProvider value={serverConfigs.capabilities ?? {}}>
|
||||||
<MediaConfigProvider value={mediaConfig ?? {}}>
|
<MediaConfigProvider value={serverConfigs.mediaConfig ?? {}}>
|
||||||
|
<AuthMetadataProvider value={serverConfigs.authMetadata}>
|
||||||
{children}
|
{children}
|
||||||
<Windows />
|
<Windows />
|
||||||
<Dialogs />
|
<Dialogs />
|
||||||
<ReusableContextMenu />
|
<ReusableContextMenu />
|
||||||
|
</AuthMetadataProvider>
|
||||||
</MediaConfigProvider>
|
</MediaConfigProvider>
|
||||||
</CapabilitiesProvider>
|
</CapabilitiesProvider>
|
||||||
)}
|
)}
|
||||||
</CapabilitiesAndMediaConfigLoader>
|
</ServerConfigsLoader>
|
||||||
</MatrixClientProvider>
|
</MatrixClientProvider>
|
||||||
)}
|
)}
|
||||||
</SpecVersions>
|
</SpecVersions>
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ export function WelcomePage() {
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer noopener"
|
rel="noreferrer noopener"
|
||||||
>
|
>
|
||||||
v4.5.1
|
v4.8.1
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -67,8 +67,8 @@ const DirectMenu = forwardRef<HTMLDivElement, DirectMenuProps>(({ requestClose }
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
|
<Menu ref={ref} style={{ minWidth: toRem(200) }}>
|
||||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
<Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={handleMarkAsRead}
|
onClick={handleMarkAsRead}
|
||||||
size="300"
|
size="300"
|
||||||
|
|
|
||||||
|
|
@ -209,7 +209,7 @@ export function Explore() {
|
||||||
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
||||||
<Avatar size="200" radii="400">
|
<Avatar size="200" radii="400">
|
||||||
<Icon
|
<Icon
|
||||||
src={Icons.Category}
|
src={Icons.Server}
|
||||||
size="100"
|
size="100"
|
||||||
filled={selectedServer === userServer}
|
filled={selectedServer === userServer}
|
||||||
/>
|
/>
|
||||||
|
|
@ -243,11 +243,7 @@ export function Explore() {
|
||||||
<NavItemContent>
|
<NavItemContent>
|
||||||
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
||||||
<Avatar size="200" radii="400">
|
<Avatar size="200" radii="400">
|
||||||
<Icon
|
<Icon src={Icons.Server} size="100" filled={server === selectedServer} />
|
||||||
src={Icons.Category}
|
|
||||||
size="100"
|
|
||||||
filled={server === selectedServer}
|
|
||||||
/>
|
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Box as="span" grow="Yes">
|
<Box as="span" grow="Yes">
|
||||||
<Text as="span" size="Inherit" truncate>
|
<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