Compare commits
28 commits
c3ba872553
...
36a5eb3c47
Author | SHA1 | Date | |
---|---|---|---|
36a5eb3c47 | |||
|
8267990e6f | ||
|
e8020acabf | ||
|
e5b980fbc7 | ||
|
b803ce99e3 | ||
|
3ae1e58ff2 | ||
|
ce347a0ff4 | ||
|
d3f97ef93e | ||
|
53cd08f0da | ||
|
da5ebf7ab3 | ||
|
ca3535b1a5 | ||
|
2c1e51a8b8 | ||
|
71b2859440 | ||
|
1d799185d6 | ||
|
b97f410731 | ||
|
a18c2e5be1 | ||
|
3025133d18 | ||
|
743e916d12 | ||
|
8c5a1d15cb | ||
|
372d4d5c34 | ||
|
b0796f72d3 | ||
|
689adde8ae | ||
|
983d533452 | ||
|
ef2733df48 | ||
|
20db27fa7e | ||
|
bb88eb7154 | ||
|
2a1bf4a42a | ||
|
2889a72b81 |
121 changed files with 6655 additions and 565 deletions
14
.github/workflows/build-pull-request.yml
vendored
14
.github/workflows/build-pull-request.yml
vendored
|
@ -12,20 +12,20 @@ jobs:
|
||||||
PR_NUMBER: ${{github.event.number}}
|
PR_NUMBER: ${{github.event.number}}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v3.5.3
|
uses: actions/checkout@v4.1.4
|
||||||
- name: Setup node
|
- name: Setup node
|
||||||
uses: actions/setup-node@v3.8.1
|
uses: actions/setup-node@v4.0.2
|
||||||
with:
|
with:
|
||||||
node-version: 18.12.1
|
node-version: 20.12.2
|
||||||
cache: "npm"
|
cache: 'npm'
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
- name: Build app
|
- name: Build app
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: "--max_old_space_size=4096"
|
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||||
run: npm run build
|
run: npm run build
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v3.1.2
|
uses: actions/upload-artifact@v4.3.3
|
||||||
with:
|
with:
|
||||||
name: preview
|
name: preview
|
||||||
path: dist
|
path: dist
|
||||||
|
@ -33,7 +33,7 @@ jobs:
|
||||||
- name: Save pr number
|
- name: Save pr number
|
||||||
run: echo ${PR_NUMBER} > ./pr.txt
|
run: echo ${PR_NUMBER} > ./pr.txt
|
||||||
- name: Upload pr number
|
- name: Upload pr number
|
||||||
uses: actions/upload-artifact@v3.1.2
|
uses: actions/upload-artifact@v4.3.3
|
||||||
with:
|
with:
|
||||||
name: pr
|
name: pr
|
||||||
path: ./pr.txt
|
path: ./pr.txt
|
||||||
|
|
2
.github/workflows/cla.yml
vendored
2
.github/workflows/cla.yml
vendored
|
@ -12,7 +12,7 @@ jobs:
|
||||||
- name: 'CLA Assistant'
|
- name: 'CLA Assistant'
|
||||||
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
|
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
|
||||||
# Beta Release
|
# Beta Release
|
||||||
uses: cla-assistant/github-action@v2.3.0
|
uses: cla-assistant/github-action@v2.3.2
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
# the below token should have repo scope and must be manually added by you in the repository's secret
|
# the below token should have repo scope and must be manually added by you in the repository's secret
|
||||||
|
|
8
.github/workflows/deploy-pull-request.yml
vendored
8
.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@246dbf436b23d7c49e21a7ab8204ca9ecd1fe615
|
uses: dawidd6/action-download-artifact@09f2f74827fd3a8607589e5ad7f9398816f540fe
|
||||||
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@246dbf436b23d7c49e21a7ab8204ca9ecd1fe615
|
uses: dawidd6/action-download-artifact@09f2f74827fd3a8607589e5ad7f9398816f540fe
|
||||||
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 }}
|
||||||
|
@ -32,7 +32,7 @@ jobs:
|
||||||
path: dist
|
path: dist
|
||||||
- name: Deploy to Netlify
|
- name: Deploy to Netlify
|
||||||
id: netlify
|
id: netlify
|
||||||
uses: nwtgck/actions-netlify@7a92f00dde8c92a5a9e8385ec2919775f7647352
|
uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654
|
||||||
with:
|
with:
|
||||||
publish-dir: dist
|
publish-dir: dist
|
||||||
deploy-message: "Deploy PR ${{ steps.pr.outputs.id }}"
|
deploy-message: "Deploy PR ${{ steps.pr.outputs.id }}"
|
||||||
|
@ -45,7 +45,7 @@ jobs:
|
||||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID_PR_CINNY }}
|
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID_PR_CINNY }}
|
||||||
timeout-minutes: 1
|
timeout-minutes: 1
|
||||||
- name: Comment preview on PR
|
- name: Comment preview on PR
|
||||||
uses: thollander/actions-comment-pull-request@1d3973dc4b8e1399c0620d3f2b1aa5e795465308
|
uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
with:
|
with:
|
||||||
|
|
4
.github/workflows/docker-pr.yml
vendored
4
.github/workflows/docker-pr.yml
vendored
|
@ -11,9 +11,9 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v3.5.3
|
uses: actions/checkout@v4.1.4
|
||||||
- name: Build Docker image
|
- name: Build Docker image
|
||||||
uses: docker/build-push-action@v4.1.1
|
uses: docker/build-push-action@v5.3.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: false
|
push: false
|
||||||
|
|
2
.github/workflows/lockfile.yml
vendored
2
.github/workflows/lockfile.yml
vendored
|
@ -14,7 +14,7 @@ jobs:
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3.5.3
|
uses: actions/checkout@v4.1.4
|
||||||
- name: NPM Lockfile Changes
|
- name: NPM Lockfile Changes
|
||||||
uses: codepunkt/npm-lockfile-changes@b40543471c36394409466fdb277a73a0856d7891
|
uses: codepunkt/npm-lockfile-changes@b40543471c36394409466fdb277a73a0856d7891
|
||||||
with:
|
with:
|
||||||
|
|
14
.github/workflows/netlify-dev.yml
vendored
14
.github/workflows/netlify-dev.yml
vendored
|
@ -11,23 +11,23 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v3.5.3
|
uses: actions/checkout@v4.1.4
|
||||||
- name: Setup node
|
- name: Setup node
|
||||||
uses: actions/setup-node@v3.8.1
|
uses: actions/setup-node@v4.0.2
|
||||||
with:
|
with:
|
||||||
node-version: 18.12.1
|
node-version: 20.12.2
|
||||||
cache: "npm"
|
cache: 'npm'
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
- name: Build app
|
- name: Build app
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: "--max_old_space_size=4096"
|
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||||
run: npm run build
|
run: npm run build
|
||||||
- name: Deploy to Netlify
|
- name: Deploy to Netlify
|
||||||
uses: nwtgck/actions-netlify@7a92f00dde8c92a5a9e8385ec2919775f7647352
|
uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654
|
||||||
with:
|
with:
|
||||||
publish-dir: dist
|
publish-dir: dist
|
||||||
deploy-message: "Dev deploy ${{ github.sha }}"
|
deploy-message: 'Dev deploy ${{ github.sha }}'
|
||||||
enable-commit-comment: false
|
enable-commit-comment: false
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
production-deploy: true
|
production-deploy: true
|
||||||
|
|
30
.github/workflows/prod-deploy.yml
vendored
30
.github/workflows/prod-deploy.yml
vendored
|
@ -10,23 +10,23 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v3.5.3
|
uses: actions/checkout@v4.1.4
|
||||||
- name: Setup node
|
- name: Setup node
|
||||||
uses: actions/setup-node@v3.8.1
|
uses: actions/setup-node@v4.0.2
|
||||||
with:
|
with:
|
||||||
node-version: 18.12.1
|
node-version: 20.12.2
|
||||||
cache: "npm"
|
cache: 'npm'
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
- name: Build app
|
- name: Build app
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: "--max_old_space_size=4096"
|
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||||
run: npm run build
|
run: npm run build
|
||||||
- name: Deploy to Netlify
|
- name: Deploy to Netlify
|
||||||
uses: nwtgck/actions-netlify@7a92f00dde8c92a5a9e8385ec2919775f7647352
|
uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654
|
||||||
with:
|
with:
|
||||||
publish-dir: dist
|
publish-dir: dist
|
||||||
deploy-message: "Prod deploy ${{ github.ref_name }}"
|
deploy-message: 'Prod deploy ${{ github.ref_name }}'
|
||||||
enable-commit-comment: false
|
enable-commit-comment: false
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
production-deploy: true
|
production-deploy: true
|
||||||
|
@ -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@de2c0eb89ae2a093876385947365aca7b0e5f844
|
uses: softprops/action-gh-release@9d7c94cfd0a1f3ed45544c887983e9fa900f0564
|
||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
cinny-${{ steps.vars.outputs.tag }}.tar.gz
|
cinny-${{ steps.vars.outputs.tag }}.tar.gz
|
||||||
|
@ -66,31 +66,31 @@ jobs:
|
||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v3.5.3
|
uses: actions/checkout@v4.1.4
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2.2.0
|
uses: docker/setup-qemu-action@v3.0.0
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v2.7.0
|
uses: docker/setup-buildx-action@v3.3.0
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v2.2.0
|
uses: docker/login-action@v3.1.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
- name: Login to the Container registry
|
- name: Login to the Container registry
|
||||||
uses: docker/login-action@v2.2.0
|
uses: docker/login-action@v3.1.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Extract metadata (tags, labels) for Docker
|
- name: Extract metadata (tags, labels) for Docker
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v4.6.0
|
uses: docker/metadata-action@v5.5.1
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
${{ 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@v4.1.1
|
uses: docker/build-push-action@v5.3.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
## Builder
|
## Builder
|
||||||
FROM node:18.12.1-alpine3.15 as builder
|
FROM node:20.12.2-alpine3.18 as builder
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
|
@ -11,7 +11,7 @@ RUN npm run build
|
||||||
|
|
||||||
|
|
||||||
## App
|
## App
|
||||||
FROM nginx:1.25.1-alpine
|
FROM nginx:1.25.5-alpine
|
||||||
|
|
||||||
COPY --from=builder /src/dist /app
|
COPY --from=builder /src/dist /app
|
||||||
|
|
||||||
|
|
|
@ -86,7 +86,7 @@ UeGsouhyuITLwEhScounZDqop+Dx
|
||||||
|
|
||||||
## Local development
|
## Local development
|
||||||
> We recommend using a version manager as versions change very quickly. You will likely need to switch
|
> We recommend using a version manager as versions change very quickly. You will likely need to switch
|
||||||
between multiple Node.js versions based on the needs of different projects you're working on. [NVM on windows](https://github.com/coreybutler/nvm-windows#installation--upgrades) on Windows and [nvm](https://github.com/nvm-sh/nvm) on Linux/macOS are pretty good choices. Also recommended nodejs version Hydrogen LTS (v18).
|
between multiple Node.js versions based on the needs of different projects you're working on. [NVM on windows](https://github.com/coreybutler/nvm-windows#installation--upgrades) on Windows and [nvm](https://github.com/nvm-sh/nvm) on Linux/macOS are pretty good choices. Recommended nodejs version is Iron LTS (v20).
|
||||||
|
|
||||||
Execute the following commands to start a development server:
|
Execute the following commands to start a development server:
|
||||||
```sh
|
```sh
|
||||||
|
|
|
@ -1,3 +0,0 @@
|
||||||
# Redirects from what the browser requests to what we serve
|
|
||||||
/login /
|
|
||||||
/register /
|
|
3
build.config.ts
Normal file
3
build.config.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export default {
|
||||||
|
base: '/',
|
||||||
|
};
|
10
config.json
10
config.json
|
@ -1,7 +1,6 @@
|
||||||
{
|
{
|
||||||
"defaultHomeserver": 3,
|
"defaultHomeserver": 2,
|
||||||
"homeserverList": [
|
"homeserverList": [
|
||||||
"0wnz.at",
|
|
||||||
"converser.eu",
|
"converser.eu",
|
||||||
"envs.net",
|
"envs.net",
|
||||||
"matrix.org",
|
"matrix.org",
|
||||||
|
@ -9,5 +8,10 @@
|
||||||
"mozilla.org",
|
"mozilla.org",
|
||||||
"xmr.se"
|
"xmr.se"
|
||||||
],
|
],
|
||||||
"allowCustomHomeservers": true
|
"allowCustomHomeservers": true,
|
||||||
|
|
||||||
|
"hashRouter": {
|
||||||
|
"enabled": false,
|
||||||
|
"basename": "/"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -96,6 +96,6 @@
|
||||||
<audio id="inviteSound">
|
<audio id="inviteSound">
|
||||||
<source src="./public/sound/invite.ogg" type="audio/ogg" />
|
<source src="./public/sound/invite.ogg" type="audio/ogg" />
|
||||||
</audio>
|
</audio>
|
||||||
<script type="module" src="./src/index.jsx"></script>
|
<script type="module" src="./src/index.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
34
netlify.toml
Normal file
34
netlify.toml
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
[[redirects]]
|
||||||
|
from = "/config.json"
|
||||||
|
to = "/config.json"
|
||||||
|
status = 200
|
||||||
|
|
||||||
|
[[redirects]]
|
||||||
|
from = "/manifest.json"
|
||||||
|
to = "/manifest.json"
|
||||||
|
status = 200
|
||||||
|
|
||||||
|
[[redirects]]
|
||||||
|
from = "/olm.wasm"
|
||||||
|
to = "/olm.wasm"
|
||||||
|
status = 200
|
||||||
|
|
||||||
|
[[redirects]]
|
||||||
|
from = "/pdf.worker.min.js"
|
||||||
|
to = "/pdf.worker.min.js"
|
||||||
|
status = 200
|
||||||
|
|
||||||
|
[[redirects]]
|
||||||
|
from = "/public/*"
|
||||||
|
to = "/public/:splat"
|
||||||
|
status = 200
|
||||||
|
|
||||||
|
[[redirects]]
|
||||||
|
from = "/assets/*"
|
||||||
|
to = "/assets/:splat"
|
||||||
|
status = 200
|
||||||
|
|
||||||
|
[[redirects]]
|
||||||
|
from = "/*"
|
||||||
|
to = "/index.html"
|
||||||
|
status = 200
|
2057
package-lock.json
generated
2057
package-lock.json
generated
File diff suppressed because it is too large
Load diff
25
package.json
25
package.json
|
@ -39,32 +39,34 @@
|
||||||
"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": "1.5.0",
|
"folds": "1.5.1",
|
||||||
"formik": "2.2.9",
|
"formik": "2.2.9",
|
||||||
"html-dom-parser": "4.0.0",
|
"html-dom-parser": "4.0.0",
|
||||||
"html-react-parser": "4.2.0",
|
"html-react-parser": "4.2.0",
|
||||||
"immer": "9.0.16",
|
"immer": "9.0.16",
|
||||||
"is-hotkey": "0.2.0",
|
"is-hotkey": "0.2.0",
|
||||||
"jotai": "1.12.0",
|
"jotai": "2.6.0",
|
||||||
|
"katex": "0.16.10",
|
||||||
"linkify-html": "4.0.2",
|
"linkify-html": "4.0.2",
|
||||||
"linkify-react": "4.1.1",
|
"linkify-react": "4.1.1",
|
||||||
"linkifyjs": "4.0.2",
|
"linkifyjs": "4.0.2",
|
||||||
"matrix-js-sdk": "24.1.0",
|
"matrix-js-sdk": "29.1.0",
|
||||||
"millify": "6.1.0",
|
"millify": "6.1.0",
|
||||||
"pdfjs-dist": "3.10.111",
|
"pdfjs-dist": "3.10.111",
|
||||||
"prismjs": "1.29.0",
|
"prismjs": "1.29.0",
|
||||||
"prop-types": "15.8.1",
|
"prop-types": "15.8.1",
|
||||||
"react": "17.0.2",
|
"react": "18.2.0",
|
||||||
"react-aria": "3.29.1",
|
"react-aria": "3.29.1",
|
||||||
"react-autosize-textarea": "7.1.0",
|
"react-autosize-textarea": "7.1.0",
|
||||||
"react-blurhash": "0.2.0",
|
"react-blurhash": "0.2.0",
|
||||||
"react-dnd": "15.1.2",
|
"react-dnd": "16.0.1",
|
||||||
"react-dnd-html5-backend": "15.1.3",
|
"react-dnd-html5-backend": "16.0.1",
|
||||||
"react-dom": "17.0.2",
|
"react-dom": "18.2.0",
|
||||||
"react-error-boundary": "4.0.10",
|
"react-error-boundary": "4.0.10",
|
||||||
"react-google-recaptcha": "2.1.0",
|
"react-google-recaptcha": "2.1.0",
|
||||||
"react-modal": "3.16.1",
|
"react-modal": "3.16.1",
|
||||||
"react-range": "1.8.14",
|
"react-range": "1.8.14",
|
||||||
|
"react-router-dom": "6.20.0",
|
||||||
"sanitize-html": "2.8.0",
|
"sanitize-html": "2.8.0",
|
||||||
"slate": "0.94.1",
|
"slate": "0.94.1",
|
||||||
"slate-history": "0.93.0",
|
"slate-history": "0.93.0",
|
||||||
|
@ -79,13 +81,14 @@
|
||||||
"@types/file-saver": "2.0.5",
|
"@types/file-saver": "2.0.5",
|
||||||
"@types/node": "18.11.18",
|
"@types/node": "18.11.18",
|
||||||
"@types/prismjs": "1.26.0",
|
"@types/prismjs": "1.26.0",
|
||||||
"@types/react": "18.0.26",
|
"@types/react": "18.2.39",
|
||||||
"@types/react-dom": "18.0.9",
|
"@types/react-dom": "18.2.17",
|
||||||
|
"@types/react-google-recaptcha": "2.1.8",
|
||||||
"@types/sanitize-html": "2.9.0",
|
"@types/sanitize-html": "2.9.0",
|
||||||
"@types/ua-parser-js": "0.7.36",
|
"@types/ua-parser-js": "0.7.36",
|
||||||
"@typescript-eslint/eslint-plugin": "5.46.1",
|
"@typescript-eslint/eslint-plugin": "5.46.1",
|
||||||
"@typescript-eslint/parser": "5.46.1",
|
"@typescript-eslint/parser": "5.46.1",
|
||||||
"@vitejs/plugin-react": "3.0.0",
|
"@vitejs/plugin-react": "4.2.0",
|
||||||
"buffer": "6.0.3",
|
"buffer": "6.0.3",
|
||||||
"eslint": "8.29.0",
|
"eslint": "8.29.0",
|
||||||
"eslint-config-airbnb": "19.0.4",
|
"eslint-config-airbnb": "19.0.4",
|
||||||
|
@ -98,7 +101,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": "4.3.9",
|
"vite": "5.0.13",
|
||||||
"vite-plugin-static-copy": "0.13.0"
|
"vite-plugin-static-copy": "0.13.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,52 +6,52 @@
|
||||||
"lang": "en-US",
|
"lang": "en-US",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"start_url": "/",
|
"start_url": "./",
|
||||||
"background_color": "#fff",
|
"background_color": "#fff",
|
||||||
"theme_color": "#fff",
|
"theme_color": "#fff",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/public/android/android-chrome-36x36.png",
|
"src": "./public/android/android-chrome-36x36.png",
|
||||||
"sizes": "36x36",
|
"sizes": "36x36",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/public/android/android-chrome-48x48.png",
|
"src": "./public/android/android-chrome-48x48.png",
|
||||||
"sizes": "48x48",
|
"sizes": "48x48",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/public/android/android-chrome-72x72.png",
|
"src": "./public/android/android-chrome-72x72.png",
|
||||||
"sizes": "72x72",
|
"sizes": "72x72",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/public/android/android-chrome-96x96.png",
|
"src": "./public/android/android-chrome-96x96.png",
|
||||||
"sizes": "96x96",
|
"sizes": "96x96",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/public/android/android-chrome-144x144.png",
|
"src": "./public/android/android-chrome-144x144.png",
|
||||||
"sizes": "144x144",
|
"sizes": "144x144",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/public/android/android-chrome-192x192.png",
|
"src": "./public/android/android-chrome-192x192.png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/public/android/android-chrome-256x256.png",
|
"src": "./public/android/android-chrome-256x256.png",
|
||||||
"sizes": "256x256",
|
"sizes": "256x256",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/public/android/android-chrome-384x384.png",
|
"src": "./public/android/android-chrome-384x384.png",
|
||||||
"sizes": "384x384",
|
"sizes": "384x384",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/public/android/android-chrome-512x512.png",
|
"src": "./public/android/android-chrome-512x512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
}
|
}
|
||||||
|
|
64
src/app/components/AuthFlowsLoader.tsx
Normal file
64
src/app/components/AuthFlowsLoader.tsx
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
import { ReactNode, useCallback, useEffect, useMemo } from 'react';
|
||||||
|
import { MatrixError, createClient } from 'matrix-js-sdk';
|
||||||
|
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
||||||
|
import { useAutoDiscoveryInfo } from '../hooks/useAutoDiscoveryInfo';
|
||||||
|
import { promiseFulfilledResult, promiseRejectedResult } from '../utils/common';
|
||||||
|
import {
|
||||||
|
AuthFlows,
|
||||||
|
RegisterFlowStatus,
|
||||||
|
RegisterFlowsResponse,
|
||||||
|
parseRegisterErrResp,
|
||||||
|
} from '../hooks/useAuthFlows';
|
||||||
|
|
||||||
|
type AuthFlowsLoaderProps = {
|
||||||
|
fallback?: () => ReactNode;
|
||||||
|
error?: (err: unknown) => ReactNode;
|
||||||
|
children: (authFlows: AuthFlows) => ReactNode;
|
||||||
|
};
|
||||||
|
export function AuthFlowsLoader({ fallback, error, children }: AuthFlowsLoaderProps) {
|
||||||
|
const autoDiscoveryInfo = useAutoDiscoveryInfo();
|
||||||
|
const baseUrl = autoDiscoveryInfo['m.homeserver'].base_url;
|
||||||
|
|
||||||
|
const mx = useMemo(() => createClient({ baseUrl }), [baseUrl]);
|
||||||
|
|
||||||
|
const [state, load] = useAsyncCallback(
|
||||||
|
useCallback(async () => {
|
||||||
|
const result = await Promise.allSettled([mx.loginFlows(), mx.registerRequest({})]);
|
||||||
|
const loginFlows = promiseFulfilledResult(result[0]);
|
||||||
|
const registerResp = promiseRejectedResult(result[1]) as MatrixError | undefined;
|
||||||
|
let registerFlows: RegisterFlowsResponse = { status: RegisterFlowStatus.InvalidRequest };
|
||||||
|
|
||||||
|
if (typeof registerResp === 'object' && registerResp.httpStatus) {
|
||||||
|
registerFlows = parseRegisterErrResp(registerResp);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!loginFlows) {
|
||||||
|
throw new Error('Missing auth flow!');
|
||||||
|
}
|
||||||
|
if ('errcode' in loginFlows) {
|
||||||
|
throw new Error('Failed to load auth flow!');
|
||||||
|
}
|
||||||
|
|
||||||
|
const authFlows: AuthFlows = {
|
||||||
|
loginFlows,
|
||||||
|
registerFlows,
|
||||||
|
};
|
||||||
|
|
||||||
|
return authFlows;
|
||||||
|
}, [mx])
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
if (state.status === AsyncStatus.Idle || state.status === AsyncStatus.Loading) {
|
||||||
|
return fallback?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.status === AsyncStatus.Error) {
|
||||||
|
return error?.(state.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return children(state.data);
|
||||||
|
}
|
38
src/app/components/ClientConfigLoader.tsx
Normal file
38
src/app/components/ClientConfigLoader.tsx
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
import { ReactNode, useCallback, useEffect, useState } from 'react';
|
||||||
|
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
||||||
|
import { ClientConfig } from '../hooks/useClientConfig';
|
||||||
|
import { trimTrailingSlash } from '../utils/common';
|
||||||
|
|
||||||
|
const getClientConfig = async (): Promise<ClientConfig> => {
|
||||||
|
const url = `${trimTrailingSlash(import.meta.env.BASE_URL)}/config.json`;
|
||||||
|
const config = await fetch(url, { method: 'GET' });
|
||||||
|
return config.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
type ClientConfigLoaderProps = {
|
||||||
|
fallback?: () => ReactNode;
|
||||||
|
error?: (err: unknown, retry: () => void, ignore: () => void) => ReactNode;
|
||||||
|
children: (config: ClientConfig) => ReactNode;
|
||||||
|
};
|
||||||
|
export function ClientConfigLoader({ fallback, error, children }: ClientConfigLoaderProps) {
|
||||||
|
const [state, load] = useAsyncCallback(getClientConfig);
|
||||||
|
const [ignoreError, setIgnoreError] = useState(false);
|
||||||
|
|
||||||
|
const ignoreCallback = useCallback(() => setIgnoreError(true), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
if (state.status === AsyncStatus.Idle || state.status === AsyncStatus.Loading) {
|
||||||
|
return fallback?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ignoreError && state.status === AsyncStatus.Error) {
|
||||||
|
return error?.(state.error, load, ignoreCallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config: ClientConfig = state.status === AsyncStatus.Success ? state.data : {};
|
||||||
|
|
||||||
|
return children(config);
|
||||||
|
}
|
35
src/app/components/ConfirmPasswordMatch.tsx
Normal file
35
src/app/components/ConfirmPasswordMatch.tsx
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
import { ReactNode, RefObject, useCallback, useRef, useState } from 'react';
|
||||||
|
import { useDebounce } from '../hooks/useDebounce';
|
||||||
|
|
||||||
|
type ConfirmPasswordMatchProps = {
|
||||||
|
initialValue: boolean;
|
||||||
|
children: (
|
||||||
|
match: boolean,
|
||||||
|
doMatch: () => void,
|
||||||
|
passRef: RefObject<HTMLInputElement>,
|
||||||
|
confPassRef: RefObject<HTMLInputElement>
|
||||||
|
) => ReactNode;
|
||||||
|
};
|
||||||
|
export function ConfirmPasswordMatch({ initialValue, children }: ConfirmPasswordMatchProps) {
|
||||||
|
const [match, setMatch] = useState(initialValue);
|
||||||
|
const passRef = useRef<HTMLInputElement>(null);
|
||||||
|
const confPassRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const doMatch = useDebounce(
|
||||||
|
useCallback(() => {
|
||||||
|
const pass = passRef.current?.value;
|
||||||
|
const confPass = confPassRef.current?.value;
|
||||||
|
if (!confPass) {
|
||||||
|
setMatch(initialValue);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMatch(pass === confPass);
|
||||||
|
}, [initialValue]),
|
||||||
|
{
|
||||||
|
wait: 500,
|
||||||
|
immediate: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return children(match, doMatch, passRef, confPassRef);
|
||||||
|
}
|
32
src/app/components/SpecVersionsLoader.tsx
Normal file
32
src/app/components/SpecVersionsLoader.tsx
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import { ReactNode, useCallback, useEffect } from 'react';
|
||||||
|
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
||||||
|
import { SpecVersions, specVersions } from '../cs-api';
|
||||||
|
import { useAutoDiscoveryInfo } from '../hooks/useAutoDiscoveryInfo';
|
||||||
|
|
||||||
|
type SpecVersionsLoaderProps = {
|
||||||
|
fallback?: () => ReactNode;
|
||||||
|
error?: (err: unknown) => ReactNode;
|
||||||
|
children: (versions: SpecVersions) => ReactNode;
|
||||||
|
};
|
||||||
|
export function SpecVersionsLoader({ fallback, error, children }: SpecVersionsLoaderProps) {
|
||||||
|
const autoDiscoveryInfo = useAutoDiscoveryInfo();
|
||||||
|
const baseUrl = autoDiscoveryInfo['m.homeserver'].base_url;
|
||||||
|
|
||||||
|
const [state, load] = useAsyncCallback(
|
||||||
|
useCallback(() => specVersions(fetch, baseUrl), [baseUrl])
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
if (state.status === AsyncStatus.Idle || state.status === AsyncStatus.Loading) {
|
||||||
|
return fallback?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.status === AsyncStatus.Error) {
|
||||||
|
return error?.(state.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return children(state.data);
|
||||||
|
}
|
17
src/app/components/SupportedUIAFlowsLoader.tsx
Normal file
17
src/app/components/SupportedUIAFlowsLoader.tsx
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { UIAFlow } from 'matrix-js-sdk';
|
||||||
|
import { useSupportedUIAFlows } from '../hooks/useUIAFlows';
|
||||||
|
|
||||||
|
export function SupportedUIAFlowsLoader({
|
||||||
|
flows,
|
||||||
|
supportedStages,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
supportedStages: string[];
|
||||||
|
flows: UIAFlow[];
|
||||||
|
children: (supportedFlows: UIAFlow[]) => ReactNode;
|
||||||
|
}) {
|
||||||
|
const supportedFlows = useSupportedUIAFlows(flows, supportedStages);
|
||||||
|
|
||||||
|
return children(supportedFlows);
|
||||||
|
}
|
72
src/app/components/UIAFlowOverlay.tsx
Normal file
72
src/app/components/UIAFlowOverlay.tsx
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
import React, { ReactNode } from 'react';
|
||||||
|
import {
|
||||||
|
Overlay,
|
||||||
|
OverlayBackdrop,
|
||||||
|
Box,
|
||||||
|
config,
|
||||||
|
Text,
|
||||||
|
TooltipProvider,
|
||||||
|
Tooltip,
|
||||||
|
Icons,
|
||||||
|
Icon,
|
||||||
|
Chip,
|
||||||
|
IconButton,
|
||||||
|
} from 'folds';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
|
||||||
|
export type UIAFlowOverlayProps = {
|
||||||
|
currentStep: number;
|
||||||
|
stepCount: number;
|
||||||
|
children: ReactNode;
|
||||||
|
onCancel: () => void;
|
||||||
|
};
|
||||||
|
export function UIAFlowOverlay({
|
||||||
|
currentStep,
|
||||||
|
stepCount,
|
||||||
|
children,
|
||||||
|
onCancel,
|
||||||
|
}: UIAFlowOverlayProps) {
|
||||||
|
return (
|
||||||
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
|
<FocusTrap focusTrapOptions={{ initialFocus: false }}>
|
||||||
|
<Box style={{ height: '100%' }} direction="Column" grow="Yes" gap="400">
|
||||||
|
<Box grow="Yes" direction="Column" alignItems="Center" justifyContent="Center">
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
style={{ padding: config.space.S200 }}
|
||||||
|
shrink="No"
|
||||||
|
justifyContent="Center"
|
||||||
|
alignItems="Center"
|
||||||
|
gap="200"
|
||||||
|
>
|
||||||
|
<Chip as="div" radii="Pill" outlined>
|
||||||
|
<Text as="span" size="T300">{`Step ${currentStep}/${stepCount}`}</Text>
|
||||||
|
</Chip>
|
||||||
|
<TooltipProvider
|
||||||
|
tooltip={
|
||||||
|
<Tooltip variant="Critical">
|
||||||
|
<Text>Exit</Text>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
position="Top"
|
||||||
|
>
|
||||||
|
{(anchorRef) => (
|
||||||
|
<IconButton
|
||||||
|
ref={anchorRef}
|
||||||
|
variant="Critical"
|
||||||
|
size="300"
|
||||||
|
onClick={onCancel}
|
||||||
|
radii="Pill"
|
||||||
|
outlined
|
||||||
|
>
|
||||||
|
<Icon size="50" src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</FocusTrap>
|
||||||
|
</Overlay>
|
||||||
|
);
|
||||||
|
}
|
|
@ -182,18 +182,6 @@ function EmojiBoardTabs({
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Box gap="100">
|
<Box gap="100">
|
||||||
<Badge
|
|
||||||
className={css.EmojiBoardTab}
|
|
||||||
as="button"
|
|
||||||
variant="Secondary"
|
|
||||||
fill={tab === EmojiBoardTab.Emoji ? 'Solid' : 'None'}
|
|
||||||
size="500"
|
|
||||||
onClick={() => onTabChange(EmojiBoardTab.Emoji)}
|
|
||||||
>
|
|
||||||
<Text as="span" size="L400">
|
|
||||||
Emoji
|
|
||||||
</Text>
|
|
||||||
</Badge>
|
|
||||||
<Badge
|
<Badge
|
||||||
className={css.EmojiBoardTab}
|
className={css.EmojiBoardTab}
|
||||||
as="button"
|
as="button"
|
||||||
|
@ -206,6 +194,18 @@ function EmojiBoardTabs({
|
||||||
Sticker
|
Sticker
|
||||||
</Text>
|
</Text>
|
||||||
</Badge>
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
className={css.EmojiBoardTab}
|
||||||
|
as="button"
|
||||||
|
variant="Secondary"
|
||||||
|
fill={tab === EmojiBoardTab.Emoji ? 'Solid' : 'None'}
|
||||||
|
size="500"
|
||||||
|
onClick={() => onTabChange(EmojiBoardTab.Emoji)}
|
||||||
|
>
|
||||||
|
<Text as="span" size="L400">
|
||||||
|
Emoji
|
||||||
|
</Text>
|
||||||
|
</Badge>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -32,8 +32,10 @@ export const ImageViewerImg = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
objectFit: 'contain',
|
objectFit: 'contain',
|
||||||
width: '100%',
|
width: 'auto',
|
||||||
height: '100%',
|
height: 'auto',
|
||||||
|
maxWidth: '100%',
|
||||||
|
maxHeight: '100%',
|
||||||
backgroundColor: color.Surface.Container,
|
backgroundColor: color.Surface.Container,
|
||||||
transition: 'transform 100ms linear',
|
transition: 'transform 100ms linear',
|
||||||
},
|
},
|
||||||
|
|
45
src/app/components/password-input/PasswordInput.tsx
Normal file
45
src/app/components/password-input/PasswordInput.tsx
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
import React, { ComponentProps, forwardRef } from 'react';
|
||||||
|
import { Icon, IconButton, Input, config, Icons } from 'folds';
|
||||||
|
import { UseStateProvider } from '../UseStateProvider';
|
||||||
|
|
||||||
|
type PasswordInputProps = Omit<ComponentProps<typeof Input>, 'type' | 'size'> & {
|
||||||
|
size: '400' | '500';
|
||||||
|
};
|
||||||
|
export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
|
||||||
|
({ variant, size, style, after, ...props }, ref) => {
|
||||||
|
const paddingRight: string = size === '500' ? config.space.S300 : config.space.S200;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UseStateProvider initial={false}>
|
||||||
|
{(visible, setVisible) => (
|
||||||
|
<Input
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
style={{ paddingRight, ...style }}
|
||||||
|
type={visible ? 'text' : 'password'}
|
||||||
|
size={size}
|
||||||
|
variant={variant}
|
||||||
|
after={
|
||||||
|
<>
|
||||||
|
{after}
|
||||||
|
<IconButton
|
||||||
|
onClick={() => setVisible(!visible)}
|
||||||
|
type="button"
|
||||||
|
variant={visible ? 'Warning' : variant}
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
style={{ opacity: config.opacity.P300 }}
|
||||||
|
size="100"
|
||||||
|
src={visible ? Icons.Eye : Icons.EyeBlind}
|
||||||
|
/>
|
||||||
|
</IconButton>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</UseStateProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
12
src/app/components/splash-screen/SplashScreen.css.ts
Normal file
12
src/app/components/splash-screen/SplashScreen.css.ts
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { color, config } from 'folds';
|
||||||
|
|
||||||
|
export const SplashScreen = style({
|
||||||
|
minHeight: '100%',
|
||||||
|
backgroundColor: color.Background.Container,
|
||||||
|
color: color.Background.OnContainer,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SplashScreenFooter = style({
|
||||||
|
padding: config.space.S400,
|
||||||
|
});
|
29
src/app/components/splash-screen/SplashScreen.tsx
Normal file
29
src/app/components/splash-screen/SplashScreen.tsx
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
import { Box, Text } from 'folds';
|
||||||
|
import React, { ReactNode } from 'react';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import * as patternsCSS from '../../styles/Patterns.css';
|
||||||
|
import * as css from './SplashScreen.css';
|
||||||
|
|
||||||
|
type SplashScreenProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
export function SplashScreen({ children }: SplashScreenProps) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
className={classNames(css.SplashScreen, patternsCSS.BackgroundDotPattern)}
|
||||||
|
direction="Column"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<Box
|
||||||
|
className={css.SplashScreenFooter}
|
||||||
|
shrink="No"
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="Center"
|
||||||
|
>
|
||||||
|
<Text size="H2" align="Center">
|
||||||
|
Cinny
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
1
src/app/components/splash-screen/index.ts
Normal file
1
src/app/components/splash-screen/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export * from './SplashScreen';
|
65
src/app/components/uia-stages/DummyStage.tsx
Normal file
65
src/app/components/uia-stages/DummyStage.tsx
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
import React, { useEffect, useCallback } from 'react';
|
||||||
|
import { Dialog, Text, Box, Button, config } from 'folds';
|
||||||
|
import { AuthType } from 'matrix-js-sdk';
|
||||||
|
import { StageComponentProps } from './types';
|
||||||
|
|
||||||
|
function DummyErrorDialog({
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
onRetry,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
onRetry: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="H4">{title}</Text>
|
||||||
|
<Text>{message}</Text>
|
||||||
|
</Box>
|
||||||
|
<Button variant="Critical" onClick={onRetry}>
|
||||||
|
<Text as="span" size="B400">
|
||||||
|
Retry
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
<Button variant="Critical" fill="None" outlined onClick={onCancel}>
|
||||||
|
<Text as="span" size="B400">
|
||||||
|
Cancel
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AutoDummyStageDialog({ stageData, submitAuthDict, onCancel }: StageComponentProps) {
|
||||||
|
const { errorCode, error, session } = stageData;
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(() => {
|
||||||
|
submitAuthDict({
|
||||||
|
type: AuthType.Dummy,
|
||||||
|
session,
|
||||||
|
});
|
||||||
|
}, [session, submitAuthDict]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!errorCode) handleSubmit();
|
||||||
|
}, [handleSubmit, errorCode]);
|
||||||
|
|
||||||
|
if (errorCode) {
|
||||||
|
return (
|
||||||
|
<DummyErrorDialog
|
||||||
|
title={errorCode}
|
||||||
|
message={error ?? 'Failed to register.'}
|
||||||
|
onRetry={handleSubmit}
|
||||||
|
onCancel={onCancel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
172
src/app/components/uia-stages/EmailStage.tsx
Normal file
172
src/app/components/uia-stages/EmailStage.tsx
Normal file
|
@ -0,0 +1,172 @@
|
||||||
|
import React, { useEffect, useCallback, FormEventHandler } from 'react';
|
||||||
|
import { Dialog, Text, Box, Button, config, Input, color, Spinner } from 'folds';
|
||||||
|
import { AuthType, MatrixError } from 'matrix-js-sdk';
|
||||||
|
import { StageComponentProps } from './types';
|
||||||
|
import { AsyncState, AsyncStatus } from '../../hooks/useAsyncCallback';
|
||||||
|
import { RequestEmailTokenCallback, RequestEmailTokenResponse } from '../../hooks/types';
|
||||||
|
|
||||||
|
function EmailErrorDialog({
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
defaultEmail,
|
||||||
|
onRetry,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
defaultEmail?: string;
|
||||||
|
onRetry: (email: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
const handleFormSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
const { retryEmailInput } = evt.target as HTMLFormElement & {
|
||||||
|
retryEmailInput: HTMLInputElement;
|
||||||
|
};
|
||||||
|
const t = retryEmailInput.value;
|
||||||
|
onRetry(t);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<Box
|
||||||
|
as="form"
|
||||||
|
onSubmit={handleFormSubmit}
|
||||||
|
style={{ padding: config.space.S400 }}
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
>
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="H4">{title}</Text>
|
||||||
|
<Text>{message}</Text>
|
||||||
|
<Text as="label" size="L400" style={{ paddingTop: config.space.S400 }}>
|
||||||
|
Email
|
||||||
|
</Text>
|
||||||
|
<Input
|
||||||
|
name="retryEmailInput"
|
||||||
|
variant="Background"
|
||||||
|
size="500"
|
||||||
|
outlined
|
||||||
|
defaultValue={defaultEmail}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Button variant="Primary" type="submit">
|
||||||
|
<Text as="span" size="B400">
|
||||||
|
Send Verification Email
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
<Button variant="Critical" fill="None" outlined type="button" onClick={onCancel}>
|
||||||
|
<Text as="span" size="B400">
|
||||||
|
Cancel
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmailStageDialog({
|
||||||
|
email,
|
||||||
|
clientSecret,
|
||||||
|
stageData,
|
||||||
|
emailTokenState,
|
||||||
|
requestEmailToken,
|
||||||
|
submitAuthDict,
|
||||||
|
onCancel,
|
||||||
|
}: StageComponentProps & {
|
||||||
|
email?: string;
|
||||||
|
clientSecret: string;
|
||||||
|
emailTokenState: AsyncState<RequestEmailTokenResponse, MatrixError>;
|
||||||
|
requestEmailToken: RequestEmailTokenCallback;
|
||||||
|
}) {
|
||||||
|
const { errorCode, error, session } = stageData;
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
(sessionId: string) => {
|
||||||
|
const threepIDCreds = {
|
||||||
|
sid: sessionId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
};
|
||||||
|
submitAuthDict({
|
||||||
|
type: AuthType.Email,
|
||||||
|
threepid_creds: threepIDCreds,
|
||||||
|
threepidCreds: threepIDCreds,
|
||||||
|
session,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[submitAuthDict, session, clientSecret]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEmailSubmit = useCallback(
|
||||||
|
(userEmail: string) => {
|
||||||
|
requestEmailToken(userEmail, clientSecret);
|
||||||
|
},
|
||||||
|
[clientSecret, requestEmailToken]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (email && !errorCode && emailTokenState.status === AsyncStatus.Idle) {
|
||||||
|
requestEmailToken(email, clientSecret);
|
||||||
|
}
|
||||||
|
}, [email, errorCode, clientSecret, emailTokenState, requestEmailToken]);
|
||||||
|
|
||||||
|
if (emailTokenState.status === AsyncStatus.Loading) {
|
||||||
|
return (
|
||||||
|
<Box direction="Column" alignItems="Center" gap="400">
|
||||||
|
<Spinner variant="Secondary" size="600" />
|
||||||
|
<Text style={{ color: color.Secondary.Main }}>Sending verification email...</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (emailTokenState.status === AsyncStatus.Error) {
|
||||||
|
return (
|
||||||
|
<EmailErrorDialog
|
||||||
|
title={emailTokenState.error.errcode ?? 'Verify Email'}
|
||||||
|
message={
|
||||||
|
emailTokenState.error?.data?.error ??
|
||||||
|
emailTokenState.error.message ??
|
||||||
|
'Failed to send verification Email request.'
|
||||||
|
}
|
||||||
|
onRetry={handleEmailSubmit}
|
||||||
|
onCancel={onCancel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (emailTokenState.status === AsyncStatus.Success) {
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="H4">Verification Request Sent</Text>
|
||||||
|
<Text>{`Please check your email "${emailTokenState.data.email}" and validate before continuing further.`}</Text>
|
||||||
|
|
||||||
|
{errorCode && (
|
||||||
|
<Text style={{ color: color.Critical.Main }}>{`${errorCode}: ${error}`}</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Button variant="Primary" onClick={() => handleSubmit(emailTokenState.data.result.sid)}>
|
||||||
|
<Text as="span" size="B400">
|
||||||
|
Continue
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
return (
|
||||||
|
<EmailErrorDialog
|
||||||
|
title="Provide Email"
|
||||||
|
message="Please provide email to send verification request."
|
||||||
|
onRetry={handleEmailSubmit}
|
||||||
|
onCancel={onCancel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
64
src/app/components/uia-stages/ReCaptchaStage.tsx
Normal file
64
src/app/components/uia-stages/ReCaptchaStage.tsx
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Dialog, Text, Box, Button, config } from 'folds';
|
||||||
|
import { AuthType } from 'matrix-js-sdk';
|
||||||
|
import ReCAPTCHA from 'react-google-recaptcha';
|
||||||
|
import { StageComponentProps } from './types';
|
||||||
|
|
||||||
|
function ReCaptchaErrorDialog({
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="H4">{title}</Text>
|
||||||
|
<Text>{message}</Text>
|
||||||
|
</Box>
|
||||||
|
<Button variant="Critical" fill="None" outlined onClick={onCancel}>
|
||||||
|
<Text as="span" size="B400">
|
||||||
|
Cancel
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReCaptchaStageDialog({ stageData, submitAuthDict, onCancel }: StageComponentProps) {
|
||||||
|
const { info, session } = stageData;
|
||||||
|
|
||||||
|
const publicKey = info?.public_key;
|
||||||
|
|
||||||
|
const handleChange = (token: string | null) => {
|
||||||
|
submitAuthDict({
|
||||||
|
type: AuthType.Recaptcha,
|
||||||
|
response: token,
|
||||||
|
session,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof publicKey !== 'string' || !session) {
|
||||||
|
return (
|
||||||
|
<ReCaptchaErrorDialog
|
||||||
|
title="Invalid Data"
|
||||||
|
message="No valid data found to proceed with ReCAPTCHA."
|
||||||
|
onCancel={onCancel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
||||||
|
<Text>Please check the box below to proceed.</Text>
|
||||||
|
<ReCAPTCHA sitekey={publicKey} onChange={handleChange} />
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
117
src/app/components/uia-stages/RegistrationTokenStage.tsx
Normal file
117
src/app/components/uia-stages/RegistrationTokenStage.tsx
Normal file
|
@ -0,0 +1,117 @@
|
||||||
|
import React, { useEffect, useCallback, FormEventHandler } from 'react';
|
||||||
|
import { Dialog, Text, Box, Button, config, Input } from 'folds';
|
||||||
|
import { AuthType } from 'matrix-js-sdk';
|
||||||
|
import { StageComponentProps } from './types';
|
||||||
|
|
||||||
|
function RegistrationTokenErrorDialog({
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
defaultToken,
|
||||||
|
onRetry,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
defaultToken?: string;
|
||||||
|
onRetry: (token: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
const handleFormSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
const { retryTokenInput } = evt.target as HTMLFormElement & {
|
||||||
|
retryTokenInput: HTMLInputElement;
|
||||||
|
};
|
||||||
|
const t = retryTokenInput.value;
|
||||||
|
onRetry(t);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<Box
|
||||||
|
as="form"
|
||||||
|
onSubmit={handleFormSubmit}
|
||||||
|
style={{ padding: config.space.S400 }}
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
>
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="H4">{title}</Text>
|
||||||
|
<Text>{message}</Text>
|
||||||
|
<Text as="label" size="L400" style={{ paddingTop: config.space.S400 }}>
|
||||||
|
Registration Token
|
||||||
|
</Text>
|
||||||
|
<Input
|
||||||
|
name="retryTokenInput"
|
||||||
|
variant="Background"
|
||||||
|
size="500"
|
||||||
|
outlined
|
||||||
|
defaultValue={defaultToken}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Button variant="Critical" type="submit">
|
||||||
|
<Text as="span" size="B400">
|
||||||
|
Retry
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
<Button variant="Critical" fill="None" outlined type="button" onClick={onCancel}>
|
||||||
|
<Text as="span" size="B400">
|
||||||
|
Cancel
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RegistrationTokenStageDialog({
|
||||||
|
token,
|
||||||
|
stageData,
|
||||||
|
submitAuthDict,
|
||||||
|
onCancel,
|
||||||
|
}: StageComponentProps & {
|
||||||
|
token?: string;
|
||||||
|
}) {
|
||||||
|
const { errorCode, error, session } = stageData;
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
(t: string) => {
|
||||||
|
submitAuthDict({
|
||||||
|
type: AuthType.RegistrationToken,
|
||||||
|
token: t,
|
||||||
|
session,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[session, submitAuthDict]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (token && !errorCode) handleSubmit(token);
|
||||||
|
}, [handleSubmit, token, errorCode]);
|
||||||
|
|
||||||
|
if (errorCode) {
|
||||||
|
return (
|
||||||
|
<RegistrationTokenErrorDialog
|
||||||
|
defaultToken={token}
|
||||||
|
title={errorCode}
|
||||||
|
message={error ?? 'Invalid registration token provided.'}
|
||||||
|
onRetry={handleSubmit}
|
||||||
|
onCancel={onCancel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return (
|
||||||
|
<RegistrationTokenErrorDialog
|
||||||
|
defaultToken={token}
|
||||||
|
title="Registration Token"
|
||||||
|
message="Please submit registration token provided by you homeserver admin."
|
||||||
|
onRetry={handleSubmit}
|
||||||
|
onCancel={onCancel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
69
src/app/components/uia-stages/TermsStage.tsx
Normal file
69
src/app/components/uia-stages/TermsStage.tsx
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
import React, { useEffect, useCallback } from 'react';
|
||||||
|
import { Dialog, Text, Box, Button, config } from 'folds';
|
||||||
|
import { AuthType } from 'matrix-js-sdk';
|
||||||
|
import { StageComponentProps } from './types';
|
||||||
|
|
||||||
|
function TermsErrorDialog({
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
onRetry,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
onRetry: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="H4">{title}</Text>
|
||||||
|
<Text>{message}</Text>
|
||||||
|
</Box>
|
||||||
|
<Button variant="Critical" onClick={onRetry}>
|
||||||
|
<Text as="span" size="B400">
|
||||||
|
Retry
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
<Button variant="Critical" fill="None" outlined onClick={onCancel}>
|
||||||
|
<Text as="span" size="B400">
|
||||||
|
Cancel
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AutoTermsStageDialog({ stageData, submitAuthDict, onCancel }: StageComponentProps) {
|
||||||
|
const { errorCode, error, session } = stageData;
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
() =>
|
||||||
|
submitAuthDict({
|
||||||
|
type: AuthType.Terms,
|
||||||
|
session,
|
||||||
|
}),
|
||||||
|
[session, submitAuthDict]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!errorCode) {
|
||||||
|
handleSubmit();
|
||||||
|
}
|
||||||
|
}, [session, errorCode, handleSubmit]);
|
||||||
|
|
||||||
|
if (errorCode) {
|
||||||
|
return (
|
||||||
|
<TermsErrorDialog
|
||||||
|
title={errorCode}
|
||||||
|
message={error ?? 'Failed to submit Terms and Condition Acceptance.'}
|
||||||
|
onRetry={handleSubmit}
|
||||||
|
onCancel={onCancel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
6
src/app/components/uia-stages/index.ts
Normal file
6
src/app/components/uia-stages/index.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
export * from './types';
|
||||||
|
export * from './DummyStage';
|
||||||
|
export * from './EmailStage';
|
||||||
|
export * from './ReCaptchaStage';
|
||||||
|
export * from './RegistrationTokenStage';
|
||||||
|
export * from './TermsStage';
|
8
src/app/components/uia-stages/types.ts
Normal file
8
src/app/components/uia-stages/types.ts
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import { AuthDict } from 'matrix-js-sdk';
|
||||||
|
import { AuthStageData } from '../../hooks/useUIAFlows';
|
||||||
|
|
||||||
|
export type StageComponentProps = {
|
||||||
|
stageData: AuthStageData;
|
||||||
|
submitAuthDict: (authDict: AuthDict) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
};
|
115
src/app/cs-api.ts
Normal file
115
src/app/cs-api.ts
Normal file
|
@ -0,0 +1,115 @@
|
||||||
|
import to from 'await-to-js';
|
||||||
|
import { trimTrailingSlash } from './utils/common';
|
||||||
|
|
||||||
|
export enum AutoDiscoveryAction {
|
||||||
|
PROMPT = 'PROMPT',
|
||||||
|
IGNORE = 'IGNORE',
|
||||||
|
FAIL_PROMPT = 'FAIL_PROMPT',
|
||||||
|
FAIL_ERROR = 'FAIL_ERROR',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AutoDiscoveryError = {
|
||||||
|
host: string;
|
||||||
|
action: AutoDiscoveryAction;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AutoDiscoveryInfo = Record<string, unknown> & {
|
||||||
|
'm.homeserver': {
|
||||||
|
base_url: string;
|
||||||
|
};
|
||||||
|
'm.identity_server'?: {
|
||||||
|
base_url: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const autoDiscovery = async (
|
||||||
|
request: typeof fetch,
|
||||||
|
server: string
|
||||||
|
): Promise<[AutoDiscoveryError, undefined] | [undefined, AutoDiscoveryInfo]> => {
|
||||||
|
const host = /^https?:\/\//.test(server) ? trimTrailingSlash(server) : `https://${server}`;
|
||||||
|
const autoDiscoveryUrl = `${host}/.well-known/matrix/client`;
|
||||||
|
|
||||||
|
const [err, response] = await to(request(autoDiscoveryUrl, { method: 'GET' }));
|
||||||
|
|
||||||
|
if (err || response.status === 404) {
|
||||||
|
// AutoDiscoveryAction.IGNORE
|
||||||
|
// We will use default value for IGNORE action
|
||||||
|
return [
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
'm.homeserver': {
|
||||||
|
base_url: host,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (response.status !== 200) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
host,
|
||||||
|
action: AutoDiscoveryAction.FAIL_PROMPT,
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const [contentErr, content] = await to<AutoDiscoveryInfo>(response.json());
|
||||||
|
|
||||||
|
if (contentErr || typeof content !== 'object') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
host,
|
||||||
|
action: AutoDiscoveryAction.FAIL_PROMPT,
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = content['m.homeserver']?.base_url;
|
||||||
|
if (typeof baseUrl !== 'string') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
host,
|
||||||
|
action: AutoDiscoveryAction.FAIL_PROMPT,
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^https?:\/\//.test(baseUrl) === false) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
host,
|
||||||
|
action: AutoDiscoveryAction.FAIL_ERROR,
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
content['m.homeserver'].base_url = trimTrailingSlash(baseUrl);
|
||||||
|
if (content['m.identity_server']) {
|
||||||
|
content['m.identity_server'].base_url = trimTrailingSlash(
|
||||||
|
content['m.identity_server'].base_url
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [undefined, content];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SpecVersions = {
|
||||||
|
versions: string[];
|
||||||
|
unstable_features?: Record<string, boolean>;
|
||||||
|
};
|
||||||
|
export const specVersions = async (
|
||||||
|
request: typeof fetch,
|
||||||
|
baseUrl: string
|
||||||
|
): Promise<SpecVersions> => {
|
||||||
|
const res = await request(`${baseUrl}/_matrix/client/versions`);
|
||||||
|
|
||||||
|
const data = (await res.json()) as unknown;
|
||||||
|
|
||||||
|
if (data && typeof data === 'object' && 'versions' in data && Array.isArray(data.versions)) {
|
||||||
|
return data as SpecVersions;
|
||||||
|
}
|
||||||
|
throw new Error('Homeserver URL does not appear to be a valid Matrix homeserver');
|
||||||
|
};
|
37
src/app/cs-errorcode.ts
Normal file
37
src/app/cs-errorcode.ts
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
export enum ErrorCode {
|
||||||
|
M_FORBIDDEN = 'M_FORBIDDEN',
|
||||||
|
M_UNKNOWN_TOKEN = 'M_UNKNOWN_TOKEN',
|
||||||
|
M_MISSING_TOKEN = 'M_MISSING_TOKEN',
|
||||||
|
M_BAD_JSON = 'M_BAD_JSON',
|
||||||
|
M_NOT_JSON = 'M_NOT_JSON',
|
||||||
|
M_NOT_FOUND = 'M_NOT_FOUND',
|
||||||
|
M_LIMIT_EXCEEDED = 'M_LIMIT_EXCEEDED',
|
||||||
|
M_UNRECOGNIZED = 'M_UNRECOGNIZED',
|
||||||
|
M_UNKNOWN = 'M_UNKNOWN',
|
||||||
|
|
||||||
|
M_UNAUTHORIZED = 'M_UNAUTHORIZED',
|
||||||
|
M_USER_DEACTIVATED = 'M_USER_DEACTIVATED',
|
||||||
|
M_USER_IN_USE = 'M_USER_IN_USE',
|
||||||
|
M_INVALID_USERNAME = 'M_INVALID_USERNAME',
|
||||||
|
M_WEAK_PASSWORD = 'M_WEAK_PASSWORD',
|
||||||
|
M_PASSWORD_TOO_SHORT = 'M_PASSWORD_TOO_SHORT',
|
||||||
|
M_ROOM_IN_USE = 'M_ROOM_IN_USE',
|
||||||
|
M_INVALID_ROOM_STATE = 'M_INVALID_ROOM_STATE',
|
||||||
|
M_THREEPID_IN_USE = 'M_THREEPID_IN_USE',
|
||||||
|
M_THREEPID_NOT_FOUND = 'M_THREEPID_NOT_FOUND',
|
||||||
|
M_THREEPID_AUTH_FAILED = 'M_THREEPID_AUTH_FAILED',
|
||||||
|
M_THREEPID_DENIED = 'M_THREEPID_DENIED',
|
||||||
|
M_SERVER_NOT_TRUSTED = 'M_SERVER_NOT_TRUSTED',
|
||||||
|
M_UNSUPPORTED_ROOM_VERSION = 'M_UNSUPPORTED_ROOM_VERSION',
|
||||||
|
M_INCOMPATIBLE_ROOM_VERSION = 'M_INCOMPATIBLE_ROOM_VERSION',
|
||||||
|
M_BAD_STATE = 'M_BAD_STATE',
|
||||||
|
M_GUEST_ACCESS_FORBIDDEN = 'M_GUEST_ACCESS_FORBIDDEN',
|
||||||
|
M_CAPTCHA_NEEDED = 'M_CAPTCHA_NEEDED',
|
||||||
|
M_CAPTCHA_INVALID = 'M_CAPTCHA_INVALID',
|
||||||
|
M_MISSING_PARAM = 'M_MISSING_PARAM',
|
||||||
|
M_INVALID_PARAM = 'M_INVALID_PARAM',
|
||||||
|
M_TOO_LARGE = 'M_TOO_LARGE',
|
||||||
|
M_EXCLUSIVE = 'M_EXCLUSIVE',
|
||||||
|
M_RESOURCE_LIMIT_EXCEEDED = 'M_RESOURCE_LIMIT_EXCEEDED',
|
||||||
|
M_CANNOT_LEAVE_SERVER_NOTICE_ROOM = 'M_CANNOT_LEAVE_SERVER_NOTICE_ROOM',
|
||||||
|
}
|
12
src/app/hooks/types.ts
Normal file
12
src/app/hooks/types.ts
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import { IRequestTokenResponse } from 'matrix-js-sdk';
|
||||||
|
|
||||||
|
export type RequestEmailTokenResponse = {
|
||||||
|
email: string;
|
||||||
|
clientSecret: string;
|
||||||
|
result: IRequestTokenResponse;
|
||||||
|
};
|
||||||
|
export type RequestEmailTokenCallback = (
|
||||||
|
email: string,
|
||||||
|
clientSecret: string,
|
||||||
|
nextLink?: string
|
||||||
|
) => Promise<RequestEmailTokenResponse>;
|
|
@ -1,4 +1,5 @@
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
import { flushSync } from 'react-dom';
|
||||||
import { useAlive } from './useAlive';
|
import { useAlive } from './useAlive';
|
||||||
|
|
||||||
export enum AsyncStatus {
|
export enum AsyncStatus {
|
||||||
|
@ -16,36 +17,56 @@ export type AsyncLoading = {
|
||||||
status: AsyncStatus.Loading;
|
status: AsyncStatus.Loading;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AsyncSuccess<T> = {
|
export type AsyncSuccess<D> = {
|
||||||
status: AsyncStatus.Success;
|
status: AsyncStatus.Success;
|
||||||
data: T;
|
data: D;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AsyncError = {
|
export type AsyncError<E = unknown> = {
|
||||||
status: AsyncStatus.Error;
|
status: AsyncStatus.Error;
|
||||||
error: unknown;
|
error: E;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AsyncState<T> = AsyncIdle | AsyncLoading | AsyncSuccess<T> | AsyncError;
|
export type AsyncState<D, E = unknown> = AsyncIdle | AsyncLoading | AsyncSuccess<D> | AsyncError<E>;
|
||||||
|
|
||||||
export type AsyncCallback<TArgs extends unknown[], TData> = (...args: TArgs) => Promise<TData>;
|
export type AsyncCallback<TArgs extends unknown[], TData> = (...args: TArgs) => Promise<TData>;
|
||||||
|
|
||||||
export const useAsyncCallback = <TArgs extends unknown[], TData>(
|
export const useAsyncCallback = <TData, TError, TArgs extends unknown[]>(
|
||||||
asyncCallback: AsyncCallback<TArgs, TData>
|
asyncCallback: AsyncCallback<TArgs, TData>
|
||||||
): [AsyncState<TData>, AsyncCallback<TArgs, TData>] => {
|
): [AsyncState<TData, TError>, AsyncCallback<TArgs, TData>] => {
|
||||||
const [state, setState] = useState<AsyncState<TData>>({
|
const [state, setState] = useState<AsyncState<TData, TError>>({
|
||||||
status: AsyncStatus.Idle,
|
status: AsyncStatus.Idle,
|
||||||
});
|
});
|
||||||
const alive = useAlive();
|
const alive = useAlive();
|
||||||
|
|
||||||
|
// Tracks the request number.
|
||||||
|
// If two or more requests are made subsequently
|
||||||
|
// we will throw all old request's response after they resolved.
|
||||||
|
const reqNumberRef = useRef(0);
|
||||||
|
|
||||||
const callback: AsyncCallback<TArgs, TData> = useCallback(
|
const callback: AsyncCallback<TArgs, TData> = useCallback(
|
||||||
async (...args) => {
|
async (...args) => {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
// Warning: flushSync was called from inside a lifecycle method.
|
||||||
|
// React cannot flush when React is already rendering.
|
||||||
|
// Consider moving this call to a scheduler task or micro task.
|
||||||
|
flushSync(() => {
|
||||||
|
// flushSync because
|
||||||
|
// https://github.com/facebook/react/issues/26713#issuecomment-1872085134
|
||||||
setState({
|
setState({
|
||||||
status: AsyncStatus.Loading,
|
status: AsyncStatus.Loading,
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
reqNumberRef.current += 1;
|
||||||
|
|
||||||
|
const currentReqNumber = reqNumberRef.current;
|
||||||
try {
|
try {
|
||||||
const data = await asyncCallback(...args);
|
const data = await asyncCallback(...args);
|
||||||
|
if (currentReqNumber !== reqNumberRef.current) {
|
||||||
|
throw new Error('AsyncCallbackHook: Request replaced!');
|
||||||
|
}
|
||||||
if (alive()) {
|
if (alive()) {
|
||||||
setState({
|
setState({
|
||||||
status: AsyncStatus.Success,
|
status: AsyncStatus.Success,
|
||||||
|
@ -54,10 +75,13 @@ export const useAsyncCallback = <TArgs extends unknown[], TData>(
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (currentReqNumber !== reqNumberRef.current) {
|
||||||
|
throw new Error('AsyncCallbackHook: Request replaced!');
|
||||||
|
}
|
||||||
if (alive()) {
|
if (alive()) {
|
||||||
setState({
|
setState({
|
||||||
status: AsyncStatus.Error,
|
status: AsyncStatus.Error,
|
||||||
error: e,
|
error: e as TError,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
throw e;
|
throw e;
|
||||||
|
|
59
src/app/hooks/useAuthFlows.ts
Normal file
59
src/app/hooks/useAuthFlows.ts
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
import { IAuthData, MatrixError } from 'matrix-js-sdk';
|
||||||
|
import { ILoginFlowsResponse } from 'matrix-js-sdk/lib/@types/auth';
|
||||||
|
|
||||||
|
export enum RegisterFlowStatus {
|
||||||
|
FlowRequired = 401,
|
||||||
|
InvalidRequest = 400,
|
||||||
|
RegistrationDisabled = 403,
|
||||||
|
RateLimited = 429,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RegisterFlowsResponse =
|
||||||
|
| {
|
||||||
|
status: RegisterFlowStatus.FlowRequired;
|
||||||
|
data: IAuthData;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: Exclude<RegisterFlowStatus, RegisterFlowStatus.FlowRequired>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseRegisterErrResp = (matrixError: MatrixError): RegisterFlowsResponse => {
|
||||||
|
switch (matrixError.httpStatus) {
|
||||||
|
case RegisterFlowStatus.InvalidRequest: {
|
||||||
|
return { status: RegisterFlowStatus.InvalidRequest };
|
||||||
|
}
|
||||||
|
case RegisterFlowStatus.RateLimited: {
|
||||||
|
return { status: RegisterFlowStatus.RateLimited };
|
||||||
|
}
|
||||||
|
case RegisterFlowStatus.RegistrationDisabled: {
|
||||||
|
return { status: RegisterFlowStatus.RegistrationDisabled };
|
||||||
|
}
|
||||||
|
case RegisterFlowStatus.FlowRequired: {
|
||||||
|
return {
|
||||||
|
status: RegisterFlowStatus.FlowRequired,
|
||||||
|
data: matrixError.data as IAuthData,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return { status: RegisterFlowStatus.InvalidRequest };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AuthFlows = {
|
||||||
|
loginFlows: ILoginFlowsResponse;
|
||||||
|
registerFlows: RegisterFlowsResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AuthFlowsContext = createContext<AuthFlows | null>(null);
|
||||||
|
|
||||||
|
export const AuthFlowsProvider = AuthFlowsContext.Provider;
|
||||||
|
|
||||||
|
export const useAuthFlows = (): AuthFlows => {
|
||||||
|
const authFlows = useContext(AuthFlowsContext);
|
||||||
|
if (!authFlows) {
|
||||||
|
throw new Error('Auth Flow info is not loaded!');
|
||||||
|
}
|
||||||
|
return authFlows;
|
||||||
|
};
|
14
src/app/hooks/useAuthServer.ts
Normal file
14
src/app/hooks/useAuthServer.ts
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
|
||||||
|
const AuthServerContext = createContext<string | null>(null);
|
||||||
|
|
||||||
|
export const AuthServerProvider = AuthServerContext.Provider;
|
||||||
|
|
||||||
|
export const useAuthServer = (): string => {
|
||||||
|
const server = useContext(AuthServerContext);
|
||||||
|
if (server === null) {
|
||||||
|
throw new Error('Auth server is not provided!');
|
||||||
|
}
|
||||||
|
|
||||||
|
return server;
|
||||||
|
};
|
15
src/app/hooks/useAutoDiscoveryInfo.ts
Normal file
15
src/app/hooks/useAutoDiscoveryInfo.ts
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
import { AutoDiscoveryInfo } from '../cs-api';
|
||||||
|
|
||||||
|
const AutoDiscoverInfoContext = createContext<AutoDiscoveryInfo | null>(null);
|
||||||
|
|
||||||
|
export const AutoDiscoveryInfoProvider = AutoDiscoverInfoContext.Provider;
|
||||||
|
|
||||||
|
export const useAutoDiscoveryInfo = (): AutoDiscoveryInfo => {
|
||||||
|
const autoDiscoveryInfo = useContext(AutoDiscoverInfoContext);
|
||||||
|
if (!autoDiscoveryInfo) {
|
||||||
|
throw new Error('Auto Discovery Info not loaded');
|
||||||
|
}
|
||||||
|
|
||||||
|
return autoDiscoveryInfo;
|
||||||
|
};
|
33
src/app/hooks/useClientConfig.ts
Normal file
33
src/app/hooks/useClientConfig.ts
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
|
||||||
|
export type ClientConfig = {
|
||||||
|
defaultHomeserver?: number;
|
||||||
|
homeserverList?: string[];
|
||||||
|
allowCustomHomeservers?: boolean;
|
||||||
|
|
||||||
|
hashRouter?: {
|
||||||
|
enabled?: boolean;
|
||||||
|
basename?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const ClientConfigContext = createContext<ClientConfig | null>(null);
|
||||||
|
|
||||||
|
export const ClientConfigProvider = ClientConfigContext.Provider;
|
||||||
|
|
||||||
|
export function useClientConfig(): ClientConfig {
|
||||||
|
const config = useContext(ClientConfigContext);
|
||||||
|
if (!config) throw new Error('Client config are not provided!');
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clientDefaultServer = (clientConfig: ClientConfig): string =>
|
||||||
|
clientConfig.homeserverList?.[clientConfig.defaultHomeserver ?? 0] ?? 'matrix.org';
|
||||||
|
|
||||||
|
export const clientAllowedServer = (clientConfig: ClientConfig, server: string): boolean => {
|
||||||
|
const { homeserverList, allowCustomHomeservers } = clientConfig;
|
||||||
|
|
||||||
|
if (allowCustomHomeservers) return true;
|
||||||
|
|
||||||
|
return homeserverList?.includes(server) === true;
|
||||||
|
};
|
|
@ -9,7 +9,7 @@ export function useCrossSigningStatus() {
|
||||||
const [isCSEnabled, setIsCSEnabled] = useState(hasCrossSigningAccountData());
|
const [isCSEnabled, setIsCSEnabled] = useState(hasCrossSigningAccountData());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isCSEnabled) return null;
|
if (isCSEnabled) return undefined;
|
||||||
const handleAccountData = (event) => {
|
const handleAccountData = (event) => {
|
||||||
if (event.getType() === 'm.cross_signing.master') {
|
if (event.getType() === 'm.cross_signing.master') {
|
||||||
setIsCSEnabled(true);
|
setIsCSEnabled(true);
|
||||||
|
|
38
src/app/hooks/useParsedLoginFlows.ts
Normal file
38
src/app/hooks/useParsedLoginFlows.ts
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { ILoginFlow, IPasswordFlow, ISSOFlow, LoginFlow } from 'matrix-js-sdk/lib/@types/auth';
|
||||||
|
import { WithRequiredProp } from '../../types/utils';
|
||||||
|
|
||||||
|
export type Required_SSOFlow = WithRequiredProp<ISSOFlow, 'identity_providers'>;
|
||||||
|
export const getSSOFlow = (loginFlows: LoginFlow[]): Required_SSOFlow | undefined =>
|
||||||
|
loginFlows.find(
|
||||||
|
(flow) =>
|
||||||
|
(flow.type === 'm.login.sso' || flow.type === 'm.login.cas') &&
|
||||||
|
'identity_providers' in flow &&
|
||||||
|
Array.isArray(flow.identity_providers) &&
|
||||||
|
flow.identity_providers.length > 0
|
||||||
|
) as Required_SSOFlow | undefined;
|
||||||
|
|
||||||
|
export const getPasswordFlow = (loginFlows: LoginFlow[]): IPasswordFlow | undefined =>
|
||||||
|
loginFlows.find((flow) => flow.type === 'm.login.password') as IPasswordFlow;
|
||||||
|
export const getTokenFlow = (loginFlows: LoginFlow[]): LoginFlow | undefined =>
|
||||||
|
loginFlows.find((flow) => flow.type === 'm.login.token') as ILoginFlow & {
|
||||||
|
type: 'm.login.token';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ParsedLoginFlows = {
|
||||||
|
password?: LoginFlow;
|
||||||
|
token?: LoginFlow;
|
||||||
|
sso?: Required_SSOFlow;
|
||||||
|
};
|
||||||
|
export const useParsedLoginFlows = (loginFlows: LoginFlow[]) => {
|
||||||
|
const parsedFlow: ParsedLoginFlows = useMemo<ParsedLoginFlows>(
|
||||||
|
() => ({
|
||||||
|
password: getPasswordFlow(loginFlows),
|
||||||
|
token: getTokenFlow(loginFlows),
|
||||||
|
sso: getSSOFlow(loginFlows),
|
||||||
|
}),
|
||||||
|
[loginFlows]
|
||||||
|
);
|
||||||
|
|
||||||
|
return parsedFlow;
|
||||||
|
};
|
32
src/app/hooks/usePasswordEmail.ts
Normal file
32
src/app/hooks/usePasswordEmail.ts
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import { MatrixClient, MatrixError } from 'matrix-js-sdk';
|
||||||
|
import { useCallback, useRef } from 'react';
|
||||||
|
import { AsyncState, useAsyncCallback } from './useAsyncCallback';
|
||||||
|
import { RequestEmailTokenCallback, RequestEmailTokenResponse } from './types';
|
||||||
|
|
||||||
|
export const usePasswordEmail = (
|
||||||
|
mx: MatrixClient
|
||||||
|
): [AsyncState<RequestEmailTokenResponse, MatrixError>, RequestEmailTokenCallback] => {
|
||||||
|
const sendAttemptRef = useRef(1);
|
||||||
|
|
||||||
|
const passwordEmailCallback: RequestEmailTokenCallback = useCallback(
|
||||||
|
async (email, clientSecret, nextLink) => {
|
||||||
|
const sendAttempt = sendAttemptRef.current;
|
||||||
|
sendAttemptRef.current += 1;
|
||||||
|
const result = await mx.requestPasswordEmailToken(email, clientSecret, sendAttempt, nextLink);
|
||||||
|
return {
|
||||||
|
email,
|
||||||
|
clientSecret,
|
||||||
|
result,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[mx]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [passwordEmailState, passwordEmail] = useAsyncCallback<
|
||||||
|
RequestEmailTokenResponse,
|
||||||
|
MatrixError,
|
||||||
|
Parameters<RequestEmailTokenCallback>
|
||||||
|
>(passwordEmailCallback);
|
||||||
|
|
||||||
|
return [passwordEmailState, passwordEmail];
|
||||||
|
};
|
26
src/app/hooks/usePathWithOrigin.ts
Normal file
26
src/app/hooks/usePathWithOrigin.ts
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useClientConfig } from './useClientConfig';
|
||||||
|
import { trimLeadingSlash, trimSlash, trimTrailingSlash } from '../utils/common';
|
||||||
|
|
||||||
|
export const usePathWithOrigin = (path: string): string => {
|
||||||
|
const { hashRouter } = useClientConfig();
|
||||||
|
const { origin } = window.location;
|
||||||
|
|
||||||
|
const pathWithOrigin = useMemo(() => {
|
||||||
|
let url: string = trimSlash(origin);
|
||||||
|
|
||||||
|
url += `/${trimSlash(import.meta.env.BASE_URL ?? '')}`;
|
||||||
|
url = trimTrailingSlash(url);
|
||||||
|
|
||||||
|
if (hashRouter?.enabled) {
|
||||||
|
url += `/#/${trimSlash(hashRouter.basename ?? '')}`;
|
||||||
|
url = trimTrailingSlash(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
url += `/${trimLeadingSlash(path)}`;
|
||||||
|
|
||||||
|
return url;
|
||||||
|
}, [path, hashRouter, origin]);
|
||||||
|
|
||||||
|
return pathWithOrigin;
|
||||||
|
};
|
32
src/app/hooks/useRegisterEmail.ts
Normal file
32
src/app/hooks/useRegisterEmail.ts
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import { MatrixClient, MatrixError } from 'matrix-js-sdk';
|
||||||
|
import { useCallback, useRef } from 'react';
|
||||||
|
import { AsyncState, useAsyncCallback } from './useAsyncCallback';
|
||||||
|
import { RequestEmailTokenCallback, RequestEmailTokenResponse } from './types';
|
||||||
|
|
||||||
|
export const useRegisterEmail = (
|
||||||
|
mx: MatrixClient
|
||||||
|
): [AsyncState<RequestEmailTokenResponse, MatrixError>, RequestEmailTokenCallback] => {
|
||||||
|
const sendAttemptRef = useRef(1);
|
||||||
|
|
||||||
|
const registerEmailCallback: RequestEmailTokenCallback = useCallback(
|
||||||
|
async (email, clientSecret, nextLink) => {
|
||||||
|
const sendAttempt = sendAttemptRef.current;
|
||||||
|
sendAttemptRef.current += 1;
|
||||||
|
const result = await mx.requestRegisterEmailToken(email, clientSecret, sendAttempt, nextLink);
|
||||||
|
return {
|
||||||
|
email,
|
||||||
|
clientSecret,
|
||||||
|
result,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[mx]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [registerEmailState, registerEmail] = useAsyncCallback<
|
||||||
|
RequestEmailTokenResponse,
|
||||||
|
MatrixError,
|
||||||
|
Parameters<RequestEmailTokenCallback>
|
||||||
|
>(registerEmailCallback);
|
||||||
|
|
||||||
|
return [registerEmailState, registerEmail];
|
||||||
|
};
|
12
src/app/hooks/useSpecVersions.ts
Normal file
12
src/app/hooks/useSpecVersions.ts
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
import { SpecVersions } from '../cs-api';
|
||||||
|
|
||||||
|
const SpecVersionsContext = createContext<SpecVersions | null>(null);
|
||||||
|
|
||||||
|
export const SpecVersionsProvider = SpecVersionsContext.Provider;
|
||||||
|
|
||||||
|
export function useSpecVersions(): SpecVersions {
|
||||||
|
const versions = useContext(SpecVersionsContext);
|
||||||
|
if (!versions) throw new Error('Server versions are not provided!');
|
||||||
|
return versions;
|
||||||
|
}
|
96
src/app/hooks/useUIAFlows.ts
Normal file
96
src/app/hooks/useUIAFlows.ts
Normal file
|
@ -0,0 +1,96 @@
|
||||||
|
import { AuthType, IAuthData, UIAFlow } from 'matrix-js-sdk';
|
||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
getSupportedUIAFlows,
|
||||||
|
getUIACompleted,
|
||||||
|
getUIAError,
|
||||||
|
getUIAErrorCode,
|
||||||
|
getUIAParams,
|
||||||
|
getUIASession,
|
||||||
|
} from '../utils/matrix-uia';
|
||||||
|
|
||||||
|
export const SUPPORTED_FLOW_TYPES = [
|
||||||
|
AuthType.Dummy,
|
||||||
|
AuthType.Password,
|
||||||
|
AuthType.Email,
|
||||||
|
AuthType.Terms,
|
||||||
|
AuthType.Recaptcha,
|
||||||
|
AuthType.RegistrationToken,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const useSupportedUIAFlows = (uiaFlows: UIAFlow[], supportedStages: string[]): UIAFlow[] =>
|
||||||
|
useMemo(() => getSupportedUIAFlows(uiaFlows, supportedStages), [uiaFlows, supportedStages]);
|
||||||
|
|
||||||
|
export const useUIACompleted = (authData: IAuthData): string[] =>
|
||||||
|
useMemo(() => getUIACompleted(authData), [authData]);
|
||||||
|
|
||||||
|
export const useUIAParams = (authData: IAuthData) =>
|
||||||
|
useMemo(() => getUIAParams(authData), [authData]);
|
||||||
|
|
||||||
|
export const useUIASession = (authData: IAuthData) =>
|
||||||
|
useMemo(() => getUIASession(authData), [authData]);
|
||||||
|
|
||||||
|
export const useUIAErrorCode = (authData: IAuthData) =>
|
||||||
|
useMemo(() => getUIAErrorCode(authData), [authData]);
|
||||||
|
|
||||||
|
export const useUIAError = (authData: IAuthData) =>
|
||||||
|
useMemo(() => getUIAError(authData), [authData]);
|
||||||
|
|
||||||
|
export type StageInfo = Record<string, unknown>;
|
||||||
|
export type AuthStageData = {
|
||||||
|
type: string;
|
||||||
|
info?: StageInfo;
|
||||||
|
session?: string;
|
||||||
|
errorCode?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
export type AuthStageDataGetter = () => AuthStageData | undefined;
|
||||||
|
|
||||||
|
export type UIAFlowInterface = {
|
||||||
|
getStageToComplete: AuthStageDataGetter;
|
||||||
|
hasStage: (stageType: string) => boolean;
|
||||||
|
getStageInfo: (stageType: string) => StageInfo | undefined;
|
||||||
|
};
|
||||||
|
export const useUIAFlow = (authData: IAuthData, uiaFlow: UIAFlow): UIAFlowInterface => {
|
||||||
|
const completed = useUIACompleted(authData);
|
||||||
|
const params = useUIAParams(authData);
|
||||||
|
const session = useUIASession(authData);
|
||||||
|
const errorCode = useUIAErrorCode(authData);
|
||||||
|
const error = useUIAError(authData);
|
||||||
|
|
||||||
|
const getStageToComplete: AuthStageDataGetter = useCallback(() => {
|
||||||
|
const { stages } = uiaFlow;
|
||||||
|
const nextStage = stages.find((stage) => !completed.includes(stage));
|
||||||
|
if (!nextStage) return undefined;
|
||||||
|
|
||||||
|
const info = params[nextStage];
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: nextStage,
|
||||||
|
info,
|
||||||
|
session,
|
||||||
|
errorCode,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
}, [uiaFlow, completed, params, errorCode, error, session]);
|
||||||
|
|
||||||
|
const hasStage = useCallback(
|
||||||
|
(stageType: string): boolean => uiaFlow.stages.includes(stageType),
|
||||||
|
[uiaFlow]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getStageInfo = useCallback(
|
||||||
|
(stageType: string): StageInfo | undefined => {
|
||||||
|
if (!hasStage(stageType)) return undefined;
|
||||||
|
|
||||||
|
return params[stageType];
|
||||||
|
},
|
||||||
|
[hasStage, params]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
getStageToComplete,
|
||||||
|
hasStage,
|
||||||
|
getStageInfo,
|
||||||
|
};
|
||||||
|
};
|
|
@ -110,7 +110,9 @@ function RoomAliases({ roomId }) {
|
||||||
|
|
||||||
const canPublishAlias = room.currentState.maySendStateEvent('m.room.canonical_alias', userId);
|
const canPublishAlias = room.currentState.maySendStateEvent('m.room.canonical_alias', userId);
|
||||||
|
|
||||||
useEffect(() => isMountedStore.setItem(true), []);
|
useEffect(() => {
|
||||||
|
isMountedStore.setItem(true)
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isUnmounted = false;
|
let isUnmounted = false;
|
||||||
|
|
|
@ -49,7 +49,9 @@ function useVisibility(roomId) {
|
||||||
const room = mx.getRoom(roomId);
|
const room = mx.getRoom(roomId);
|
||||||
|
|
||||||
const [activeType, setActiveType] = useState(room.getHistoryVisibility());
|
const [activeType, setActiveType] = useState(room.getHistoryVisibility());
|
||||||
useEffect(() => setActiveType(room.getHistoryVisibility()), [roomId]);
|
useEffect(() => {
|
||||||
|
setActiveType(room.getHistoryVisibility());
|
||||||
|
}, [roomId]);
|
||||||
|
|
||||||
const setVisibility = useCallback((item) => {
|
const setVisibility = useCallback((item) => {
|
||||||
if (item.type === activeType.type) return;
|
if (item.type === activeType.type) return;
|
||||||
|
|
|
@ -103,7 +103,9 @@ function setRoomNotifType(roomId, newType) {
|
||||||
function useNotifications(roomId) {
|
function useNotifications(roomId) {
|
||||||
const { notifications } = initMatrix;
|
const { notifications } = initMatrix;
|
||||||
const [activeType, setActiveType] = useState(notifications.getNotiType(roomId));
|
const [activeType, setActiveType] = useState(notifications.getNotiType(roomId));
|
||||||
useEffect(() => setActiveType(notifications.getNotiType(roomId)), [roomId]);
|
useEffect(() => {
|
||||||
|
setActiveType(notifications.getNotiType(roomId));
|
||||||
|
}, [roomId]);
|
||||||
|
|
||||||
const setNotification = useCallback((item) => {
|
const setNotification = useCallback((item) => {
|
||||||
if (item.type === activeType.type) return;
|
if (item.type === activeType.type) return;
|
||||||
|
|
|
@ -29,7 +29,9 @@ function useRoomSearch(roomId) {
|
||||||
const mountStore = useStore(roomId);
|
const mountStore = useStore(roomId);
|
||||||
const mx = initMatrix.matrixClient;
|
const mx = initMatrix.matrixClient;
|
||||||
|
|
||||||
useEffect(() => mountStore.setItem(true), [roomId]);
|
useEffect(() => {
|
||||||
|
mountStore.setItem(true)
|
||||||
|
}, [roomId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (searchData?.results?.length > 0) {
|
if (searchData?.results?.length > 0) {
|
||||||
|
|
|
@ -50,7 +50,9 @@ function useVisibility(roomId) {
|
||||||
const room = mx.getRoom(roomId);
|
const room = mx.getRoom(roomId);
|
||||||
|
|
||||||
const [activeType, setActiveType] = useState(room.getJoinRule());
|
const [activeType, setActiveType] = useState(room.getJoinRule());
|
||||||
useEffect(() => setActiveType(room.getJoinRule()), [roomId]);
|
useEffect(() => {
|
||||||
|
setActiveType(room.getJoinRule());
|
||||||
|
}, [roomId]);
|
||||||
|
|
||||||
const setNotification = useCallback((item) => {
|
const setNotification = useCallback((item) => {
|
||||||
if (item.type === activeType.type) return;
|
if (item.type === activeType.type) return;
|
||||||
|
|
|
@ -80,7 +80,7 @@ function EmojiVerificationContent({ data, requestClose }) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (request === null) return null;
|
if (request === null) return undefined;
|
||||||
const req = request;
|
const req = request;
|
||||||
req.on('change', handleChange);
|
req.on('change', handleChange);
|
||||||
return () => {
|
return () => {
|
||||||
|
|
|
@ -62,7 +62,7 @@ function JoinAliasContent({ term, requestClose }) {
|
||||||
let via;
|
let via;
|
||||||
if (alias.startsWith('#')) {
|
if (alias.startsWith('#')) {
|
||||||
try {
|
try {
|
||||||
const aliasData = await mx.resolveRoomAlias(alias);
|
const aliasData = await mx.getRoomIdForAlias(alias);
|
||||||
via = aliasData?.servers.slice(0, 3) || [];
|
via = aliasData?.servers.slice(0, 3) || [];
|
||||||
if (mountStore.getItem()) {
|
if (mountStore.getItem()) {
|
||||||
setProcess(`Joining ${alias}...`);
|
setProcess(`Joining ${alias}...`);
|
||||||
|
|
|
@ -45,7 +45,8 @@ export const AudioContent = as<'div', AudioContentProps>(
|
||||||
|
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
// duration in seconds. (NOTE: info.duration is in milliseconds)
|
// duration in seconds. (NOTE: info.duration is in milliseconds)
|
||||||
const [duration, setDuration] = useState((info.duration ?? 0) / 1000);
|
const infoDuration = info.duration ?? 0;
|
||||||
|
const [duration, setDuration] = useState((infoDuration >= 0 ? infoDuration : 0) / 1000);
|
||||||
|
|
||||||
const getAudioRef = useCallback(() => audioRef.current, []);
|
const getAudioRef = useCallback(() => audioRef.current, []);
|
||||||
const { loading } = useMediaLoading(getAudioRef);
|
const { loading } = useMediaLoading(getAudioRef);
|
||||||
|
|
|
@ -23,7 +23,10 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
|
||||||
const [previewStatus, loadPreview] = useAsyncCallback(
|
const [previewStatus, loadPreview] = useAsyncCallback(
|
||||||
useCallback(() => mx.getUrlPreview(url, ts), [url, ts, mx])
|
useCallback(() => mx.getUrlPreview(url, ts), [url, ts, mx])
|
||||||
);
|
);
|
||||||
if (previewStatus.status === AsyncStatus.Idle) loadPreview();
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadPreview();
|
||||||
|
}, [loadPreview]);
|
||||||
|
|
||||||
if (previewStatus.status === AsyncStatus.Error) return null;
|
if (previewStatus.status === AsyncStatus.Error) return null;
|
||||||
|
|
||||||
|
|
|
@ -302,7 +302,9 @@ function SpaceManageContent({ roomId, requestClose }) {
|
||||||
};
|
};
|
||||||
}, [roomId]);
|
}, [roomId]);
|
||||||
|
|
||||||
useEffect(() => setSelected([]), [spacePath]);
|
useEffect(() => {
|
||||||
|
setSelected([]);
|
||||||
|
}, [spacePath]);
|
||||||
|
|
||||||
const handleSelected = (selectedRoomId) => {
|
const handleSelected = (selectedRoomId) => {
|
||||||
const newSelected = [...selected];
|
const newSelected = [...selected];
|
||||||
|
|
|
@ -1,17 +0,0 @@
|
||||||
import React, { StrictMode } from 'react';
|
|
||||||
import { Provider } from 'jotai';
|
|
||||||
|
|
||||||
import { isAuthenticated } from '../../client/state/auth';
|
|
||||||
|
|
||||||
import Auth from '../templates/auth/Auth';
|
|
||||||
import Client from '../templates/client/Client';
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
return (
|
|
||||||
<StrictMode>
|
|
||||||
<Provider>{isAuthenticated() ? <Client /> : <Auth />}</Provider>
|
|
||||||
</StrictMode>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App;
|
|
85
src/app/pages/App.tsx
Normal file
85
src/app/pages/App.tsx
Normal file
|
@ -0,0 +1,85 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Provider as JotaiProvider } from 'jotai';
|
||||||
|
import {
|
||||||
|
Route,
|
||||||
|
RouterProvider,
|
||||||
|
createBrowserRouter,
|
||||||
|
createHashRouter,
|
||||||
|
createRoutesFromElements,
|
||||||
|
redirect,
|
||||||
|
} from 'react-router-dom';
|
||||||
|
|
||||||
|
import { ClientConfigLoader } from '../components/ClientConfigLoader';
|
||||||
|
import { ClientConfig, ClientConfigProvider } from '../hooks/useClientConfig';
|
||||||
|
import { AuthLayout, Login, Register, ResetPassword, authLayoutLoader } from './auth';
|
||||||
|
import { LOGIN_PATH, REGISTER_PATH, RESET_PASSWORD_PATH, ROOT_PATH } from './paths';
|
||||||
|
import { isAuthenticated } from '../../client/state/auth';
|
||||||
|
import Client from '../templates/client/Client';
|
||||||
|
import { getLoginPath } from './pathUtils';
|
||||||
|
import { ConfigConfigError, ConfigConfigLoading } from './ConfigConfig';
|
||||||
|
import { FeatureCheck } from './FeatureCheck';
|
||||||
|
|
||||||
|
const createRouter = (clientConfig: ClientConfig) => {
|
||||||
|
const { hashRouter } = clientConfig;
|
||||||
|
|
||||||
|
const routes = createRoutesFromElements(
|
||||||
|
<Route>
|
||||||
|
<Route
|
||||||
|
path={ROOT_PATH}
|
||||||
|
loader={() => {
|
||||||
|
if (isAuthenticated()) return redirect('/home');
|
||||||
|
return redirect(getLoginPath());
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Route loader={authLayoutLoader} element={<AuthLayout />}>
|
||||||
|
<Route path={LOGIN_PATH} element={<Login />} />
|
||||||
|
<Route path={REGISTER_PATH} element={<Register />} />
|
||||||
|
<Route path={RESET_PASSWORD_PATH} element={<ResetPassword />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
loader={() => {
|
||||||
|
if (!isAuthenticated()) return redirect(getLoginPath());
|
||||||
|
return null;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Route path="/home" element={<Client />} />
|
||||||
|
<Route path="/direct" element={<p>direct</p>} />
|
||||||
|
<Route path="/:spaceIdOrAlias" element={<p>:spaceIdOrAlias</p>} />
|
||||||
|
<Route path="/explore" element={<p>explore</p>} />
|
||||||
|
</Route>
|
||||||
|
<Route path="/*" element={<p>Page not found</p>} />
|
||||||
|
</Route>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hashRouter?.enabled) {
|
||||||
|
return createHashRouter(routes, { basename: hashRouter.basename });
|
||||||
|
}
|
||||||
|
return createBrowserRouter(routes, {
|
||||||
|
basename: import.meta.env.BASE_URL,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO: app crash boundary
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<FeatureCheck>
|
||||||
|
<ClientConfigLoader
|
||||||
|
fallback={() => <ConfigConfigLoading />}
|
||||||
|
error={(err, retry, ignore) => (
|
||||||
|
<ConfigConfigError error={err} retry={retry} ignore={ignore} />
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{(clientConfig) => (
|
||||||
|
<ClientConfigProvider value={clientConfig}>
|
||||||
|
<JotaiProvider>
|
||||||
|
<RouterProvider router={createRouter(clientConfig)} />
|
||||||
|
</JotaiProvider>
|
||||||
|
</ClientConfigProvider>
|
||||||
|
)}
|
||||||
|
</ClientConfigLoader>
|
||||||
|
</FeatureCheck>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
53
src/app/pages/ConfigConfig.tsx
Normal file
53
src/app/pages/ConfigConfig.tsx
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
import { Box, Button, Dialog, Spinner, Text, color, config } from 'folds';
|
||||||
|
import React from 'react';
|
||||||
|
import { SplashScreen } from '../components/splash-screen';
|
||||||
|
|
||||||
|
export function ConfigConfigLoading() {
|
||||||
|
return (
|
||||||
|
<SplashScreen>
|
||||||
|
<Box grow="Yes" direction="Column" gap="400" alignItems="Center" justifyContent="Center">
|
||||||
|
<Spinner variant="Secondary" size="600" />
|
||||||
|
<Text>Heating up</Text>
|
||||||
|
</Box>
|
||||||
|
</SplashScreen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfigConfigErrorProps = {
|
||||||
|
error: unknown;
|
||||||
|
retry: () => void;
|
||||||
|
ignore: () => void;
|
||||||
|
};
|
||||||
|
export function ConfigConfigError({ error, retry, ignore }: ConfigConfigErrorProps) {
|
||||||
|
return (
|
||||||
|
<SplashScreen>
|
||||||
|
<Box grow="Yes" direction="Column" gap="400" alignItems="Center" justifyContent="Center">
|
||||||
|
<Dialog>
|
||||||
|
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text>Failed to load client configuration file.</Text>
|
||||||
|
{typeof error === 'object' &&
|
||||||
|
error &&
|
||||||
|
'message' in error &&
|
||||||
|
typeof error.message === 'string' && (
|
||||||
|
<Text size="T300" style={{ color: color.Critical.Main }}>
|
||||||
|
{error.message}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Button variant="Critical" onClick={retry}>
|
||||||
|
<Text as="span" size="B400">
|
||||||
|
Retry
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
<Button variant="Critical" onClick={ignore} fill="Soft">
|
||||||
|
<Text as="span" size="B400">
|
||||||
|
Continue
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
</SplashScreen>
|
||||||
|
);
|
||||||
|
}
|
42
src/app/pages/FeatureCheck.tsx
Normal file
42
src/app/pages/FeatureCheck.tsx
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
import React, { ReactNode, useEffect } from 'react';
|
||||||
|
import { Box, Dialog, Text, config } from 'folds';
|
||||||
|
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
||||||
|
import { checkIndexedDBSupport } from '../utils/featureCheck';
|
||||||
|
import { SplashScreen } from '../components/splash-screen';
|
||||||
|
|
||||||
|
export function FeatureCheck({ children }: { children: ReactNode }) {
|
||||||
|
const [idbSupportState, checkIDBSupport] = useAsyncCallback(checkIndexedDBSupport);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkIDBSupport();
|
||||||
|
}, [checkIDBSupport]);
|
||||||
|
|
||||||
|
if (idbSupportState.status === AsyncStatus.Success && idbSupportState.data === false) {
|
||||||
|
return (
|
||||||
|
<SplashScreen>
|
||||||
|
<Box grow="Yes" alignItems="Center" justifyContent="Center">
|
||||||
|
<Dialog>
|
||||||
|
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
||||||
|
<Text>Missing Browser Feature</Text>
|
||||||
|
<Text size="T300" priority="400">
|
||||||
|
No IndexedDB support found. This application requires IndexedDB to store session
|
||||||
|
data locally. Please make sure your browser support IndexedDB and have it enabled.
|
||||||
|
</Text>
|
||||||
|
<Text size="T200">
|
||||||
|
<a
|
||||||
|
href="https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
What is IndexedDB?
|
||||||
|
</a>
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
</SplashScreen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
28
src/app/pages/auth/AuthFooter.tsx
Normal file
28
src/app/pages/auth/AuthFooter.tsx
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Box, Text } from 'folds';
|
||||||
|
import * as css from './styles.css';
|
||||||
|
|
||||||
|
export function AuthFooter() {
|
||||||
|
return (
|
||||||
|
<Box className={css.AuthFooter} justifyContent="Center" gap="400" wrap="Wrap">
|
||||||
|
<Text as="a" size="T300" href="https://cinny.in" target="_blank" rel="noreferrer">
|
||||||
|
About
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
as="a"
|
||||||
|
size="T300"
|
||||||
|
href="https://github.com/ajbura/cinny/releases"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
v3.2.0
|
||||||
|
</Text>
|
||||||
|
<Text as="a" size="T300" href="https://twitter.com/cinnyapp" target="_blank" rel="noreferrer">
|
||||||
|
Twitter
|
||||||
|
</Text>
|
||||||
|
<Text as="a" size="T300" href="https://matrix.org" target="_blank" rel="noreferrer">
|
||||||
|
Powered by Matrix
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
215
src/app/pages/auth/AuthLayout.tsx
Normal file
215
src/app/pages/auth/AuthLayout.tsx
Normal file
|
@ -0,0 +1,215 @@
|
||||||
|
import React, { useCallback, useEffect } from 'react';
|
||||||
|
import { Box, Header, Scroll, Spinner, Text, color } from 'folds';
|
||||||
|
import {
|
||||||
|
LoaderFunction,
|
||||||
|
Outlet,
|
||||||
|
generatePath,
|
||||||
|
matchPath,
|
||||||
|
redirect,
|
||||||
|
useLocation,
|
||||||
|
useNavigate,
|
||||||
|
useParams,
|
||||||
|
} from 'react-router-dom';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import { AuthFooter } from './AuthFooter';
|
||||||
|
import * as css from './styles.css';
|
||||||
|
import * as PatternsCss from '../../styles/Patterns.css';
|
||||||
|
import { isAuthenticated } from '../../../client/state/auth';
|
||||||
|
import {
|
||||||
|
clientAllowedServer,
|
||||||
|
clientDefaultServer,
|
||||||
|
useClientConfig,
|
||||||
|
} from '../../hooks/useClientConfig';
|
||||||
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
|
import { LOGIN_PATH, REGISTER_PATH } from '../paths';
|
||||||
|
import CinnySVG from '../../../../public/res/svg/cinny.svg';
|
||||||
|
import { ServerPicker } from './ServerPicker';
|
||||||
|
import { AutoDiscoveryAction, autoDiscovery } from '../../cs-api';
|
||||||
|
import { SpecVersionsLoader } from '../../components/SpecVersionsLoader';
|
||||||
|
import { SpecVersionsProvider } from '../../hooks/useSpecVersions';
|
||||||
|
import { AutoDiscoveryInfoProvider } from '../../hooks/useAutoDiscoveryInfo';
|
||||||
|
import { AuthFlowsLoader } from '../../components/AuthFlowsLoader';
|
||||||
|
import { AuthFlowsProvider } from '../../hooks/useAuthFlows';
|
||||||
|
import { AuthServerProvider } from '../../hooks/useAuthServer';
|
||||||
|
|
||||||
|
export const authLayoutLoader: LoaderFunction = () => {
|
||||||
|
if (isAuthenticated()) {
|
||||||
|
return redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentAuthPath = (pathname: string): string => {
|
||||||
|
if (matchPath(LOGIN_PATH, pathname)) {
|
||||||
|
return LOGIN_PATH;
|
||||||
|
}
|
||||||
|
if (matchPath(REGISTER_PATH, pathname)) {
|
||||||
|
return REGISTER_PATH;
|
||||||
|
}
|
||||||
|
return LOGIN_PATH;
|
||||||
|
};
|
||||||
|
|
||||||
|
function AuthLayoutLoading({ message }: { message: string }) {
|
||||||
|
return (
|
||||||
|
<Box justifyContent="Center" alignItems="Center" gap="200">
|
||||||
|
<Spinner size="100" variant="Secondary" />
|
||||||
|
<Text align="Center" size="T300">
|
||||||
|
{message}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuthLayoutError({ message }: { message: string }) {
|
||||||
|
return (
|
||||||
|
<Box justifyContent="Center" alignItems="Center" gap="200">
|
||||||
|
<Text align="Center" style={{ color: color.Critical.Main }} size="T300">
|
||||||
|
{message}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthLayout() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const { server: urlEncodedServer } = useParams();
|
||||||
|
|
||||||
|
const clientConfig = useClientConfig();
|
||||||
|
|
||||||
|
const defaultServer = clientDefaultServer(clientConfig);
|
||||||
|
let server: string = urlEncodedServer ? decodeURIComponent(urlEncodedServer) : defaultServer;
|
||||||
|
|
||||||
|
if (!clientAllowedServer(clientConfig, server)) {
|
||||||
|
server = defaultServer;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [discoveryState, discoverServer] = useAsyncCallback(
|
||||||
|
useCallback(async (serverName: string) => {
|
||||||
|
const response = await autoDiscovery(fetch, serverName);
|
||||||
|
return {
|
||||||
|
serverName,
|
||||||
|
response,
|
||||||
|
};
|
||||||
|
}, [])
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (server) discoverServer(server);
|
||||||
|
}, [discoverServer, server]);
|
||||||
|
|
||||||
|
// if server is mismatches with path server, update path
|
||||||
|
useEffect(() => {
|
||||||
|
if (!urlEncodedServer || decodeURIComponent(urlEncodedServer) !== server) {
|
||||||
|
navigate(
|
||||||
|
generatePath(currentAuthPath(location.pathname), {
|
||||||
|
server: encodeURIComponent(server),
|
||||||
|
}),
|
||||||
|
{ replace: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [urlEncodedServer, navigate, location, server]);
|
||||||
|
|
||||||
|
const selectServer = useCallback(
|
||||||
|
(newServer: string) => {
|
||||||
|
if (newServer === server) {
|
||||||
|
if (discoveryState.status === AsyncStatus.Loading) return;
|
||||||
|
discoverServer(server);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigate(
|
||||||
|
generatePath(currentAuthPath(location.pathname), { server: encodeURIComponent(newServer) })
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[navigate, location, discoveryState, server, discoverServer]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [autoDiscoveryError, autoDiscoveryInfo] =
|
||||||
|
discoveryState.status === AsyncStatus.Success ? discoveryState.data.response : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Scroll variant="Background" visibility="Hover" size="300" hideTrack>
|
||||||
|
<Box
|
||||||
|
className={classNames(css.AuthLayout, PatternsCss.BackgroundDotPattern)}
|
||||||
|
direction="Column"
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="SpaceBetween"
|
||||||
|
gap="400"
|
||||||
|
>
|
||||||
|
<Box direction="Column" className={css.AuthCard}>
|
||||||
|
<Header className={css.AuthHeader} size="600" variant="Surface">
|
||||||
|
<Box grow="Yes" direction="Row" gap="300" alignItems="Center">
|
||||||
|
<img className={css.AuthLogo} src={CinnySVG} alt="Cinny Logo" />
|
||||||
|
<Text size="H3">Cinny</Text>
|
||||||
|
</Box>
|
||||||
|
</Header>
|
||||||
|
<Box className={css.AuthCardContent} direction="Column">
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text as="label" size="L400" priority="300">
|
||||||
|
Homeserver
|
||||||
|
</Text>
|
||||||
|
<ServerPicker
|
||||||
|
server={server}
|
||||||
|
serverList={clientConfig.homeserverList ?? []}
|
||||||
|
allowCustomServer={clientConfig.allowCustomHomeservers}
|
||||||
|
onServerChange={selectServer}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{discoveryState.status === AsyncStatus.Loading && (
|
||||||
|
<AuthLayoutLoading message="Looking for homeserver..." />
|
||||||
|
)}
|
||||||
|
{discoveryState.status === AsyncStatus.Error && (
|
||||||
|
<AuthLayoutError message="Failed to find homeserver." />
|
||||||
|
)}
|
||||||
|
{autoDiscoveryError?.action === AutoDiscoveryAction.FAIL_PROMPT && (
|
||||||
|
<AuthLayoutError
|
||||||
|
message={`Failed to connect. Homeserver configuration found with ${autoDiscoveryError.host} appears unusable.`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{autoDiscoveryError?.action === AutoDiscoveryAction.FAIL_ERROR && (
|
||||||
|
<AuthLayoutError message="Failed to connect. Homeserver configuration base_url appears invalid." />
|
||||||
|
)}
|
||||||
|
{discoveryState.status === AsyncStatus.Success && autoDiscoveryInfo && (
|
||||||
|
<AuthServerProvider value={discoveryState.data.serverName}>
|
||||||
|
<AutoDiscoveryInfoProvider value={autoDiscoveryInfo}>
|
||||||
|
<SpecVersionsLoader
|
||||||
|
fallback={() => (
|
||||||
|
<AuthLayoutLoading
|
||||||
|
message={`Connecting to ${autoDiscoveryInfo['m.homeserver'].base_url}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
error={() => (
|
||||||
|
<AuthLayoutError message="Failed to connect. Either homeserver is unavailable at this moment or does not exist." />
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{(specVersions) => (
|
||||||
|
<SpecVersionsProvider value={specVersions}>
|
||||||
|
<AuthFlowsLoader
|
||||||
|
fallback={() => (
|
||||||
|
<AuthLayoutLoading message="Loading authentication flow..." />
|
||||||
|
)}
|
||||||
|
error={() => (
|
||||||
|
<AuthLayoutError message="Failed to get authentication flow information." />
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{(authFlows) => (
|
||||||
|
<AuthFlowsProvider value={authFlows}>
|
||||||
|
<Outlet />
|
||||||
|
</AuthFlowsProvider>
|
||||||
|
)}
|
||||||
|
</AuthFlowsLoader>
|
||||||
|
</SpecVersionsProvider>
|
||||||
|
)}
|
||||||
|
</SpecVersionsLoader>
|
||||||
|
</AutoDiscoveryInfoProvider>
|
||||||
|
</AuthServerProvider>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<AuthFooter />
|
||||||
|
</Box>
|
||||||
|
</Scroll>
|
||||||
|
);
|
||||||
|
}
|
13
src/app/pages/auth/FiledError.tsx
Normal file
13
src/app/pages/auth/FiledError.tsx
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Box, Icon, Icons, color, Text } from 'folds';
|
||||||
|
|
||||||
|
export function FieldError({ message }: { message: string }) {
|
||||||
|
return (
|
||||||
|
<Box style={{ color: color.Critical.Main }} alignItems="Center" gap="100">
|
||||||
|
<Icon size="50" filled src={Icons.Warning} />
|
||||||
|
<Text size="T200">
|
||||||
|
<b>{message}</b>
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
12
src/app/pages/auth/OrDivider.tsx
Normal file
12
src/app/pages/auth/OrDivider.tsx
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Box, Line, Text } from 'folds';
|
||||||
|
|
||||||
|
export function OrDivider() {
|
||||||
|
return (
|
||||||
|
<Box gap="400" alignItems="Center">
|
||||||
|
<Line style={{ flexGrow: 1 }} direction="Horizontal" size="300" variant="Surface" />
|
||||||
|
<Text>OR</Text>
|
||||||
|
<Line style={{ flexGrow: 1 }} direction="Horizontal" size="300" variant="Surface" />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
68
src/app/pages/auth/SSOLogin.tsx
Normal file
68
src/app/pages/auth/SSOLogin.tsx
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
import { Avatar, AvatarImage, Box, Button, Text } from 'folds';
|
||||||
|
import { IIdentityProvider, createClient } from 'matrix-js-sdk';
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo';
|
||||||
|
|
||||||
|
type SSOLoginProps = {
|
||||||
|
providers: IIdentityProvider[];
|
||||||
|
asIcons?: boolean;
|
||||||
|
redirectUrl: string;
|
||||||
|
};
|
||||||
|
export function SSOLogin({ providers, redirectUrl, asIcons }: SSOLoginProps) {
|
||||||
|
const discovery = useAutoDiscoveryInfo();
|
||||||
|
const baseUrl = discovery['m.homeserver'].base_url;
|
||||||
|
const mx = useMemo(() => createClient({ baseUrl }), [baseUrl]);
|
||||||
|
|
||||||
|
const getSSOIdUrl = (ssoId: string): string => mx.getSsoLoginUrl(redirectUrl, 'sso', ssoId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box justifyContent="Center" gap="600" wrap="Wrap">
|
||||||
|
{providers.map((provider) => {
|
||||||
|
const { id, name, icon } = provider;
|
||||||
|
const iconUrl = icon && mx.mxcUrlToHttp(icon, 96, 96, 'crop', false);
|
||||||
|
|
||||||
|
const buttonTitle = `Continue with ${name}`;
|
||||||
|
|
||||||
|
if (iconUrl && asIcons) {
|
||||||
|
return (
|
||||||
|
<Avatar
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
key={id}
|
||||||
|
as="a"
|
||||||
|
href={getSSOIdUrl(id)}
|
||||||
|
aria-label={buttonTitle}
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
>
|
||||||
|
<AvatarImage src={iconUrl} alt={name} title={buttonTitle} />
|
||||||
|
</Avatar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
key={id}
|
||||||
|
as="a"
|
||||||
|
href={getSSOIdUrl(id)}
|
||||||
|
size="500"
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
outlined
|
||||||
|
before={
|
||||||
|
iconUrl && (
|
||||||
|
<Avatar size="200" radii="300">
|
||||||
|
<AvatarImage src={iconUrl} alt={name} />
|
||||||
|
</Avatar>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text align="Center" size="B500" truncate>
|
||||||
|
{buttonTitle}
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
140
src/app/pages/auth/ServerPicker.tsx
Normal file
140
src/app/pages/auth/ServerPicker.tsx
Normal file
|
@ -0,0 +1,140 @@
|
||||||
|
import React, {
|
||||||
|
ChangeEventHandler,
|
||||||
|
KeyboardEventHandler,
|
||||||
|
MouseEventHandler,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import {
|
||||||
|
Header,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Icons,
|
||||||
|
Input,
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
|
PopOut,
|
||||||
|
Text,
|
||||||
|
config,
|
||||||
|
} from 'folds';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
|
||||||
|
import { useDebounce } from '../../hooks/useDebounce';
|
||||||
|
|
||||||
|
export function ServerPicker({
|
||||||
|
server,
|
||||||
|
serverList,
|
||||||
|
allowCustomServer,
|
||||||
|
onServerChange,
|
||||||
|
}: {
|
||||||
|
server: string;
|
||||||
|
serverList: string[];
|
||||||
|
allowCustomServer?: boolean;
|
||||||
|
onServerChange: (server: string) => void;
|
||||||
|
}) {
|
||||||
|
const [serverMenu, setServerMenu] = useState(false);
|
||||||
|
const serverInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// sync input with it outside server changes
|
||||||
|
if (serverInputRef.current && serverInputRef.current.value !== server) {
|
||||||
|
serverInputRef.current.value = server;
|
||||||
|
}
|
||||||
|
}, [server]);
|
||||||
|
|
||||||
|
const debounceServerSelect = useDebounce(onServerChange, { wait: 700 });
|
||||||
|
|
||||||
|
const handleServerChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
|
||||||
|
const inputServer = evt.target.value.trim();
|
||||||
|
if (inputServer) debounceServerSelect(inputServer);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (evt) => {
|
||||||
|
if (evt.key === 'ArrowDown') {
|
||||||
|
evt.preventDefault();
|
||||||
|
setServerMenu(true);
|
||||||
|
}
|
||||||
|
if (evt.key === 'Enter') {
|
||||||
|
evt.preventDefault();
|
||||||
|
const inputServer = evt.currentTarget.value.trim();
|
||||||
|
if (inputServer) onServerChange(inputServer);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleServerSelect: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
|
const selectedServer = evt.currentTarget.getAttribute('data-server');
|
||||||
|
if (selectedServer) {
|
||||||
|
onServerChange(selectedServer);
|
||||||
|
}
|
||||||
|
setServerMenu(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
ref={serverInputRef}
|
||||||
|
style={{ paddingRight: config.space.S200 }}
|
||||||
|
variant={allowCustomServer ? 'Background' : 'Surface'}
|
||||||
|
outlined
|
||||||
|
defaultValue={server}
|
||||||
|
onChange={handleServerChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
size="500"
|
||||||
|
readOnly={!allowCustomServer}
|
||||||
|
onClick={allowCustomServer ? undefined : () => setServerMenu(true)}
|
||||||
|
after={
|
||||||
|
serverList.length === 0 || (serverList.length === 1 && !allowCustomServer) ? undefined : (
|
||||||
|
<PopOut
|
||||||
|
open={serverMenu}
|
||||||
|
position="Bottom"
|
||||||
|
align="End"
|
||||||
|
offset={4}
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: () => setServerMenu(false),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu>
|
||||||
|
<Header size="300" style={{ padding: `0 ${config.space.S200}` }}>
|
||||||
|
<Text size="L400">Homeserver List</Text>
|
||||||
|
</Header>
|
||||||
|
<div style={{ padding: config.space.S100, paddingTop: 0 }}>
|
||||||
|
{serverList?.map((serverName) => (
|
||||||
|
<MenuItem
|
||||||
|
key={serverName}
|
||||||
|
radii="300"
|
||||||
|
aria-pressed={serverName === server}
|
||||||
|
data-server={serverName}
|
||||||
|
onClick={handleServerSelect}
|
||||||
|
>
|
||||||
|
<Text>{serverName}</Text>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(anchorRef) => (
|
||||||
|
<IconButton
|
||||||
|
ref={anchorRef}
|
||||||
|
onClick={() => setServerMenu(true)}
|
||||||
|
variant={allowCustomServer ? 'Background' : 'Surface'}
|
||||||
|
size="300"
|
||||||
|
aria-pressed={serverMenu}
|
||||||
|
radii="300"
|
||||||
|
>
|
||||||
|
<Icon src={Icons.ChevronBottom} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</PopOut>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
4
src/app/pages/auth/index.ts
Normal file
4
src/app/pages/auth/index.ts
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
export * from './AuthLayout';
|
||||||
|
export * from './login';
|
||||||
|
export * from './register';
|
||||||
|
export * from './reset-password';
|
95
src/app/pages/auth/login/Login.tsx
Normal file
95
src/app/pages/auth/login/Login.tsx
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Box, Text, color } from 'folds';
|
||||||
|
import { Link, useSearchParams } from 'react-router-dom';
|
||||||
|
import { useAuthFlows } from '../../../hooks/useAuthFlows';
|
||||||
|
import { useAuthServer } from '../../../hooks/useAuthServer';
|
||||||
|
import { useParsedLoginFlows } from '../../../hooks/useParsedLoginFlows';
|
||||||
|
import { PasswordLoginForm } from './PasswordLoginForm';
|
||||||
|
import { SSOLogin } from '../SSOLogin';
|
||||||
|
import { TokenLogin } from './TokenLogin';
|
||||||
|
import { OrDivider } from '../OrDivider';
|
||||||
|
import { getLoginPath, getRegisterPath, withSearchParam } from '../../pathUtils';
|
||||||
|
import { usePathWithOrigin } from '../../../hooks/usePathWithOrigin';
|
||||||
|
import { LoginPathSearchParams } from '../../paths';
|
||||||
|
import { useClientConfig } from '../../../hooks/useClientConfig';
|
||||||
|
|
||||||
|
const getLoginTokenSearchParam = () => {
|
||||||
|
// when using hasRouter query params in existing route
|
||||||
|
// gets ignored by react-router, so we need to read it ourself
|
||||||
|
// we only need to read loginToken as it's the only param that
|
||||||
|
// is provided by external entity. example: SSO login
|
||||||
|
const parmas = new URLSearchParams(window.location.search);
|
||||||
|
const loginToken = parmas.get('loginToken');
|
||||||
|
return loginToken ?? undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLoginSearchParams = (searchParams: URLSearchParams): LoginPathSearchParams => ({
|
||||||
|
username: searchParams.get('username') ?? undefined,
|
||||||
|
email: searchParams.get('email') ?? undefined,
|
||||||
|
loginToken: searchParams.get('loginToken') ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function Login() {
|
||||||
|
const server = useAuthServer();
|
||||||
|
const { hashRouter } = useClientConfig();
|
||||||
|
const { loginFlows } = useAuthFlows();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const loginSearchParams = getLoginSearchParams(searchParams);
|
||||||
|
const ssoRedirectUrl = usePathWithOrigin(getLoginPath(server));
|
||||||
|
const loginTokenForHashRouter = getLoginTokenSearchParam();
|
||||||
|
const absoluteLoginPath = usePathWithOrigin(getLoginPath(server));
|
||||||
|
|
||||||
|
if (hashRouter?.enabled && loginTokenForHashRouter) {
|
||||||
|
window.location.replace(
|
||||||
|
withSearchParam(absoluteLoginPath, {
|
||||||
|
loginToken: loginTokenForHashRouter,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedFlows = useParsedLoginFlows(loginFlows.flows);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box direction="Column" gap="500">
|
||||||
|
<Text size="H2" priority="400">
|
||||||
|
Login
|
||||||
|
</Text>
|
||||||
|
{parsedFlows.token && loginSearchParams.loginToken && (
|
||||||
|
<TokenLogin token={loginSearchParams.loginToken} />
|
||||||
|
)}
|
||||||
|
{parsedFlows.password && (
|
||||||
|
<>
|
||||||
|
<PasswordLoginForm
|
||||||
|
defaultUsername={loginSearchParams.username}
|
||||||
|
defaultEmail={loginSearchParams.email}
|
||||||
|
/>
|
||||||
|
<span data-spacing-node />
|
||||||
|
{parsedFlows.sso && <OrDivider />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{parsedFlows.sso && (
|
||||||
|
<>
|
||||||
|
<SSOLogin
|
||||||
|
providers={parsedFlows.sso.identity_providers}
|
||||||
|
redirectUrl={ssoRedirectUrl}
|
||||||
|
asIcons={
|
||||||
|
parsedFlows.password !== undefined && parsedFlows.sso.identity_providers.length > 2
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span data-spacing-node />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!parsedFlows.password && !parsedFlows.sso && (
|
||||||
|
<>
|
||||||
|
<Text style={{ color: color.Critical.Main }}>
|
||||||
|
{`This client does not support login on "${server}" homeserver. Password and SSO based login method not found.`}
|
||||||
|
</Text>
|
||||||
|
<span data-spacing-node />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Text align="Center">
|
||||||
|
Do not have an account? <Link to={getRegisterPath(server)}>Register</Link>
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
272
src/app/pages/auth/login/PasswordLoginForm.tsx
Normal file
272
src/app/pages/auth/login/PasswordLoginForm.tsx
Normal file
|
@ -0,0 +1,272 @@
|
||||||
|
import React, { FormEventHandler, useCallback, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Header,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Icons,
|
||||||
|
Input,
|
||||||
|
Menu,
|
||||||
|
Overlay,
|
||||||
|
OverlayBackdrop,
|
||||||
|
OverlayCenter,
|
||||||
|
PopOut,
|
||||||
|
Spinner,
|
||||||
|
Text,
|
||||||
|
config,
|
||||||
|
} from 'folds';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { MatrixError } from 'matrix-js-sdk';
|
||||||
|
import { getMxIdLocalPart, getMxIdServer, isUserId } from '../../../utils/matrix';
|
||||||
|
import { EMAIL_REGEX } from '../../../utils/regex';
|
||||||
|
import { useAutoDiscoveryInfo } from '../../../hooks/useAutoDiscoveryInfo';
|
||||||
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
|
import { useAuthServer } from '../../../hooks/useAuthServer';
|
||||||
|
import { useClientConfig } from '../../../hooks/useClientConfig';
|
||||||
|
import {
|
||||||
|
CustomLoginResponse,
|
||||||
|
LoginError,
|
||||||
|
factoryGetBaseUrl,
|
||||||
|
login,
|
||||||
|
useLoginComplete,
|
||||||
|
} from './loginUtil';
|
||||||
|
import { PasswordInput } from '../../../components/password-input/PasswordInput';
|
||||||
|
import { FieldError } from '../FiledError';
|
||||||
|
import { getResetPasswordPath } from '../../pathUtils';
|
||||||
|
|
||||||
|
function UsernameHint({ server }: { server: string }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<PopOut
|
||||||
|
open={open}
|
||||||
|
position="Top"
|
||||||
|
align="End"
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: () => setOpen(false),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu>
|
||||||
|
<Header size="300" style={{ padding: `0 ${config.space.S200}` }}>
|
||||||
|
<Text size="L400">Hint</Text>
|
||||||
|
</Header>
|
||||||
|
<Box
|
||||||
|
style={{ padding: config.space.S200, paddingTop: 0 }}
|
||||||
|
direction="Column"
|
||||||
|
tabIndex={0}
|
||||||
|
gap="100"
|
||||||
|
>
|
||||||
|
<Text size="T300">
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
Username:
|
||||||
|
</Text>{' '}
|
||||||
|
johndoe
|
||||||
|
</Text>
|
||||||
|
<Text size="T300">
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
Matrix ID:
|
||||||
|
</Text>
|
||||||
|
{` @johndoe:${server}`}
|
||||||
|
</Text>
|
||||||
|
<Text size="T300">
|
||||||
|
<Text as="span" size="Inherit" priority="300">
|
||||||
|
Email:
|
||||||
|
</Text>
|
||||||
|
{` johndoe@${server}`}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(targetRef) => (
|
||||||
|
<IconButton
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
ref={targetRef}
|
||||||
|
type="button"
|
||||||
|
variant="Background"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
aria-pressed={open}
|
||||||
|
>
|
||||||
|
<Icon style={{ opacity: config.opacity.P300 }} size="100" src={Icons.Info} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</PopOut>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type PasswordLoginFormProps = {
|
||||||
|
defaultUsername?: string;
|
||||||
|
defaultEmail?: string;
|
||||||
|
};
|
||||||
|
export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLoginFormProps) {
|
||||||
|
const server = useAuthServer();
|
||||||
|
const clientConfig = useClientConfig();
|
||||||
|
|
||||||
|
const serverDiscovery = useAutoDiscoveryInfo();
|
||||||
|
const baseUrl = serverDiscovery['m.homeserver'].base_url;
|
||||||
|
|
||||||
|
const [loginState, startLogin] = useAsyncCallback<
|
||||||
|
CustomLoginResponse,
|
||||||
|
MatrixError,
|
||||||
|
Parameters<typeof login>
|
||||||
|
>(useCallback(login, []));
|
||||||
|
|
||||||
|
useLoginComplete(loginState.status === AsyncStatus.Success ? loginState.data : undefined);
|
||||||
|
|
||||||
|
const handleUsernameLogin = (username: string, password: string) => {
|
||||||
|
startLogin(baseUrl, {
|
||||||
|
type: 'm.login.password',
|
||||||
|
identifier: {
|
||||||
|
type: 'm.id.user',
|
||||||
|
user: username,
|
||||||
|
},
|
||||||
|
password,
|
||||||
|
initial_device_display_name: 'Cinny Web',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMxIdLogin = async (mxId: string, password: string) => {
|
||||||
|
const mxIdServer = getMxIdServer(mxId);
|
||||||
|
const mxIdUsername = getMxIdLocalPart(mxId);
|
||||||
|
if (!mxIdServer || !mxIdUsername) return;
|
||||||
|
|
||||||
|
const getBaseUrl = factoryGetBaseUrl(clientConfig, mxIdServer);
|
||||||
|
|
||||||
|
startLogin(getBaseUrl, {
|
||||||
|
type: 'm.login.password',
|
||||||
|
identifier: {
|
||||||
|
type: 'm.id.user',
|
||||||
|
user: mxIdUsername,
|
||||||
|
},
|
||||||
|
password,
|
||||||
|
initial_device_display_name: 'Cinny Web',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const handleEmailLogin = (email: string, password: string) => {
|
||||||
|
startLogin(baseUrl, {
|
||||||
|
type: 'm.login.password',
|
||||||
|
identifier: {
|
||||||
|
type: 'm.id.thirdparty',
|
||||||
|
medium: 'email',
|
||||||
|
address: email,
|
||||||
|
},
|
||||||
|
password,
|
||||||
|
initial_device_display_name: 'Cinny Web',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
const { usernameInput, passwordInput } = evt.target as HTMLFormElement & {
|
||||||
|
usernameInput: HTMLInputElement;
|
||||||
|
passwordInput: HTMLInputElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
const username = usernameInput.value.trim();
|
||||||
|
const password = passwordInput.value;
|
||||||
|
if (!username) {
|
||||||
|
usernameInput.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!password) {
|
||||||
|
passwordInput.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isUserId(username)) {
|
||||||
|
handleMxIdLogin(username, password);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (EMAIL_REGEX.test(username)) {
|
||||||
|
handleEmailLogin(username, password);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleUsernameLogin(username, password);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box as="form" onSubmit={handleSubmit} direction="Inherit" gap="400">
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text as="label" size="L400" priority="300">
|
||||||
|
Username
|
||||||
|
</Text>
|
||||||
|
<Input
|
||||||
|
defaultValue={defaultUsername ?? defaultEmail}
|
||||||
|
style={{ paddingRight: config.space.S300 }}
|
||||||
|
name="usernameInput"
|
||||||
|
variant="Background"
|
||||||
|
size="500"
|
||||||
|
required
|
||||||
|
outlined
|
||||||
|
after={<UsernameHint server={server} />}
|
||||||
|
/>
|
||||||
|
{loginState.status === AsyncStatus.Error && (
|
||||||
|
<>
|
||||||
|
{loginState.error.errcode === LoginError.ServerNotAllowed && (
|
||||||
|
<FieldError message="Login with custom server not allowed by your client instance." />
|
||||||
|
)}
|
||||||
|
{loginState.error.errcode === LoginError.InvalidServer && (
|
||||||
|
<FieldError message="Failed to find your Matrix ID server." />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text as="label" size="L400" priority="300">
|
||||||
|
Password
|
||||||
|
</Text>
|
||||||
|
<PasswordInput name="passwordInput" variant="Background" size="500" outlined required />
|
||||||
|
<Box alignItems="Start" justifyContent="SpaceBetween" gap="200">
|
||||||
|
{loginState.status === AsyncStatus.Error && (
|
||||||
|
<>
|
||||||
|
{loginState.error.errcode === LoginError.Forbidden && (
|
||||||
|
<FieldError message="Invalid Username or Password." />
|
||||||
|
)}
|
||||||
|
{loginState.error.errcode === LoginError.UserDeactivated && (
|
||||||
|
<FieldError message="This account has been deactivated." />
|
||||||
|
)}
|
||||||
|
{loginState.error.errcode === LoginError.InvalidRequest && (
|
||||||
|
<FieldError message="Failed to login. Part of your request data is invalid." />
|
||||||
|
)}
|
||||||
|
{loginState.error.errcode === LoginError.RateLimited && (
|
||||||
|
<FieldError message="Failed to login. Your login request has been rate-limited by server, Please try after some time." />
|
||||||
|
)}
|
||||||
|
{loginState.error.errcode === LoginError.Unknown && (
|
||||||
|
<FieldError message="Failed to login. Unknown reason." />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Box grow="Yes" shrink="No" justifyContent="End">
|
||||||
|
<Text as="span" size="T200" priority="400" align="Right">
|
||||||
|
<Link to={getResetPasswordPath(server)}>Forget Password?</Link>
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Button type="submit" variant="Primary" size="500">
|
||||||
|
<Text as="span" size="B500">
|
||||||
|
Login
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Overlay
|
||||||
|
open={
|
||||||
|
loginState.status === AsyncStatus.Loading || loginState.status === AsyncStatus.Success
|
||||||
|
}
|
||||||
|
backdrop={<OverlayBackdrop />}
|
||||||
|
>
|
||||||
|
<OverlayCenter>
|
||||||
|
<Spinner variant="Secondary" size="600" />
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
94
src/app/pages/auth/login/TokenLogin.tsx
Normal file
94
src/app/pages/auth/login/TokenLogin.tsx
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Icon,
|
||||||
|
Icons,
|
||||||
|
Overlay,
|
||||||
|
OverlayBackdrop,
|
||||||
|
OverlayCenter,
|
||||||
|
Spinner,
|
||||||
|
Text,
|
||||||
|
color,
|
||||||
|
config,
|
||||||
|
} from 'folds';
|
||||||
|
import React, { useCallback, useEffect } from 'react';
|
||||||
|
import { MatrixError } from 'matrix-js-sdk';
|
||||||
|
import { useAutoDiscoveryInfo } from '../../../hooks/useAutoDiscoveryInfo';
|
||||||
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
|
import { CustomLoginResponse, LoginError, login, useLoginComplete } from './loginUtil';
|
||||||
|
|
||||||
|
function LoginTokenError({ message }: { message: string }) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
backgroundColor: color.Critical.Container,
|
||||||
|
color: color.Critical.OnContainer,
|
||||||
|
padding: config.space.S300,
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
}}
|
||||||
|
justifyContent="Start"
|
||||||
|
alignItems="Start"
|
||||||
|
gap="300"
|
||||||
|
>
|
||||||
|
<Icon size="300" filled src={Icons.Warning} />
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="L400">Token Login</Text>
|
||||||
|
<Text size="T300">
|
||||||
|
<b>{message}</b>
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type TokenLoginProps = {
|
||||||
|
token: string;
|
||||||
|
};
|
||||||
|
export function TokenLogin({ token }: TokenLoginProps) {
|
||||||
|
const discovery = useAutoDiscoveryInfo();
|
||||||
|
const baseUrl = discovery['m.homeserver'].base_url;
|
||||||
|
|
||||||
|
const [loginState, startLogin] = useAsyncCallback<
|
||||||
|
CustomLoginResponse,
|
||||||
|
MatrixError,
|
||||||
|
Parameters<typeof login>
|
||||||
|
>(useCallback(login, []));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
startLogin(baseUrl, {
|
||||||
|
type: 'm.login.token',
|
||||||
|
token,
|
||||||
|
initial_device_display_name: 'Cinny Web',
|
||||||
|
});
|
||||||
|
}, [baseUrl, token, startLogin]);
|
||||||
|
|
||||||
|
useLoginComplete(loginState.status === AsyncStatus.Success ? loginState.data : undefined);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{loginState.status === AsyncStatus.Error && (
|
||||||
|
<>
|
||||||
|
{loginState.error.errcode === LoginError.Forbidden && (
|
||||||
|
<LoginTokenError message="Invalid login token." />
|
||||||
|
)}
|
||||||
|
{loginState.error.errcode === LoginError.UserDeactivated && (
|
||||||
|
<LoginTokenError message="This account has been deactivated." />
|
||||||
|
)}
|
||||||
|
{loginState.error.errcode === LoginError.InvalidRequest && (
|
||||||
|
<LoginTokenError message="Failed to login. Part of your request data is invalid." />
|
||||||
|
)}
|
||||||
|
{loginState.error.errcode === LoginError.RateLimited && (
|
||||||
|
<LoginTokenError message="Failed to login. Your login request has been rate-limited by server, Please try after some time." />
|
||||||
|
)}
|
||||||
|
{loginState.error.errcode === LoginError.Unknown && (
|
||||||
|
<LoginTokenError message="Failed to login. Unknown reason." />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Overlay open={loginState.status !== AsyncStatus.Error} backdrop={<OverlayBackdrop />}>
|
||||||
|
<OverlayCenter>
|
||||||
|
<Spinner size="600" variant="Secondary" />
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
1
src/app/pages/auth/login/index.ts
Normal file
1
src/app/pages/auth/login/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export * from './Login';
|
118
src/app/pages/auth/login/loginUtil.ts
Normal file
118
src/app/pages/auth/login/loginUtil.ts
Normal file
|
@ -0,0 +1,118 @@
|
||||||
|
import to from 'await-to-js';
|
||||||
|
import { LoginRequest, LoginResponse, MatrixError, createClient } from 'matrix-js-sdk';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { ClientConfig, clientAllowedServer } from '../../../hooks/useClientConfig';
|
||||||
|
import { autoDiscovery, specVersions } from '../../../cs-api';
|
||||||
|
import { updateLocalStore } from '../../../../client/action/auth';
|
||||||
|
import { ROOT_PATH } from '../../paths';
|
||||||
|
import { ErrorCode } from '../../../cs-errorcode';
|
||||||
|
|
||||||
|
export enum GetBaseUrlError {
|
||||||
|
NotAllow = 'NotAllow',
|
||||||
|
NotFound = 'NotFound',
|
||||||
|
}
|
||||||
|
export const factoryGetBaseUrl = (clientConfig: ClientConfig, server: string) => {
|
||||||
|
const getBaseUrl = async (): Promise<string> => {
|
||||||
|
if (!clientAllowedServer(clientConfig, server)) {
|
||||||
|
throw new Error(GetBaseUrlError.NotAllow);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, discovery] = await to(autoDiscovery(fetch, server));
|
||||||
|
|
||||||
|
let mxIdBaseUrl: string | undefined;
|
||||||
|
const [, discoveryInfo] = discovery ?? [];
|
||||||
|
|
||||||
|
if (discoveryInfo) {
|
||||||
|
mxIdBaseUrl = discoveryInfo['m.homeserver'].base_url;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mxIdBaseUrl) {
|
||||||
|
throw new Error(GetBaseUrlError.NotFound);
|
||||||
|
}
|
||||||
|
const [, versions] = await to(specVersions(fetch, mxIdBaseUrl));
|
||||||
|
if (!versions) {
|
||||||
|
throw new Error(GetBaseUrlError.NotFound);
|
||||||
|
}
|
||||||
|
return mxIdBaseUrl;
|
||||||
|
};
|
||||||
|
return getBaseUrl;
|
||||||
|
};
|
||||||
|
|
||||||
|
export enum LoginError {
|
||||||
|
ServerNotAllowed = 'ServerNotAllowed',
|
||||||
|
InvalidServer = 'InvalidServer',
|
||||||
|
Forbidden = 'Forbidden',
|
||||||
|
UserDeactivated = 'UserDeactivated',
|
||||||
|
InvalidRequest = 'InvalidRequest',
|
||||||
|
RateLimited = 'RateLimited',
|
||||||
|
Unknown = 'Unknown',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CustomLoginResponse = {
|
||||||
|
baseUrl: string;
|
||||||
|
response: LoginResponse;
|
||||||
|
};
|
||||||
|
export const login = async (
|
||||||
|
serverBaseUrl: string | (() => Promise<string>),
|
||||||
|
data: LoginRequest
|
||||||
|
): Promise<CustomLoginResponse> => {
|
||||||
|
const [urlError, url] =
|
||||||
|
typeof serverBaseUrl === 'function' ? await to(serverBaseUrl()) : [undefined, serverBaseUrl];
|
||||||
|
if (urlError) {
|
||||||
|
throw new MatrixError({
|
||||||
|
errcode:
|
||||||
|
urlError.message === GetBaseUrlError.NotAllow
|
||||||
|
? LoginError.ServerNotAllowed
|
||||||
|
: LoginError.InvalidServer,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const mx = createClient({ baseUrl: url });
|
||||||
|
const [err, res] = await to<LoginResponse, MatrixError>(mx.login(data.type, data));
|
||||||
|
|
||||||
|
if (err) {
|
||||||
|
if (err.httpStatus === 400) {
|
||||||
|
throw new MatrixError({
|
||||||
|
errcode: LoginError.InvalidRequest,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (err.httpStatus === 429) {
|
||||||
|
throw new MatrixError({
|
||||||
|
errcode: LoginError.RateLimited,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (err.errcode === ErrorCode.M_USER_DEACTIVATED) {
|
||||||
|
throw new MatrixError({
|
||||||
|
errcode: LoginError.UserDeactivated,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err.httpStatus === 403) {
|
||||||
|
throw new MatrixError({
|
||||||
|
errcode: LoginError.Forbidden,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new MatrixError({
|
||||||
|
errcode: LoginError.Unknown,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
baseUrl: url,
|
||||||
|
response: res,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useLoginComplete = (data?: CustomLoginResponse) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data) {
|
||||||
|
const { response: loginRes, baseUrl: loginBaseUrl } = data;
|
||||||
|
updateLocalStore(loginRes.access_token, loginRes.device_id, loginRes.user_id, loginBaseUrl);
|
||||||
|
// TODO: add after login redirect url
|
||||||
|
navigate(ROOT_PATH, { replace: true });
|
||||||
|
}
|
||||||
|
}, [data, navigate]);
|
||||||
|
};
|
420
src/app/pages/auth/register/PasswordRegisterForm.tsx
Normal file
420
src/app/pages/auth/register/PasswordRegisterForm.tsx
Normal file
|
@ -0,0 +1,420 @@
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Input,
|
||||||
|
Overlay,
|
||||||
|
OverlayBackdrop,
|
||||||
|
OverlayCenter,
|
||||||
|
Spinner,
|
||||||
|
Text,
|
||||||
|
color,
|
||||||
|
} from 'folds';
|
||||||
|
import React, { ChangeEventHandler, useCallback, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
AuthDict,
|
||||||
|
AuthType,
|
||||||
|
IAuthData,
|
||||||
|
MatrixError,
|
||||||
|
RegisterRequest,
|
||||||
|
UIAFlow,
|
||||||
|
createClient,
|
||||||
|
} from 'matrix-js-sdk';
|
||||||
|
import { PasswordInput } from '../../../components/password-input/PasswordInput';
|
||||||
|
import {
|
||||||
|
getLoginTermUrl,
|
||||||
|
getUIAFlowForStages,
|
||||||
|
hasStageInFlows,
|
||||||
|
requiredStageInFlows,
|
||||||
|
} from '../../../utils/matrix-uia';
|
||||||
|
import { useUIACompleted, useUIAFlow, useUIAParams } from '../../../hooks/useUIAFlows';
|
||||||
|
import { AsyncState, AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
|
import { useAutoDiscoveryInfo } from '../../../hooks/useAutoDiscoveryInfo';
|
||||||
|
import { RegisterError, RegisterResult, register, useRegisterComplete } from './registerUtil';
|
||||||
|
import { FieldError } from '../FiledError';
|
||||||
|
import {
|
||||||
|
AutoDummyStageDialog,
|
||||||
|
AutoTermsStageDialog,
|
||||||
|
EmailStageDialog,
|
||||||
|
ReCaptchaStageDialog,
|
||||||
|
RegistrationTokenStageDialog,
|
||||||
|
} from '../../../components/uia-stages';
|
||||||
|
import { useRegisterEmail } from '../../../hooks/useRegisterEmail';
|
||||||
|
import { ConfirmPasswordMatch } from '../../../components/ConfirmPasswordMatch';
|
||||||
|
import { UIAFlowOverlay } from '../../../components/UIAFlowOverlay';
|
||||||
|
import { RequestEmailTokenCallback, RequestEmailTokenResponse } from '../../../hooks/types';
|
||||||
|
|
||||||
|
export const SUPPORTED_REGISTER_STAGES = [
|
||||||
|
AuthType.RegistrationToken,
|
||||||
|
AuthType.Terms,
|
||||||
|
AuthType.Recaptcha,
|
||||||
|
AuthType.Email,
|
||||||
|
AuthType.Dummy,
|
||||||
|
];
|
||||||
|
type RegisterFormInputs = {
|
||||||
|
usernameInput: HTMLInputElement;
|
||||||
|
passwordInput: HTMLInputElement;
|
||||||
|
confirmPasswordInput: HTMLInputElement;
|
||||||
|
tokenInput?: HTMLInputElement;
|
||||||
|
emailInput?: HTMLInputElement;
|
||||||
|
termsInput?: HTMLInputElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FormData = {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
token?: string;
|
||||||
|
email?: string;
|
||||||
|
terms?: boolean;
|
||||||
|
clientSecret: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickStages = (uiaFlows: UIAFlow[], formData: FormData): string[] => {
|
||||||
|
const pickedStages: string[] = [];
|
||||||
|
if (formData.token) pickedStages.push(AuthType.RegistrationToken);
|
||||||
|
if (formData.email) pickedStages.push(AuthType.Email);
|
||||||
|
if (formData.terms) pickedStages.push(AuthType.Terms);
|
||||||
|
if (hasStageInFlows(uiaFlows, AuthType.Recaptcha)) {
|
||||||
|
pickedStages.push(AuthType.Recaptcha);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pickedStages;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RegisterUIAFlowProps = {
|
||||||
|
formData: FormData;
|
||||||
|
flow: UIAFlow;
|
||||||
|
authData: IAuthData;
|
||||||
|
registerEmailState: AsyncState<RequestEmailTokenResponse, MatrixError>;
|
||||||
|
registerEmail: RequestEmailTokenCallback;
|
||||||
|
onRegister: (registerReqData: RegisterRequest) => void;
|
||||||
|
};
|
||||||
|
function RegisterUIAFlow({
|
||||||
|
formData,
|
||||||
|
flow,
|
||||||
|
authData,
|
||||||
|
registerEmailState,
|
||||||
|
registerEmail,
|
||||||
|
onRegister,
|
||||||
|
}: RegisterUIAFlowProps) {
|
||||||
|
const completed = useUIACompleted(authData);
|
||||||
|
const { getStageToComplete } = useUIAFlow(authData, flow);
|
||||||
|
|
||||||
|
const stageToComplete = getStageToComplete();
|
||||||
|
|
||||||
|
const handleAuthDict = useCallback(
|
||||||
|
(authDict: AuthDict) => {
|
||||||
|
const { password, username } = formData;
|
||||||
|
onRegister({
|
||||||
|
auth: authDict,
|
||||||
|
password,
|
||||||
|
username,
|
||||||
|
initial_device_display_name: 'Cinny Web',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[onRegister, formData]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCancel = useCallback(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!stageToComplete) return null;
|
||||||
|
return (
|
||||||
|
<UIAFlowOverlay
|
||||||
|
currentStep={completed.length + 1}
|
||||||
|
stepCount={flow.stages.length}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
>
|
||||||
|
{stageToComplete.type === AuthType.RegistrationToken && (
|
||||||
|
<RegistrationTokenStageDialog
|
||||||
|
token={formData.token}
|
||||||
|
stageData={stageToComplete}
|
||||||
|
submitAuthDict={handleAuthDict}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{stageToComplete.type === AuthType.Terms && (
|
||||||
|
<AutoTermsStageDialog
|
||||||
|
stageData={stageToComplete}
|
||||||
|
submitAuthDict={handleAuthDict}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{stageToComplete.type === AuthType.Recaptcha && (
|
||||||
|
<ReCaptchaStageDialog
|
||||||
|
stageData={stageToComplete}
|
||||||
|
submitAuthDict={handleAuthDict}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{stageToComplete.type === AuthType.Email && (
|
||||||
|
<EmailStageDialog
|
||||||
|
email={formData.email}
|
||||||
|
clientSecret={formData.clientSecret}
|
||||||
|
stageData={stageToComplete}
|
||||||
|
requestEmailToken={registerEmail}
|
||||||
|
emailTokenState={registerEmailState}
|
||||||
|
submitAuthDict={handleAuthDict}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{stageToComplete.type === AuthType.Dummy && (
|
||||||
|
<AutoDummyStageDialog
|
||||||
|
stageData={stageToComplete}
|
||||||
|
submitAuthDict={handleAuthDict}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</UIAFlowOverlay>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type PasswordRegisterFormProps = {
|
||||||
|
authData: IAuthData;
|
||||||
|
uiaFlows: UIAFlow[];
|
||||||
|
defaultUsername?: string;
|
||||||
|
defaultEmail?: string;
|
||||||
|
defaultRegisterToken?: string;
|
||||||
|
};
|
||||||
|
export function PasswordRegisterForm({
|
||||||
|
authData,
|
||||||
|
uiaFlows,
|
||||||
|
defaultUsername,
|
||||||
|
defaultEmail,
|
||||||
|
defaultRegisterToken,
|
||||||
|
}: PasswordRegisterFormProps) {
|
||||||
|
const serverDiscovery = useAutoDiscoveryInfo();
|
||||||
|
const baseUrl = serverDiscovery['m.homeserver'].base_url;
|
||||||
|
const mx = useMemo(() => createClient({ baseUrl }), [baseUrl]);
|
||||||
|
const params = useUIAParams(authData);
|
||||||
|
const termUrl = getLoginTermUrl(params);
|
||||||
|
const [formData, setFormData] = useState<FormData>();
|
||||||
|
|
||||||
|
const [ongoingFlow, setOngoingFlow] = useState<UIAFlow>();
|
||||||
|
|
||||||
|
const [registerEmailState, registerEmail] = useRegisterEmail(mx);
|
||||||
|
|
||||||
|
const [registerState, handleRegister] = useAsyncCallback<
|
||||||
|
RegisterResult,
|
||||||
|
MatrixError,
|
||||||
|
[RegisterRequest]
|
||||||
|
>(useCallback(async (registerReqData) => register(mx, registerReqData), [mx]));
|
||||||
|
const [ongoingAuthData, customRegisterResp] =
|
||||||
|
registerState.status === AsyncStatus.Success ? registerState.data : [];
|
||||||
|
const registerError =
|
||||||
|
registerState.status === AsyncStatus.Error ? registerState.error : undefined;
|
||||||
|
|
||||||
|
useRegisterComplete(customRegisterResp);
|
||||||
|
|
||||||
|
const handleSubmit: ChangeEventHandler<HTMLFormElement> = (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
const {
|
||||||
|
usernameInput,
|
||||||
|
passwordInput,
|
||||||
|
confirmPasswordInput,
|
||||||
|
emailInput,
|
||||||
|
tokenInput,
|
||||||
|
termsInput,
|
||||||
|
} = evt.target as HTMLFormElement & RegisterFormInputs;
|
||||||
|
const token = tokenInput?.value.trim();
|
||||||
|
const username = usernameInput.value.trim();
|
||||||
|
const password = passwordInput.value;
|
||||||
|
const confirmPassword = confirmPasswordInput.value;
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const email = emailInput?.value.trim();
|
||||||
|
const terms = termsInput?.value === 'on';
|
||||||
|
|
||||||
|
if (!username) {
|
||||||
|
usernameInput.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fData: FormData = {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
token,
|
||||||
|
email,
|
||||||
|
terms,
|
||||||
|
clientSecret: mx.generateClientSecret(),
|
||||||
|
};
|
||||||
|
const pickedStages = pickStages(uiaFlows, fData);
|
||||||
|
const pickedFlow = getUIAFlowForStages(uiaFlows, pickedStages);
|
||||||
|
setOngoingFlow(pickedFlow);
|
||||||
|
setFormData(fData);
|
||||||
|
handleRegister({
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
auth: {
|
||||||
|
session: authData.session,
|
||||||
|
},
|
||||||
|
initial_device_display_name: 'Cinny Web',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box as="form" onSubmit={handleSubmit} direction="Inherit" gap="400">
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text as="label" size="L400" priority="300">
|
||||||
|
Username
|
||||||
|
</Text>
|
||||||
|
<Input
|
||||||
|
variant="Background"
|
||||||
|
defaultValue={defaultUsername}
|
||||||
|
name="usernameInput"
|
||||||
|
size="500"
|
||||||
|
outlined
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{registerError?.errcode === RegisterError.UserTaken && (
|
||||||
|
<FieldError message="This username is already taken." />
|
||||||
|
)}
|
||||||
|
{registerError?.errcode === RegisterError.UserInvalid && (
|
||||||
|
<FieldError message="This username contains invalid characters." />
|
||||||
|
)}
|
||||||
|
{registerError?.errcode === RegisterError.UserExclusive && (
|
||||||
|
<FieldError message="This username is reserved." />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<ConfirmPasswordMatch initialValue>
|
||||||
|
{(match, doMatch, passRef, confPassRef) => (
|
||||||
|
<>
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text as="label" size="L400" priority="300">
|
||||||
|
Password
|
||||||
|
</Text>
|
||||||
|
<PasswordInput
|
||||||
|
ref={passRef}
|
||||||
|
onChange={doMatch}
|
||||||
|
name="passwordInput"
|
||||||
|
variant="Background"
|
||||||
|
size="500"
|
||||||
|
outlined
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{registerError?.errcode === RegisterError.PasswordWeak && (
|
||||||
|
<FieldError
|
||||||
|
message={
|
||||||
|
registerError.data.error ??
|
||||||
|
'Weak Password. Password rejected by server please choosing more strong Password.'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{registerError?.errcode === RegisterError.PasswordShort && (
|
||||||
|
<FieldError
|
||||||
|
message={
|
||||||
|
registerError.data.error ??
|
||||||
|
'Short Password. Password rejected by server please choosing more long Password.'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text as="label" size="L400" priority="300">
|
||||||
|
Confirm Password
|
||||||
|
</Text>
|
||||||
|
<PasswordInput
|
||||||
|
ref={confPassRef}
|
||||||
|
onChange={doMatch}
|
||||||
|
name="confirmPasswordInput"
|
||||||
|
variant="Background"
|
||||||
|
size="500"
|
||||||
|
style={{ color: match ? undefined : color.Critical.Main }}
|
||||||
|
outlined
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ConfirmPasswordMatch>
|
||||||
|
{hasStageInFlows(uiaFlows, AuthType.RegistrationToken) && (
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text as="label" size="L400" priority="300">
|
||||||
|
{requiredStageInFlows(uiaFlows, AuthType.RegistrationToken)
|
||||||
|
? 'Registration Token'
|
||||||
|
: 'Registration Token (Optional)'}
|
||||||
|
</Text>
|
||||||
|
<Input
|
||||||
|
variant="Background"
|
||||||
|
defaultValue={defaultRegisterToken}
|
||||||
|
name="tokenInput"
|
||||||
|
size="500"
|
||||||
|
required={requiredStageInFlows(uiaFlows, AuthType.RegistrationToken)}
|
||||||
|
outlined
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{hasStageInFlows(uiaFlows, AuthType.Email) && (
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text as="label" size="L400" priority="300">
|
||||||
|
{requiredStageInFlows(uiaFlows, AuthType.Email) ? 'Email' : 'Email (Optional)'}
|
||||||
|
</Text>
|
||||||
|
<Input
|
||||||
|
variant="Background"
|
||||||
|
defaultValue={defaultEmail}
|
||||||
|
name="emailInput"
|
||||||
|
type="email"
|
||||||
|
size="500"
|
||||||
|
required={requiredStageInFlows(uiaFlows, AuthType.Email)}
|
||||||
|
outlined
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasStageInFlows(uiaFlows, AuthType.Terms) && termUrl && (
|
||||||
|
<Box alignItems="Center" gap="200">
|
||||||
|
<Checkbox name="termsInput" size="300" variant="Primary" required />
|
||||||
|
<Text size="T300">
|
||||||
|
I accept server{' '}
|
||||||
|
<a href={termUrl} target="_blank" rel="noreferrer">
|
||||||
|
Terms and Conditions
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{registerError?.errcode === RegisterError.RateLimited && (
|
||||||
|
<FieldError message="Failed to register. Your register request has been rate-limited by server, Please try after some time." />
|
||||||
|
)}
|
||||||
|
{registerError?.errcode === RegisterError.Forbidden && (
|
||||||
|
<FieldError message="Failed to register. The homeserver does not permit registration." />
|
||||||
|
)}
|
||||||
|
{registerError?.errcode === RegisterError.InvalidRequest && (
|
||||||
|
<FieldError message="Failed to register. Invalid request." />
|
||||||
|
)}
|
||||||
|
{registerError?.errcode === RegisterError.Unknown && (
|
||||||
|
<FieldError message={registerError.data.error ?? 'Failed to register. Unknown Reason.'} />
|
||||||
|
)}
|
||||||
|
<span data-spacing-node />
|
||||||
|
<Button variant="Primary" size="500" type="submit">
|
||||||
|
<Text as="span" size="B500">
|
||||||
|
Register
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
{registerState.status === AsyncStatus.Success &&
|
||||||
|
formData &&
|
||||||
|
ongoingFlow &&
|
||||||
|
ongoingAuthData && (
|
||||||
|
<RegisterUIAFlow
|
||||||
|
formData={formData}
|
||||||
|
flow={ongoingFlow}
|
||||||
|
authData={ongoingAuthData}
|
||||||
|
registerEmail={registerEmail}
|
||||||
|
registerEmailState={registerEmailState}
|
||||||
|
onRegister={handleRegister}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{registerState.status === AsyncStatus.Loading && (
|
||||||
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
|
<OverlayCenter>
|
||||||
|
<Spinner variant="Secondary" size="600" />
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
95
src/app/pages/auth/register/Register.tsx
Normal file
95
src/app/pages/auth/register/Register.tsx
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Box, Text, color } from 'folds';
|
||||||
|
import { Link, useSearchParams } from 'react-router-dom';
|
||||||
|
import { useAuthServer } from '../../../hooks/useAuthServer';
|
||||||
|
import { RegisterFlowStatus, useAuthFlows } from '../../../hooks/useAuthFlows';
|
||||||
|
import { useParsedLoginFlows } from '../../../hooks/useParsedLoginFlows';
|
||||||
|
import { PasswordRegisterForm, SUPPORTED_REGISTER_STAGES } from '../register/PasswordRegisterForm';
|
||||||
|
import { OrDivider } from '../OrDivider';
|
||||||
|
import { SSOLogin } from '../SSOLogin';
|
||||||
|
import { SupportedUIAFlowsLoader } from '../../../components/SupportedUIAFlowsLoader';
|
||||||
|
import { getLoginPath } from '../../pathUtils';
|
||||||
|
import { usePathWithOrigin } from '../../../hooks/usePathWithOrigin';
|
||||||
|
import { RegisterPathSearchParams } from '../../paths';
|
||||||
|
|
||||||
|
const getRegisterSearchParams = (searchParams: URLSearchParams): RegisterPathSearchParams => ({
|
||||||
|
username: searchParams.get('username') ?? undefined,
|
||||||
|
email: searchParams.get('email') ?? undefined,
|
||||||
|
token: searchParams.get('token') ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function Register() {
|
||||||
|
const server = useAuthServer();
|
||||||
|
const { loginFlows, registerFlows } = useAuthFlows();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const registerSearchParams = getRegisterSearchParams(searchParams);
|
||||||
|
const { sso } = useParsedLoginFlows(loginFlows.flows);
|
||||||
|
|
||||||
|
// redirect to /login because only that path handle m.login.token
|
||||||
|
const ssoRedirectUrl = usePathWithOrigin(getLoginPath(server));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box direction="Column" gap="500">
|
||||||
|
<Text size="H2" priority="400">
|
||||||
|
Register
|
||||||
|
</Text>
|
||||||
|
{registerFlows.status === RegisterFlowStatus.RegistrationDisabled && !sso && (
|
||||||
|
<Text style={{ color: color.Critical.Main }} size="T300">
|
||||||
|
Registration has been disabled on this homeserver.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{registerFlows.status === RegisterFlowStatus.RateLimited && !sso && (
|
||||||
|
<Text style={{ color: color.Critical.Main }} size="T300">
|
||||||
|
You have been rate-limited! Please try after some time.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{registerFlows.status === RegisterFlowStatus.InvalidRequest && !sso && (
|
||||||
|
<Text style={{ color: color.Critical.Main }} size="T300">
|
||||||
|
Invalid Request! Failed to get any registration options.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{registerFlows.status === RegisterFlowStatus.FlowRequired && (
|
||||||
|
<>
|
||||||
|
<SupportedUIAFlowsLoader
|
||||||
|
flows={registerFlows.data.flows ?? []}
|
||||||
|
supportedStages={SUPPORTED_REGISTER_STAGES}
|
||||||
|
>
|
||||||
|
{(supportedFlows) =>
|
||||||
|
supportedFlows.length === 0 ? (
|
||||||
|
<Text style={{ color: color.Critical.Main }} size="T300">
|
||||||
|
This application does not support registration on this homeserver.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<PasswordRegisterForm
|
||||||
|
authData={registerFlows.data}
|
||||||
|
uiaFlows={supportedFlows}
|
||||||
|
defaultUsername={registerSearchParams.username}
|
||||||
|
defaultEmail={registerSearchParams.email}
|
||||||
|
defaultRegisterToken={registerSearchParams.token}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</SupportedUIAFlowsLoader>
|
||||||
|
<span data-spacing-node />
|
||||||
|
{sso && <OrDivider />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{sso && (
|
||||||
|
<>
|
||||||
|
<SSOLogin
|
||||||
|
providers={sso.identity_providers}
|
||||||
|
redirectUrl={ssoRedirectUrl}
|
||||||
|
asIcons={
|
||||||
|
registerFlows.status === RegisterFlowStatus.FlowRequired &&
|
||||||
|
sso.identity_providers.length > 2
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span data-spacing-node />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Text align="Center">
|
||||||
|
Already have an account? <Link to={getLoginPath(server)}>Login</Link>
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
1
src/app/pages/auth/register/index.ts
Normal file
1
src/app/pages/auth/register/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export * from './Register';
|
125
src/app/pages/auth/register/registerUtil.ts
Normal file
125
src/app/pages/auth/register/registerUtil.ts
Normal file
|
@ -0,0 +1,125 @@
|
||||||
|
import to from 'await-to-js';
|
||||||
|
import {
|
||||||
|
IAuthData,
|
||||||
|
MatrixClient,
|
||||||
|
MatrixError,
|
||||||
|
RegisterRequest,
|
||||||
|
RegisterResponse,
|
||||||
|
} from 'matrix-js-sdk';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { updateLocalStore } from '../../../../client/action/auth';
|
||||||
|
import { ROOT_PATH } from '../../paths';
|
||||||
|
import { ErrorCode } from '../../../cs-errorcode';
|
||||||
|
|
||||||
|
export enum RegisterError {
|
||||||
|
UserTaken = 'UserTaken',
|
||||||
|
UserInvalid = 'UserInvalid',
|
||||||
|
UserExclusive = 'UserExclusive',
|
||||||
|
PasswordWeak = 'PasswordWeak',
|
||||||
|
PasswordShort = 'PasswordShort',
|
||||||
|
InvalidRequest = 'InvalidRequest',
|
||||||
|
Forbidden = 'Forbidden',
|
||||||
|
RateLimited = 'RateLimited',
|
||||||
|
Unknown = 'Unknown',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CustomRegisterResponse = {
|
||||||
|
baseUrl: string;
|
||||||
|
response: RegisterResponse;
|
||||||
|
};
|
||||||
|
export type RegisterResult = [IAuthData, undefined] | [undefined, CustomRegisterResponse];
|
||||||
|
export const register = async (
|
||||||
|
mx: MatrixClient,
|
||||||
|
requestData: RegisterRequest
|
||||||
|
): Promise<RegisterResult> => {
|
||||||
|
const [err, res] = await to<RegisterResponse, MatrixError>(mx.registerRequest(requestData));
|
||||||
|
|
||||||
|
if (err) {
|
||||||
|
if (err.httpStatus === 401) {
|
||||||
|
const authData = err.data as IAuthData;
|
||||||
|
return [authData, undefined];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err.errcode === ErrorCode.M_USER_IN_USE) {
|
||||||
|
throw new MatrixError({
|
||||||
|
errcode: RegisterError.UserTaken,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (err.errcode === ErrorCode.M_INVALID_USERNAME) {
|
||||||
|
throw new MatrixError({
|
||||||
|
errcode: RegisterError.UserInvalid,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (err.errcode === ErrorCode.M_EXCLUSIVE) {
|
||||||
|
throw new MatrixError({
|
||||||
|
errcode: RegisterError.UserExclusive,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (err.errcode === ErrorCode.M_WEAK_PASSWORD) {
|
||||||
|
throw new MatrixError({
|
||||||
|
errcode: RegisterError.PasswordWeak,
|
||||||
|
error: err.data.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (err.errcode === ErrorCode.M_PASSWORD_TOO_SHORT) {
|
||||||
|
throw new MatrixError({
|
||||||
|
errcode: RegisterError.PasswordShort,
|
||||||
|
error: err.data.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err.httpStatus === 429) {
|
||||||
|
throw new MatrixError({
|
||||||
|
errcode: RegisterError.RateLimited,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err.httpStatus === 400) {
|
||||||
|
throw new MatrixError({
|
||||||
|
errcode: RegisterError.InvalidRequest,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err.httpStatus === 403) {
|
||||||
|
throw new MatrixError({
|
||||||
|
errcode: RegisterError.Forbidden,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new MatrixError({
|
||||||
|
errcode: RegisterError.Unknown,
|
||||||
|
error: err.data.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
baseUrl: mx.baseUrl,
|
||||||
|
response: res,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useRegisterComplete = (data?: CustomRegisterResponse) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data) {
|
||||||
|
const { response, baseUrl } = data;
|
||||||
|
|
||||||
|
const userId = response.user_id;
|
||||||
|
const accessToken = response.access_token;
|
||||||
|
const deviceId = response.device_id;
|
||||||
|
|
||||||
|
if (accessToken && deviceId) {
|
||||||
|
updateLocalStore(accessToken, deviceId, userId, baseUrl);
|
||||||
|
// TODO: add after register redirect url
|
||||||
|
navigate(ROOT_PATH, { replace: true });
|
||||||
|
} else {
|
||||||
|
// TODO: navigate to login with userId
|
||||||
|
navigate(ROOT_PATH, { replace: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [data, navigate]);
|
||||||
|
};
|
274
src/app/pages/auth/reset-password/PasswordResetForm.tsx
Normal file
274
src/app/pages/auth/reset-password/PasswordResetForm.tsx
Normal file
|
@ -0,0 +1,274 @@
|
||||||
|
import React, { FormEventHandler, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
Input,
|
||||||
|
Overlay,
|
||||||
|
OverlayBackdrop,
|
||||||
|
OverlayCenter,
|
||||||
|
Spinner,
|
||||||
|
Text,
|
||||||
|
color,
|
||||||
|
config,
|
||||||
|
} from 'folds';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { AuthDict, AuthType, MatrixError, createClient } from 'matrix-js-sdk';
|
||||||
|
import { useAutoDiscoveryInfo } from '../../../hooks/useAutoDiscoveryInfo';
|
||||||
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
|
import { useAuthServer } from '../../../hooks/useAuthServer';
|
||||||
|
import { usePasswordEmail } from '../../../hooks/usePasswordEmail';
|
||||||
|
import { PasswordInput } from '../../../components/password-input/PasswordInput';
|
||||||
|
import { ConfirmPasswordMatch } from '../../../components/ConfirmPasswordMatch';
|
||||||
|
import { FieldError } from '../FiledError';
|
||||||
|
import { UIAFlowOverlay } from '../../../components/UIAFlowOverlay';
|
||||||
|
import { EmailStageDialog } from '../../../components/uia-stages';
|
||||||
|
import { ResetPasswordResult, resetPassword } from './resetPasswordUtil';
|
||||||
|
import { getLoginPath, withSearchParam } from '../../pathUtils';
|
||||||
|
import { LoginPathSearchParams } from '../../paths';
|
||||||
|
import { getUIAError, getUIAErrorCode } from '../../../utils/matrix-uia';
|
||||||
|
|
||||||
|
type FormData = {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
clientSecret: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ResetPasswordComplete({ email }: { email?: string }) {
|
||||||
|
const server = useAuthServer();
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
const path = getLoginPath(server);
|
||||||
|
if (email) {
|
||||||
|
navigate(withSearchParam<LoginPathSearchParams>(path, { email }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigate(path);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
|
<OverlayCenter>
|
||||||
|
<FocusTrap>
|
||||||
|
<Dialog>
|
||||||
|
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
||||||
|
<Text>
|
||||||
|
Password has been reset successfully. Please login with your new password.
|
||||||
|
</Text>
|
||||||
|
<Button variant="Primary" onClick={handleClick}>
|
||||||
|
<Text size="B400" as="span">
|
||||||
|
Login
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
</FocusTrap>
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type PasswordResetFormProps = {
|
||||||
|
defaultEmail?: string;
|
||||||
|
};
|
||||||
|
export function PasswordResetForm({ defaultEmail }: PasswordResetFormProps) {
|
||||||
|
const server = useAuthServer();
|
||||||
|
|
||||||
|
const serverDiscovery = useAutoDiscoveryInfo();
|
||||||
|
const baseUrl = serverDiscovery['m.homeserver'].base_url;
|
||||||
|
const mx = useMemo(() => createClient({ baseUrl }), [baseUrl]);
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState<FormData>();
|
||||||
|
|
||||||
|
const [passwordEmailState, passwordEmail] = usePasswordEmail(mx);
|
||||||
|
|
||||||
|
const [resetPasswordState, handleResetPassword] = useAsyncCallback<
|
||||||
|
ResetPasswordResult,
|
||||||
|
MatrixError,
|
||||||
|
[AuthDict, string]
|
||||||
|
>(useCallback(async (authDict, newPassword) => resetPassword(mx, authDict, newPassword), [mx]));
|
||||||
|
|
||||||
|
const [ongoingAuthData, resetPasswordResult] =
|
||||||
|
resetPasswordState.status === AsyncStatus.Success ? resetPasswordState.data : [];
|
||||||
|
const resetPasswordError =
|
||||||
|
resetPasswordState.status === AsyncStatus.Error ? resetPasswordState.error : undefined;
|
||||||
|
|
||||||
|
const flowErrorCode = ongoingAuthData && getUIAErrorCode(ongoingAuthData);
|
||||||
|
const flowError = ongoingAuthData && getUIAError(ongoingAuthData);
|
||||||
|
|
||||||
|
let waitingToVerifyEmail = true;
|
||||||
|
if (resetPasswordResult) waitingToVerifyEmail = false;
|
||||||
|
if (ongoingAuthData && flowErrorCode === undefined) waitingToVerifyEmail = false;
|
||||||
|
if (resetPasswordError) waitingToVerifyEmail = false;
|
||||||
|
if (resetPasswordState.status === AsyncStatus.Loading) waitingToVerifyEmail = false;
|
||||||
|
|
||||||
|
// We only support UIA m.login.password stage for reset password
|
||||||
|
// So we will assume to process it as soon as
|
||||||
|
// we have 401 with no error on initial request.
|
||||||
|
useEffect(() => {
|
||||||
|
if (formData && ongoingAuthData && !flowErrorCode) {
|
||||||
|
handleResetPassword(
|
||||||
|
{
|
||||||
|
type: AuthType.Password,
|
||||||
|
identifier: {
|
||||||
|
type: 'm.id.thirdparty',
|
||||||
|
medium: 'email',
|
||||||
|
address: formData.email,
|
||||||
|
},
|
||||||
|
password: formData.password,
|
||||||
|
},
|
||||||
|
formData.password
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [ongoingAuthData, flowErrorCode, formData, handleResetPassword]);
|
||||||
|
|
||||||
|
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
const { emailInput, passwordInput, confirmPasswordInput } = evt.target as HTMLFormElement & {
|
||||||
|
emailInput: HTMLInputElement;
|
||||||
|
passwordInput: HTMLInputElement;
|
||||||
|
confirmPasswordInput: HTMLInputElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
const email = emailInput.value.trim();
|
||||||
|
const password = passwordInput.value;
|
||||||
|
const confirmPassword = confirmPasswordInput.value;
|
||||||
|
if (!email) {
|
||||||
|
emailInput.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password !== confirmPassword) return;
|
||||||
|
|
||||||
|
const clientSecret = mx.generateClientSecret();
|
||||||
|
passwordEmail(email, clientSecret);
|
||||||
|
setFormData({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
clientSecret,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmitRequest = useCallback(
|
||||||
|
(authDict: AuthDict) => {
|
||||||
|
if (!formData) return;
|
||||||
|
const { password } = formData;
|
||||||
|
handleResetPassword(authDict, password);
|
||||||
|
},
|
||||||
|
[formData, handleResetPassword]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box as="form" onSubmit={handleSubmit} direction="Inherit" gap="400">
|
||||||
|
<Text size="T300" priority="400">
|
||||||
|
Homeserver <strong>{server}</strong> will send you an email to let you reset your password.
|
||||||
|
</Text>
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text as="label" size="L400" priority="300">
|
||||||
|
Email
|
||||||
|
</Text>
|
||||||
|
<Input
|
||||||
|
defaultValue={defaultEmail}
|
||||||
|
type="email"
|
||||||
|
name="emailInput"
|
||||||
|
variant="Background"
|
||||||
|
size="500"
|
||||||
|
required
|
||||||
|
outlined
|
||||||
|
/>
|
||||||
|
{passwordEmailState.status === AsyncStatus.Error && (
|
||||||
|
<FieldError
|
||||||
|
message={`${passwordEmailState.error.errcode}: ${passwordEmailState.error.data?.error}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<ConfirmPasswordMatch initialValue>
|
||||||
|
{(match, doMatch, passRef, confPassRef) => (
|
||||||
|
<>
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text as="label" size="L400" priority="300">
|
||||||
|
New Password
|
||||||
|
</Text>
|
||||||
|
<PasswordInput
|
||||||
|
ref={passRef}
|
||||||
|
onChange={doMatch}
|
||||||
|
name="passwordInput"
|
||||||
|
variant="Background"
|
||||||
|
size="500"
|
||||||
|
outlined
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text as="label" size="L400" priority="300">
|
||||||
|
Confirm Password
|
||||||
|
</Text>
|
||||||
|
<PasswordInput
|
||||||
|
ref={confPassRef}
|
||||||
|
onChange={doMatch}
|
||||||
|
name="confirmPasswordInput"
|
||||||
|
variant="Background"
|
||||||
|
size="500"
|
||||||
|
style={{ color: match ? undefined : color.Critical.Main }}
|
||||||
|
outlined
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ConfirmPasswordMatch>
|
||||||
|
{resetPasswordError && (
|
||||||
|
<FieldError
|
||||||
|
message={`${resetPasswordError.errcode}: ${
|
||||||
|
resetPasswordError.data?.error ?? 'Failed to reset password.'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span data-spacing-node />
|
||||||
|
<Button type="submit" variant="Primary" size="500">
|
||||||
|
<Text as="span" size="B500">
|
||||||
|
Reset Password
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{resetPasswordResult && <ResetPasswordComplete email={formData?.email} />}
|
||||||
|
|
||||||
|
{passwordEmailState.status === AsyncStatus.Success && formData && waitingToVerifyEmail && (
|
||||||
|
<UIAFlowOverlay currentStep={1} stepCount={1} onCancel={handleCancel}>
|
||||||
|
<EmailStageDialog
|
||||||
|
stageData={{
|
||||||
|
type: AuthType.Email,
|
||||||
|
errorCode: flowErrorCode,
|
||||||
|
error: flowError,
|
||||||
|
session: ongoingAuthData?.session,
|
||||||
|
}}
|
||||||
|
submitAuthDict={handleSubmitRequest}
|
||||||
|
email={formData.email}
|
||||||
|
clientSecret={formData.clientSecret}
|
||||||
|
requestEmailToken={passwordEmail}
|
||||||
|
emailTokenState={passwordEmailState}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
/>
|
||||||
|
</UIAFlowOverlay>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Overlay
|
||||||
|
open={
|
||||||
|
passwordEmailState.status === AsyncStatus.Loading ||
|
||||||
|
resetPasswordState.status === AsyncStatus.Loading
|
||||||
|
}
|
||||||
|
backdrop={<OverlayBackdrop />}
|
||||||
|
>
|
||||||
|
<OverlayCenter>
|
||||||
|
<Spinner variant="Secondary" size="600" />
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
36
src/app/pages/auth/reset-password/ResetPassword.tsx
Normal file
36
src/app/pages/auth/reset-password/ResetPassword.tsx
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
import { Box, Text } from 'folds';
|
||||||
|
import React from 'react';
|
||||||
|
import { Link, useSearchParams } from 'react-router-dom';
|
||||||
|
import { getLoginPath } from '../../pathUtils';
|
||||||
|
import { useAuthServer } from '../../../hooks/useAuthServer';
|
||||||
|
import { PasswordResetForm } from './PasswordResetForm';
|
||||||
|
|
||||||
|
export type ResetPasswordSearchParams = {
|
||||||
|
email?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getResetPasswordSearchParams = (
|
||||||
|
searchParams: URLSearchParams
|
||||||
|
): ResetPasswordSearchParams => ({
|
||||||
|
email: searchParams.get('email') ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function ResetPassword() {
|
||||||
|
const server = useAuthServer();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const resetPasswordSearchParams = getResetPasswordSearchParams(searchParams);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box direction="Column" gap="500">
|
||||||
|
<Text size="H2" priority="400">
|
||||||
|
Reset Password
|
||||||
|
</Text>
|
||||||
|
<PasswordResetForm defaultEmail={resetPasswordSearchParams.email} />
|
||||||
|
<span data-spacing-node />
|
||||||
|
|
||||||
|
<Text align="Center">
|
||||||
|
Remember your password? <Link to={getLoginPath(server)}>Login</Link>
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
1
src/app/pages/auth/reset-password/index.ts
Normal file
1
src/app/pages/auth/reset-password/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export * from './ResetPassword';
|
23
src/app/pages/auth/reset-password/resetPasswordUtil.ts
Normal file
23
src/app/pages/auth/reset-password/resetPasswordUtil.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import to from 'await-to-js';
|
||||||
|
import { AuthDict, IAuthData, MatrixClient, MatrixError } from 'matrix-js-sdk';
|
||||||
|
|
||||||
|
export type ResetPasswordResponse = Record<string, never>;
|
||||||
|
export type ResetPasswordResult = [IAuthData, undefined] | [undefined, ResetPasswordResponse];
|
||||||
|
export const resetPassword = async (
|
||||||
|
mx: MatrixClient,
|
||||||
|
authDict: AuthDict,
|
||||||
|
newPassword: string
|
||||||
|
): Promise<ResetPasswordResult> => {
|
||||||
|
const [err, res] = await to<ResetPasswordResponse, MatrixError>(
|
||||||
|
mx.setPassword(authDict, newPassword, false)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (err) {
|
||||||
|
if (err.httpStatus === 401) {
|
||||||
|
const authData = err.data as IAuthData;
|
||||||
|
return [authData, undefined];
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return [undefined, res];
|
||||||
|
};
|
53
src/app/pages/auth/styles.css.ts
Normal file
53
src/app/pages/auth/styles.css.ts
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { DefaultReset, color, config, toRem } from 'folds';
|
||||||
|
|
||||||
|
export const AuthLayout = style({
|
||||||
|
minHeight: '100%',
|
||||||
|
backgroundColor: color.Background.Container,
|
||||||
|
color: color.Background.OnContainer,
|
||||||
|
padding: config.space.S400,
|
||||||
|
paddingRight: config.space.S200,
|
||||||
|
paddingBottom: 0,
|
||||||
|
position: 'relative',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AuthCard = style({
|
||||||
|
marginTop: '1vh',
|
||||||
|
maxWidth: toRem(460),
|
||||||
|
width: '100%',
|
||||||
|
backgroundColor: color.Surface.Container,
|
||||||
|
color: color.Surface.OnContainer,
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
boxShadow: config.shadow.E100,
|
||||||
|
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||||
|
overflow: 'hidden',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AuthLogo = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
width: toRem(26),
|
||||||
|
height: toRem(26),
|
||||||
|
|
||||||
|
borderRadius: '50%',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const AuthHeader = style({
|
||||||
|
padding: `0 ${config.space.S400}`,
|
||||||
|
borderBottomWidth: config.borderWidth.B300,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AuthCardContent = style({
|
||||||
|
maxWidth: toRem(402),
|
||||||
|
width: '100%',
|
||||||
|
margin: 'auto',
|
||||||
|
padding: config.space.S400,
|
||||||
|
paddingTop: config.space.S700,
|
||||||
|
paddingBottom: toRem(44),
|
||||||
|
gap: toRem(44),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AuthFooter = style({
|
||||||
|
padding: config.space.S200,
|
||||||
|
});
|
28
src/app/pages/pathUtils.ts
Normal file
28
src/app/pages/pathUtils.ts
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
import { generatePath } from 'react-router-dom';
|
||||||
|
import { LOGIN_PATH, REGISTER_PATH, RESET_PASSWORD_PATH, ROOT_PATH } from './paths';
|
||||||
|
|
||||||
|
export const withSearchParam = <T extends Record<string, string>>(
|
||||||
|
path: string,
|
||||||
|
searchParam: T
|
||||||
|
): string => {
|
||||||
|
const params = new URLSearchParams(searchParam);
|
||||||
|
|
||||||
|
return `${path}?${params}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getRootPath = (): string => ROOT_PATH;
|
||||||
|
|
||||||
|
export const getLoginPath = (server?: string): string => {
|
||||||
|
const params = server ? { server: encodeURIComponent(server) } : undefined;
|
||||||
|
return generatePath(LOGIN_PATH, params);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getRegisterPath = (server?: string): string => {
|
||||||
|
const params = server ? { server: encodeURIComponent(server) } : undefined;
|
||||||
|
return generatePath(REGISTER_PATH, params);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getResetPasswordPath = (server?: string): string => {
|
||||||
|
const params = server ? { server: encodeURIComponent(server) } : undefined;
|
||||||
|
return generatePath(RESET_PASSWORD_PATH, params);
|
||||||
|
};
|
17
src/app/pages/paths.ts
Normal file
17
src/app/pages/paths.ts
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
export const ROOT_PATH = '/';
|
||||||
|
|
||||||
|
export type LoginPathSearchParams = {
|
||||||
|
username?: string;
|
||||||
|
email?: string;
|
||||||
|
loginToken?: string;
|
||||||
|
};
|
||||||
|
export const LOGIN_PATH = '/login/:server?/';
|
||||||
|
|
||||||
|
export type RegisterPathSearchParams = {
|
||||||
|
username?: string;
|
||||||
|
email?: string;
|
||||||
|
token?: string;
|
||||||
|
};
|
||||||
|
export const REGISTER_PATH = '/register/:server?/';
|
||||||
|
|
||||||
|
export const RESET_PASSWORD_PATH = '/reset-password/:server?/';
|
|
@ -7,7 +7,7 @@ export const usePdfJSLoader = () =>
|
||||||
useAsyncCallback(
|
useAsyncCallback(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
const pdf = await import('pdfjs-dist');
|
const pdf = await import('pdfjs-dist');
|
||||||
pdf.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.js';
|
pdf.GlobalWorkerOptions.workerSrc = 'pdf.worker.min.js';
|
||||||
return pdf;
|
return pdf;
|
||||||
}, [])
|
}, [])
|
||||||
);
|
);
|
||||||
|
|
58
src/app/state/hooks/inviteList.ts
Normal file
58
src/app/state/hooks/inviteList.ts
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { selectAtom } from 'jotai/utils';
|
||||||
|
import { MatrixClient } from 'matrix-js-sdk';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { isDirectInvite, isRoom, isSpace, isUnsupportedRoom } from '../../utils/room';
|
||||||
|
import { compareRoomsEqual } from '../utils';
|
||||||
|
import { mDirectAtom } from '../mDirectList';
|
||||||
|
import { allInvitesAtom } from '../inviteList';
|
||||||
|
|
||||||
|
export const useSpaceInvites = (mx: MatrixClient, invitesAtom: typeof allInvitesAtom) => {
|
||||||
|
const selector = useCallback(
|
||||||
|
(rooms: string[]) => rooms.filter((roomId) => isSpace(mx.getRoom(roomId))),
|
||||||
|
[mx]
|
||||||
|
);
|
||||||
|
return useAtomValue(selectAtom(invitesAtom, selector, compareRoomsEqual));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useRoomInvites = (
|
||||||
|
mx: MatrixClient,
|
||||||
|
invitesAtom: typeof allInvitesAtom,
|
||||||
|
directAtom: typeof mDirectAtom
|
||||||
|
) => {
|
||||||
|
const mDirects = useAtomValue(directAtom);
|
||||||
|
const selector = useCallback(
|
||||||
|
(rooms: string[]) =>
|
||||||
|
rooms.filter(
|
||||||
|
(roomId) =>
|
||||||
|
isRoom(mx.getRoom(roomId)) &&
|
||||||
|
!(mDirects.has(roomId) || isDirectInvite(mx.getRoom(roomId), mx.getUserId()))
|
||||||
|
),
|
||||||
|
[mx, mDirects]
|
||||||
|
);
|
||||||
|
return useAtomValue(selectAtom(invitesAtom, selector, compareRoomsEqual));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDirectInvites = (
|
||||||
|
mx: MatrixClient,
|
||||||
|
invitesAtom: typeof allInvitesAtom,
|
||||||
|
directAtom: typeof mDirectAtom
|
||||||
|
) => {
|
||||||
|
const mDirects = useAtomValue(directAtom);
|
||||||
|
const selector = useCallback(
|
||||||
|
(rooms: string[]) =>
|
||||||
|
rooms.filter(
|
||||||
|
(roomId) => mDirects.has(roomId) || isDirectInvite(mx.getRoom(roomId), mx.getUserId())
|
||||||
|
),
|
||||||
|
[mx, mDirects]
|
||||||
|
);
|
||||||
|
return useAtomValue(selectAtom(invitesAtom, selector, compareRoomsEqual));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useUnsupportedInvites = (mx: MatrixClient, invitesAtom: typeof allInvitesAtom) => {
|
||||||
|
const selector = useCallback(
|
||||||
|
(rooms: string[]) => rooms.filter((roomId) => isUnsupportedRoom(mx.getRoom(roomId))),
|
||||||
|
[mx]
|
||||||
|
);
|
||||||
|
return useAtomValue(selectAtom(invitesAtom, selector, compareRoomsEqual));
|
||||||
|
};
|
52
src/app/state/hooks/roomList.ts
Normal file
52
src/app/state/hooks/roomList.ts
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { selectAtom } from 'jotai/utils';
|
||||||
|
import { MatrixClient } from 'matrix-js-sdk';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { isRoom, isSpace, isUnsupportedRoom } from '../../utils/room';
|
||||||
|
import { compareRoomsEqual } from '../utils';
|
||||||
|
import { mDirectAtom } from '../mDirectList';
|
||||||
|
import { allRoomsAtom } from '../roomList';
|
||||||
|
|
||||||
|
export const useSpaces = (mx: MatrixClient, roomsAtom: typeof allRoomsAtom) => {
|
||||||
|
const selector = useCallback(
|
||||||
|
(rooms: string[]) => rooms.filter((roomId) => isSpace(mx.getRoom(roomId))),
|
||||||
|
[mx]
|
||||||
|
);
|
||||||
|
return useAtomValue(selectAtom(roomsAtom, selector, compareRoomsEqual));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useRooms = (
|
||||||
|
mx: MatrixClient,
|
||||||
|
roomsAtom: typeof allRoomsAtom,
|
||||||
|
directAtom: typeof mDirectAtom
|
||||||
|
) => {
|
||||||
|
const mDirects = useAtomValue(directAtom);
|
||||||
|
const selector = useCallback(
|
||||||
|
(rooms: string[]) =>
|
||||||
|
rooms.filter((roomId) => isRoom(mx.getRoom(roomId)) && !mDirects.has(roomId)),
|
||||||
|
[mx, mDirects]
|
||||||
|
);
|
||||||
|
return useAtomValue(selectAtom(roomsAtom, selector, compareRoomsEqual));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDirects = (
|
||||||
|
mx: MatrixClient,
|
||||||
|
roomsAtom: typeof allRoomsAtom,
|
||||||
|
directAtom: typeof mDirectAtom
|
||||||
|
) => {
|
||||||
|
const mDirects = useAtomValue(directAtom);
|
||||||
|
const selector = useCallback(
|
||||||
|
(rooms: string[]) =>
|
||||||
|
rooms.filter((roomId) => isRoom(mx.getRoom(roomId)) && mDirects.has(roomId)),
|
||||||
|
[mx, mDirects]
|
||||||
|
);
|
||||||
|
return useAtomValue(selectAtom(roomsAtom, selector, compareRoomsEqual));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useUnsupportedRooms = (mx: MatrixClient, roomsAtom: typeof allRoomsAtom) => {
|
||||||
|
const selector = useCallback(
|
||||||
|
(rooms: string[]) => rooms.filter((roomId) => isUnsupportedRoom(mx.getRoom(roomId))),
|
||||||
|
[mx]
|
||||||
|
);
|
||||||
|
return useAtomValue(selectAtom(roomsAtom, selector, compareRoomsEqual));
|
||||||
|
};
|
|
@ -1,16 +1,16 @@
|
||||||
import { atom, useAtomValue, useSetAtom, WritableAtom } from 'jotai';
|
import { atom, useAtomValue, useSetAtom } from 'jotai';
|
||||||
import { SetAtom } from 'jotai/core/atom';
|
|
||||||
import { selectAtom } from 'jotai/utils';
|
import { selectAtom } from 'jotai/utils';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { Settings } from '../settings';
|
import { Settings, settingsAtom as sAtom } from '../settings';
|
||||||
|
|
||||||
export const useSetSetting = <K extends keyof Settings>(
|
export type SettingSetter<K extends keyof Settings> =
|
||||||
settingsAtom: WritableAtom<Settings, Settings>,
|
| Settings[K]
|
||||||
key: K
|
| ((s: Settings[K]) => Settings[K]);
|
||||||
) => {
|
|
||||||
|
export const useSetSetting = <K extends keyof Settings>(settingsAtom: typeof sAtom, key: K) => {
|
||||||
const setterAtom = useMemo(
|
const setterAtom = useMemo(
|
||||||
() =>
|
() =>
|
||||||
atom<null, Settings[K] | ((s: Settings[K]) => Settings[K])>(null, (get, set, value) => {
|
atom<null, [SettingSetter<K>], undefined>(null, (get, set, value) => {
|
||||||
const s = { ...get(settingsAtom) };
|
const s = { ...get(settingsAtom) };
|
||||||
s[key] = typeof value === 'function' ? value(s[key]) : value;
|
s[key] = typeof value === 'function' ? value(s[key]) : value;
|
||||||
set(settingsAtom, s);
|
set(settingsAtom, s);
|
||||||
|
@ -22,9 +22,9 @@ export const useSetSetting = <K extends keyof Settings>(
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useSetting = <K extends keyof Settings>(
|
export const useSetting = <K extends keyof Settings>(
|
||||||
settingsAtom: WritableAtom<Settings, Settings>,
|
settingsAtom: typeof sAtom,
|
||||||
key: K
|
key: K
|
||||||
): [Settings[K], SetAtom<Settings[K] | ((s: Settings[K]) => Settings[K]), void>] => {
|
): [Settings[K], ReturnType<typeof useSetSetting<K>>] => {
|
||||||
const selector = useMemo(() => (s: Settings) => s[key], [key]);
|
const selector = useMemo(() => (s: Settings) => s[key], [key]);
|
||||||
const setting = useAtomValue(selectAtom(settingsAtom, selector));
|
const setting = useAtomValue(selectAtom(settingsAtom, selector));
|
||||||
|
|
||||||
|
|
32
src/app/state/inviteList.ts
Normal file
32
src/app/state/inviteList.ts
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import { atom, WritableAtom } from 'jotai';
|
||||||
|
import { MatrixClient } from 'matrix-js-sdk';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Membership } from '../../types/matrix/room';
|
||||||
|
import { RoomsAction, useBindRoomsWithMembershipsAtom } from './utils';
|
||||||
|
|
||||||
|
const baseRoomsAtom = atom<string[]>([]);
|
||||||
|
export const allInvitesAtom = atom<string[], [RoomsAction], undefined>(
|
||||||
|
(get) => get(baseRoomsAtom),
|
||||||
|
(get, set, action) => {
|
||||||
|
if (action.type === 'INITIALIZE') {
|
||||||
|
set(baseRoomsAtom, action.rooms);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
set(baseRoomsAtom, (ids) => {
|
||||||
|
const newIds = ids.filter((id) => id !== action.roomId);
|
||||||
|
if (action.type === 'PUT') newIds.push(action.roomId);
|
||||||
|
return newIds;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const useBindAllInvitesAtom = (
|
||||||
|
mx: MatrixClient,
|
||||||
|
allRooms: WritableAtom<string[], [RoomsAction], undefined>
|
||||||
|
) => {
|
||||||
|
useBindRoomsWithMembershipsAtom(
|
||||||
|
mx,
|
||||||
|
allRooms,
|
||||||
|
useMemo(() => [Membership.Invite], [])
|
||||||
|
);
|
||||||
|
};
|
|
@ -12,7 +12,7 @@ export type ListAction<T> =
|
||||||
|
|
||||||
export const createListAtom = <T>() => {
|
export const createListAtom = <T>() => {
|
||||||
const baseListAtom = atom<T[]>([]);
|
const baseListAtom = atom<T[]>([]);
|
||||||
return atom<T[], ListAction<T>>(
|
return atom<T[], [ListAction<T>], undefined>(
|
||||||
(get) => get(baseListAtom),
|
(get) => get(baseListAtom),
|
||||||
(get, set, action) => {
|
(get, set, action) => {
|
||||||
const items = get(baseListAtom);
|
const items = get(baseListAtom);
|
||||||
|
|
44
src/app/state/mDirectList.ts
Normal file
44
src/app/state/mDirectList.ts
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
import { atom, useSetAtom } from 'jotai';
|
||||||
|
import { ClientEvent, MatrixClient, MatrixEvent } from 'matrix-js-sdk';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { AccountDataEvent } from '../../types/matrix/accountData';
|
||||||
|
import { getAccountData, getMDirects } from '../utils/room';
|
||||||
|
|
||||||
|
export type MDirectAction = {
|
||||||
|
type: 'INITIALIZE' | 'UPDATE';
|
||||||
|
rooms: Set<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseMDirectAtom = atom(new Set<string>());
|
||||||
|
export const mDirectAtom = atom<Set<string>, [MDirectAction], undefined>(
|
||||||
|
(get) => get(baseMDirectAtom),
|
||||||
|
(get, set, action) => {
|
||||||
|
set(baseMDirectAtom, action.rooms);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const useBindMDirectAtom = (mx: MatrixClient, mDirect: typeof mDirectAtom) => {
|
||||||
|
const setMDirect = useSetAtom(mDirect);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mDirectEvent = getAccountData(mx, AccountDataEvent.Direct);
|
||||||
|
if (mDirectEvent) {
|
||||||
|
setMDirect({
|
||||||
|
type: 'INITIALIZE',
|
||||||
|
rooms: getMDirects(mDirectEvent),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAccountData = (event: MatrixEvent) => {
|
||||||
|
setMDirect({
|
||||||
|
type: 'UPDATE',
|
||||||
|
rooms: getMDirects(event),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
mx.on(ClientEvent.AccountData, handleAccountData);
|
||||||
|
return () => {
|
||||||
|
mx.removeListener(ClientEvent.AccountData, handleAccountData);
|
||||||
|
};
|
||||||
|
}, [mx, setMDirect]);
|
||||||
|
};
|
98
src/app/state/mutedRoomList.ts
Normal file
98
src/app/state/mutedRoomList.ts
Normal file
|
@ -0,0 +1,98 @@
|
||||||
|
import { atom, useSetAtom } from 'jotai';
|
||||||
|
import { ClientEvent, IPushRule, IPushRules, MatrixClient, MatrixEvent } from 'matrix-js-sdk';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { MuteChanges } from '../../types/matrix/room';
|
||||||
|
import { findMutedRule, isMutedRule } from '../utils/room';
|
||||||
|
|
||||||
|
export type MutedRoomsUpdate =
|
||||||
|
| {
|
||||||
|
type: 'INITIALIZE';
|
||||||
|
addRooms: string[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'UPDATE';
|
||||||
|
addRooms: string[];
|
||||||
|
removeRooms: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const muteChangesAtom = atom<MuteChanges>({
|
||||||
|
added: [],
|
||||||
|
removed: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseMutedRoomsAtom = atom(new Set<string>());
|
||||||
|
export const mutedRoomsAtom = atom<Set<string>, [MutedRoomsUpdate], undefined>(
|
||||||
|
(get) => get(baseMutedRoomsAtom),
|
||||||
|
(get, set, action) => {
|
||||||
|
const mutedRooms = new Set([...get(mutedRoomsAtom)]);
|
||||||
|
if (action.type === 'INITIALIZE') {
|
||||||
|
set(baseMutedRoomsAtom, new Set([...action.addRooms]));
|
||||||
|
set(muteChangesAtom, {
|
||||||
|
added: [...action.addRooms],
|
||||||
|
removed: [],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action.type === 'UPDATE') {
|
||||||
|
action.removeRooms.forEach((roomId) => mutedRooms.delete(roomId));
|
||||||
|
action.addRooms.forEach((roomId) => mutedRooms.add(roomId));
|
||||||
|
set(baseMutedRoomsAtom, mutedRooms);
|
||||||
|
set(muteChangesAtom, {
|
||||||
|
added: [...action.addRooms],
|
||||||
|
removed: [...action.removeRooms],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const useBindMutedRoomsAtom = (mx: MatrixClient, mutedAtom: typeof mutedRoomsAtom) => {
|
||||||
|
const setMuted = useSetAtom(mutedAtom);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const overrideRules = mx.getAccountData('m.push_rules')?.getContent<IPushRules>()
|
||||||
|
?.global?.override;
|
||||||
|
if (overrideRules) {
|
||||||
|
const mutedRooms = overrideRules.reduce<string[]>((rooms, rule) => {
|
||||||
|
if (isMutedRule(rule)) rooms.push(rule.rule_id);
|
||||||
|
return rooms;
|
||||||
|
}, []);
|
||||||
|
setMuted({
|
||||||
|
type: 'INITIALIZE',
|
||||||
|
addRooms: mutedRooms,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [mx, setMuted]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handlePushRules = (mEvent: MatrixEvent, oldMEvent?: MatrixEvent) => {
|
||||||
|
if (mEvent.getType() === 'm.push_rules') {
|
||||||
|
const override = mEvent?.getContent()?.global?.override as IPushRule[] | undefined;
|
||||||
|
const oldOverride = oldMEvent?.getContent()?.global?.override as IPushRule[] | undefined;
|
||||||
|
if (!override || !oldOverride) return;
|
||||||
|
|
||||||
|
const isMuteToggled = (rule: IPushRule, otherOverride: IPushRule[]) => {
|
||||||
|
const roomId = rule.rule_id;
|
||||||
|
|
||||||
|
const isMuted = isMutedRule(rule);
|
||||||
|
if (!isMuted) return false;
|
||||||
|
const isOtherMuted = findMutedRule(otherOverride, roomId);
|
||||||
|
if (isOtherMuted) return false;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mutedRules = override.filter((rule) => isMuteToggled(rule, oldOverride));
|
||||||
|
const unMutedRules = oldOverride.filter((rule) => isMuteToggled(rule, override));
|
||||||
|
|
||||||
|
setMuted({
|
||||||
|
type: 'UPDATE',
|
||||||
|
addRooms: mutedRules.map((rule) => rule.rule_id),
|
||||||
|
removeRooms: unMutedRules.map((rule) => rule.rule_id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
mx.on(ClientEvent.AccountData, handlePushRules);
|
||||||
|
return () => {
|
||||||
|
mx.removeListener(ClientEvent.AccountData, handlePushRules);
|
||||||
|
};
|
||||||
|
}, [mx, setMuted]);
|
||||||
|
};
|
28
src/app/state/roomList.ts
Normal file
28
src/app/state/roomList.ts
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
import { atom } from 'jotai';
|
||||||
|
import { MatrixClient } from 'matrix-js-sdk';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Membership } from '../../types/matrix/room';
|
||||||
|
import { RoomsAction, useBindRoomsWithMembershipsAtom } from './utils';
|
||||||
|
|
||||||
|
const baseRoomsAtom = atom<string[]>([]);
|
||||||
|
export const allRoomsAtom = atom<string[], [RoomsAction], undefined>(
|
||||||
|
(get) => get(baseRoomsAtom),
|
||||||
|
(get, set, action) => {
|
||||||
|
if (action.type === 'INITIALIZE') {
|
||||||
|
set(baseRoomsAtom, action.rooms);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
set(baseRoomsAtom, (ids) => {
|
||||||
|
const newIds = ids.filter((id) => id !== action.roomId);
|
||||||
|
if (action.type === 'PUT') newIds.push(action.roomId);
|
||||||
|
return newIds;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
export const useBindAllRoomsAtom = (mx: MatrixClient, allRooms: typeof allRoomsAtom) => {
|
||||||
|
useBindRoomsWithMembershipsAtom(
|
||||||
|
mx,
|
||||||
|
allRooms,
|
||||||
|
useMemo(() => [Membership.Join], [])
|
||||||
|
);
|
||||||
|
};
|
120
src/app/state/roomToParents.ts
Normal file
120
src/app/state/roomToParents.ts
Normal file
|
@ -0,0 +1,120 @@
|
||||||
|
import produce from 'immer';
|
||||||
|
import { atom, useSetAtom } from 'jotai';
|
||||||
|
import {
|
||||||
|
ClientEvent,
|
||||||
|
MatrixClient,
|
||||||
|
MatrixEvent,
|
||||||
|
Room,
|
||||||
|
RoomEvent,
|
||||||
|
RoomStateEvent,
|
||||||
|
} from 'matrix-js-sdk';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Membership, RoomToParents, StateEvent } from '../../types/matrix/room';
|
||||||
|
import {
|
||||||
|
getRoomToParents,
|
||||||
|
getSpaceChildren,
|
||||||
|
isSpace,
|
||||||
|
isValidChild,
|
||||||
|
mapParentWithChildren,
|
||||||
|
} from '../utils/room';
|
||||||
|
|
||||||
|
export type RoomToParentsAction =
|
||||||
|
| {
|
||||||
|
type: 'INITIALIZE';
|
||||||
|
roomToParents: RoomToParents;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'PUT';
|
||||||
|
parent: string;
|
||||||
|
children: string[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'DELETE';
|
||||||
|
roomId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseRoomToParents = atom<RoomToParents>(new Map());
|
||||||
|
export const roomToParentsAtom = atom<RoomToParents, [RoomToParentsAction], undefined>(
|
||||||
|
(get) => get(baseRoomToParents),
|
||||||
|
(get, set, action) => {
|
||||||
|
if (action.type === 'INITIALIZE') {
|
||||||
|
set(baseRoomToParents, action.roomToParents);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action.type === 'PUT') {
|
||||||
|
set(
|
||||||
|
baseRoomToParents,
|
||||||
|
produce(get(baseRoomToParents), (draftRoomToParents) => {
|
||||||
|
mapParentWithChildren(draftRoomToParents, action.parent, action.children);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action.type === 'DELETE') {
|
||||||
|
set(
|
||||||
|
baseRoomToParents,
|
||||||
|
produce(get(baseRoomToParents), (draftRoomToParents) => {
|
||||||
|
const noParentRooms: string[] = [];
|
||||||
|
draftRoomToParents.delete(action.roomId);
|
||||||
|
draftRoomToParents.forEach((parents, child) => {
|
||||||
|
parents.delete(action.roomId);
|
||||||
|
if (parents.size === 0) noParentRooms.push(child);
|
||||||
|
});
|
||||||
|
noParentRooms.forEach((room) => draftRoomToParents.delete(room));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const useBindRoomToParentsAtom = (
|
||||||
|
mx: MatrixClient,
|
||||||
|
roomToParents: typeof roomToParentsAtom
|
||||||
|
) => {
|
||||||
|
const setRoomToParents = useSetAtom(roomToParents);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRoomToParents({ type: 'INITIALIZE', roomToParents: getRoomToParents(mx) });
|
||||||
|
|
||||||
|
const handleAddRoom = (room: Room) => {
|
||||||
|
if (isSpace(room) && room.getMyMembership() !== Membership.Invite) {
|
||||||
|
setRoomToParents({ type: 'PUT', parent: room.roomId, children: getSpaceChildren(room) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMembershipChange = (room: Room, membership: string) => {
|
||||||
|
if (isSpace(room) && membership === Membership.Join) {
|
||||||
|
setRoomToParents({ type: 'PUT', parent: room.roomId, children: getSpaceChildren(room) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStateChange = (mEvent: MatrixEvent) => {
|
||||||
|
if (mEvent.getType() === StateEvent.SpaceChild) {
|
||||||
|
const childId = mEvent.getStateKey();
|
||||||
|
const roomId = mEvent.getRoomId();
|
||||||
|
if (childId && roomId) {
|
||||||
|
if (isValidChild(mEvent)) {
|
||||||
|
setRoomToParents({ type: 'PUT', parent: roomId, children: [childId] });
|
||||||
|
} else {
|
||||||
|
setRoomToParents({ type: 'DELETE', roomId: childId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteRoom = (roomId: string) => {
|
||||||
|
setRoomToParents({ type: 'DELETE', roomId });
|
||||||
|
};
|
||||||
|
|
||||||
|
mx.on(ClientEvent.Room, handleAddRoom);
|
||||||
|
mx.on(RoomEvent.MyMembership, handleMembershipChange);
|
||||||
|
mx.on(RoomStateEvent.Events, handleStateChange);
|
||||||
|
mx.on(ClientEvent.DeleteRoom, handleDeleteRoom);
|
||||||
|
return () => {
|
||||||
|
mx.removeListener(ClientEvent.Room, handleAddRoom);
|
||||||
|
mx.removeListener(RoomEvent.MyMembership, handleMembershipChange);
|
||||||
|
mx.removeListener(RoomStateEvent.Events, handleStateChange);
|
||||||
|
mx.removeListener(ClientEvent.DeleteRoom, handleDeleteRoom);
|
||||||
|
};
|
||||||
|
}, [mx, setRoomToParents]);
|
||||||
|
};
|
219
src/app/state/roomToUnread.ts
Normal file
219
src/app/state/roomToUnread.ts
Normal file
|
@ -0,0 +1,219 @@
|
||||||
|
import produce from 'immer';
|
||||||
|
import { atom, useSetAtom, PrimitiveAtom, useAtomValue } from 'jotai';
|
||||||
|
import { IRoomTimelineData, MatrixClient, MatrixEvent, Room, RoomEvent } from 'matrix-js-sdk';
|
||||||
|
import { ReceiptContent, ReceiptType } from 'matrix-js-sdk/lib/@types/read_receipts';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
MuteChanges,
|
||||||
|
Membership,
|
||||||
|
NotificationType,
|
||||||
|
RoomToUnread,
|
||||||
|
UnreadInfo,
|
||||||
|
} from '../../types/matrix/room';
|
||||||
|
import {
|
||||||
|
getAllParents,
|
||||||
|
getNotificationType,
|
||||||
|
getUnreadInfo,
|
||||||
|
getUnreadInfos,
|
||||||
|
isNotificationEvent,
|
||||||
|
roomHaveUnread,
|
||||||
|
} from '../utils/room';
|
||||||
|
import { roomToParentsAtom } from './roomToParents';
|
||||||
|
|
||||||
|
export type RoomToUnreadAction =
|
||||||
|
| {
|
||||||
|
type: 'RESET';
|
||||||
|
unreadInfos: UnreadInfo[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'PUT';
|
||||||
|
unreadInfo: UnreadInfo;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'DELETE';
|
||||||
|
roomId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const putUnreadInfo = (
|
||||||
|
roomToUnread: RoomToUnread,
|
||||||
|
allParents: Set<string>,
|
||||||
|
unreadInfo: UnreadInfo
|
||||||
|
) => {
|
||||||
|
const oldUnread = roomToUnread.get(unreadInfo.roomId) ?? { highlight: 0, total: 0, from: null };
|
||||||
|
roomToUnread.set(unreadInfo.roomId, {
|
||||||
|
highlight: unreadInfo.highlight,
|
||||||
|
total: unreadInfo.total,
|
||||||
|
from: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const newH = unreadInfo.highlight - oldUnread.highlight;
|
||||||
|
const newT = unreadInfo.total - oldUnread.total;
|
||||||
|
|
||||||
|
allParents.forEach((parentId) => {
|
||||||
|
const oldParentUnread = roomToUnread.get(parentId) ?? { highlight: 0, total: 0, from: null };
|
||||||
|
roomToUnread.set(parentId, {
|
||||||
|
highlight: (oldParentUnread.highlight += newH),
|
||||||
|
total: (oldParentUnread.total += newT),
|
||||||
|
from: new Set([...(oldParentUnread.from ?? []), unreadInfo.roomId]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteUnreadInfo = (roomToUnread: RoomToUnread, allParents: Set<string>, roomId: string) => {
|
||||||
|
const oldUnread = roomToUnread.get(roomId);
|
||||||
|
if (!oldUnread) return;
|
||||||
|
roomToUnread.delete(roomId);
|
||||||
|
|
||||||
|
allParents.forEach((parentId) => {
|
||||||
|
const oldParentUnread = roomToUnread.get(parentId);
|
||||||
|
if (!oldParentUnread) return;
|
||||||
|
const newFrom = new Set([...(oldParentUnread.from ?? roomId)]);
|
||||||
|
newFrom.delete(roomId);
|
||||||
|
if (newFrom.size === 0) {
|
||||||
|
roomToUnread.delete(parentId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
roomToUnread.set(parentId, {
|
||||||
|
highlight: oldParentUnread.highlight - oldUnread.highlight,
|
||||||
|
total: oldParentUnread.total - oldUnread.total,
|
||||||
|
from: newFrom,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseRoomToUnread = atom<RoomToUnread>(new Map());
|
||||||
|
export const roomToUnreadAtom = atom<RoomToUnread, [RoomToUnreadAction], undefined>(
|
||||||
|
(get) => get(baseRoomToUnread),
|
||||||
|
(get, set, action) => {
|
||||||
|
if (action.type === 'RESET') {
|
||||||
|
const draftRoomToUnread: RoomToUnread = new Map();
|
||||||
|
action.unreadInfos.forEach((unreadInfo) => {
|
||||||
|
putUnreadInfo(
|
||||||
|
draftRoomToUnread,
|
||||||
|
getAllParents(get(roomToParentsAtom), unreadInfo.roomId),
|
||||||
|
unreadInfo
|
||||||
|
);
|
||||||
|
});
|
||||||
|
set(baseRoomToUnread, draftRoomToUnread);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action.type === 'PUT') {
|
||||||
|
set(
|
||||||
|
baseRoomToUnread,
|
||||||
|
produce(get(baseRoomToUnread), (draftRoomToUnread) =>
|
||||||
|
putUnreadInfo(
|
||||||
|
draftRoomToUnread,
|
||||||
|
getAllParents(get(roomToParentsAtom), action.unreadInfo.roomId),
|
||||||
|
action.unreadInfo
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action.type === 'DELETE' && get(baseRoomToUnread).has(action.roomId)) {
|
||||||
|
set(
|
||||||
|
baseRoomToUnread,
|
||||||
|
produce(get(baseRoomToUnread), (draftRoomToUnread) =>
|
||||||
|
deleteUnreadInfo(
|
||||||
|
draftRoomToUnread,
|
||||||
|
getAllParents(get(roomToParentsAtom), action.roomId),
|
||||||
|
action.roomId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const useBindRoomToUnreadAtom = (
|
||||||
|
mx: MatrixClient,
|
||||||
|
unreadAtom: typeof roomToUnreadAtom,
|
||||||
|
muteChangesAtom: PrimitiveAtom<MuteChanges>
|
||||||
|
) => {
|
||||||
|
const setUnreadAtom = useSetAtom(unreadAtom);
|
||||||
|
const muteChanges = useAtomValue(muteChangesAtom);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setUnreadAtom({
|
||||||
|
type: 'RESET',
|
||||||
|
unreadInfos: getUnreadInfos(mx),
|
||||||
|
});
|
||||||
|
}, [mx, setUnreadAtom]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleTimelineEvent = (
|
||||||
|
mEvent: MatrixEvent,
|
||||||
|
room: Room | undefined,
|
||||||
|
toStartOfTimeline: boolean | undefined,
|
||||||
|
removed: boolean,
|
||||||
|
data: IRoomTimelineData
|
||||||
|
) => {
|
||||||
|
if (!room || !data.liveEvent || room.isSpaceRoom() || !isNotificationEvent(mEvent)) return;
|
||||||
|
if (getNotificationType(mx, room.roomId) === NotificationType.Mute) {
|
||||||
|
setUnreadAtom({
|
||||||
|
type: 'DELETE',
|
||||||
|
roomId: room.roomId,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mEvent.getSender() === mx.getUserId()) return;
|
||||||
|
setUnreadAtom({ type: 'PUT', unreadInfo: getUnreadInfo(room) });
|
||||||
|
};
|
||||||
|
mx.on(RoomEvent.Timeline, handleTimelineEvent);
|
||||||
|
return () => {
|
||||||
|
mx.removeListener(RoomEvent.Timeline, handleTimelineEvent);
|
||||||
|
};
|
||||||
|
}, [mx, setUnreadAtom]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleReceipt = (mEvent: MatrixEvent, room: Room) => {
|
||||||
|
if (mEvent.getType() === 'm.receipt') {
|
||||||
|
const myUserId = mx.getUserId();
|
||||||
|
if (!myUserId) return;
|
||||||
|
if (room.isSpaceRoom()) return;
|
||||||
|
const content = mEvent.getContent<ReceiptContent>();
|
||||||
|
|
||||||
|
const isMyReceipt = Object.keys(content).find((eventId) =>
|
||||||
|
(Object.keys(content[eventId]) as ReceiptType[]).find(
|
||||||
|
(receiptType) => content[eventId][receiptType][myUserId]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (isMyReceipt) {
|
||||||
|
setUnreadAtom({ type: 'DELETE', roomId: room.roomId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
mx.on(RoomEvent.Receipt, handleReceipt);
|
||||||
|
return () => {
|
||||||
|
mx.removeListener(RoomEvent.Receipt, handleReceipt);
|
||||||
|
};
|
||||||
|
}, [mx, setUnreadAtom]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
muteChanges.removed.forEach((roomId) => {
|
||||||
|
const room = mx.getRoom(roomId);
|
||||||
|
if (!room) return;
|
||||||
|
if (!roomHaveUnread(mx, room)) return;
|
||||||
|
setUnreadAtom({ type: 'PUT', unreadInfo: getUnreadInfo(room) });
|
||||||
|
});
|
||||||
|
muteChanges.added.forEach((roomId) => {
|
||||||
|
setUnreadAtom({ type: 'DELETE', roomId });
|
||||||
|
});
|
||||||
|
}, [mx, setUnreadAtom, muteChanges]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleMembershipChange = (room: Room, membership: string) => {
|
||||||
|
if (membership !== Membership.Join) {
|
||||||
|
setUnreadAtom({
|
||||||
|
type: 'DELETE',
|
||||||
|
roomId: room.roomId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
mx.on(RoomEvent.MyMembership, handleMembershipChange);
|
||||||
|
return () => {
|
||||||
|
mx.removeListener(RoomEvent.MyMembership, handleMembershipChange);
|
||||||
|
};
|
||||||
|
}, [mx, setUnreadAtom]);
|
||||||
|
};
|
129
src/app/state/sessions.ts
Normal file
129
src/app/state/sessions.ts
Normal file
|
@ -0,0 +1,129 @@
|
||||||
|
import { atom } from 'jotai';
|
||||||
|
import {
|
||||||
|
atomWithLocalStorage,
|
||||||
|
getLocalStorageItem,
|
||||||
|
setLocalStorageItem,
|
||||||
|
} from './utils/atomWithLocalStorage';
|
||||||
|
|
||||||
|
export type Session = {
|
||||||
|
baseUrl: string;
|
||||||
|
userId: string;
|
||||||
|
deviceId: string;
|
||||||
|
accessToken: string;
|
||||||
|
expiresInMs?: number;
|
||||||
|
refreshToken?: string;
|
||||||
|
fallbackSdkStores?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Sessions = Session[];
|
||||||
|
export type SessionStoreName = {
|
||||||
|
sync: string;
|
||||||
|
crypto: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration code for old session
|
||||||
|
*/
|
||||||
|
const FALLBACK_STORE_NAME: SessionStoreName = {
|
||||||
|
sync: 'web-sync-store',
|
||||||
|
crypto: 'crypto-store',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const removeFallbackSession = () => {
|
||||||
|
localStorage.removeItem('cinny_hs_base_url');
|
||||||
|
localStorage.removeItem('cinny_user_id');
|
||||||
|
localStorage.removeItem('cinny_device_id');
|
||||||
|
localStorage.removeItem('cinny_access_token');
|
||||||
|
};
|
||||||
|
const getFallbackSession = (): Session | undefined => {
|
||||||
|
const baseUrl = localStorage.getItem('cinny_hs_base_url');
|
||||||
|
const userId = localStorage.getItem('cinny_user_id');
|
||||||
|
const deviceId = localStorage.getItem('cinny_device_id');
|
||||||
|
const accessToken = localStorage.getItem('cinny_access_token');
|
||||||
|
|
||||||
|
if (baseUrl && userId && deviceId && accessToken) {
|
||||||
|
const session: Session = {
|
||||||
|
baseUrl,
|
||||||
|
userId,
|
||||||
|
deviceId,
|
||||||
|
accessToken,
|
||||||
|
fallbackSdkStores: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* End of migration code for old session
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const getSessionStoreName = (session: Session): SessionStoreName => {
|
||||||
|
if (session.fallbackSdkStores) {
|
||||||
|
return FALLBACK_STORE_NAME;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sync: `sync${session.userId}`,
|
||||||
|
crypto: `crypto${session.userId}`,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MATRIX_SESSIONS_KEY = 'matrixSessions';
|
||||||
|
const baseSessionsAtom = atomWithLocalStorage<Sessions>(
|
||||||
|
MATRIX_SESSIONS_KEY,
|
||||||
|
(key) => {
|
||||||
|
const defaultSessions: Sessions = [];
|
||||||
|
const sessions = getLocalStorageItem(key, defaultSessions);
|
||||||
|
|
||||||
|
// Before multi account support session was stored
|
||||||
|
// as multiple item in local storage.
|
||||||
|
// So we need these migration code.
|
||||||
|
const fallbackSession = getFallbackSession();
|
||||||
|
if (fallbackSession) {
|
||||||
|
removeFallbackSession();
|
||||||
|
sessions.push(fallbackSession);
|
||||||
|
setLocalStorageItem(key, sessions);
|
||||||
|
}
|
||||||
|
return sessions;
|
||||||
|
},
|
||||||
|
(key, value) => {
|
||||||
|
setLocalStorageItem(key, value);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export type SessionsAction =
|
||||||
|
| {
|
||||||
|
type: 'PUT';
|
||||||
|
session: Session;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'DELETE';
|
||||||
|
session: Session;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sessionsAtom = atom<Sessions, [SessionsAction], undefined>(
|
||||||
|
(get) => get(baseSessionsAtom),
|
||||||
|
(get, set, action) => {
|
||||||
|
if (action.type === 'PUT') {
|
||||||
|
const sessions = [...get(baseSessionsAtom)];
|
||||||
|
const sessionIndex = sessions.findIndex(
|
||||||
|
(session) => session.userId === action.session.userId
|
||||||
|
);
|
||||||
|
if (sessionIndex === -1) {
|
||||||
|
sessions.push(action.session);
|
||||||
|
} else {
|
||||||
|
sessions.splice(sessionIndex, 1, action.session);
|
||||||
|
}
|
||||||
|
set(baseSessionsAtom, sessions);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action.type === 'DELETE') {
|
||||||
|
const sessions = get(baseSessionsAtom).filter(
|
||||||
|
(session) => session.userId !== action.session.userId
|
||||||
|
);
|
||||||
|
set(baseSessionsAtom, sessions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue