Merge branch 'dev' into multiple-docker-build
This commit is contained in:
commit
2551d41a56
97 changed files with 4311 additions and 1915 deletions
3
.github/FUNDING.yml
vendored
3
.github/FUNDING.yml
vendored
|
@ -1,2 +1,3 @@
|
|||
github: ajbura
|
||||
liberapay: ajbura
|
||||
open_collective: cinny
|
||||
liberapay: ajbura
|
8
.github/workflows/build-pull-request.yml
vendored
8
.github/workflows/build-pull-request.yml
vendored
|
@ -11,23 +11,23 @@ jobs:
|
|||
PR_NUMBER: ${{github.event.number}}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3.0.1
|
||||
uses: actions/checkout@v3.0.2
|
||||
- name: Build app
|
||||
run: npm ci && npm run build
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v3.0.0
|
||||
uses: actions/upload-artifact@v3.1.0
|
||||
with:
|
||||
name: previewbuild
|
||||
path: dist
|
||||
retention-days: 1
|
||||
- name: Get PR info
|
||||
uses: actions/github-script@v6.0.0
|
||||
uses: actions/github-script@v6.1.0
|
||||
with:
|
||||
script: |
|
||||
var fs = require('fs');
|
||||
fs.writeFileSync('${{github.workspace}}/pr.json', JSON.stringify(context.payload.pull_request));
|
||||
- name: Upload PR Info
|
||||
uses: actions/upload-artifact@v3.0.0
|
||||
uses: actions/upload-artifact@v3.1.0
|
||||
with:
|
||||
name: pr.json
|
||||
path: pr.json
|
||||
|
|
11
.github/workflows/deploy-pull-request.yml
vendored
11
.github/workflows/deploy-pull-request.yml
vendored
|
@ -6,6 +6,9 @@ on:
|
|||
- completed
|
||||
jobs:
|
||||
get-build-and-deploy:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
|
@ -14,7 +17,7 @@ jobs:
|
|||
# workflow_run action (https://github.com/actions/download-artifact/issues/60)
|
||||
# so instead we get this mess:
|
||||
- name: Download artifact
|
||||
uses: actions/github-script@v6.0.0
|
||||
uses: actions/github-script@v6.1.0
|
||||
with:
|
||||
script: |
|
||||
var artifacts = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
|
@ -48,7 +51,7 @@ jobs:
|
|||
run: unzip -d dist previewbuild.zip && rm previewbuild.zip && unzip pr.json.zip && rm pr.json.zip
|
||||
- name: Read PR Info
|
||||
id: readctx
|
||||
uses: actions/github-script@v6.0.0
|
||||
uses: actions/github-script@v6.1.0
|
||||
with:
|
||||
script: |
|
||||
var fs = require('fs');
|
||||
|
@ -56,7 +59,7 @@ jobs:
|
|||
console.log(`::set-output name=prnumber::${pr.number}`);
|
||||
- name: Deploy to Netlify
|
||||
id: netlify
|
||||
uses: nwtgck/actions-netlify@v1.2.3
|
||||
uses: nwtgck/actions-netlify@b7c1504e00c6b8a249d1848cc1b522a4865eed99
|
||||
with:
|
||||
publish-dir: dist
|
||||
deploy-message: "Deploy from GitHub Actions"
|
||||
|
@ -68,7 +71,7 @@ jobs:
|
|||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE3_ID }}
|
||||
timeout-minutes: 1
|
||||
- name: Edit PR Description
|
||||
uses: Beakyn/gha-comment-pull-request@v1.0.2
|
||||
uses: Beakyn/gha-comment-pull-request@2167a7aee24f9e61ce76a23039f322e49a990409
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
|
|
4
.github/workflows/docker-pr.yml
vendored
4
.github/workflows/docker-pr.yml
vendored
|
@ -13,13 +13,13 @@ jobs:
|
|||
PR_NUMBER: ${{github.event.number}}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3.0.1
|
||||
uses: actions/checkout@v3.0.2
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1.2.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1.6.0
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@v2.10.0
|
||||
uses: docker/build-push-action@v3.0.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
|
7
.github/workflows/netlify-dev.yml
vendored
7
.github/workflows/netlify-dev.yml
vendored
|
@ -9,12 +9,13 @@ jobs:
|
|||
deploy-to-netlify:
|
||||
name: 'Deploy'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3.0.1
|
||||
uses: actions/checkout@v3.0.2
|
||||
- name: Build and deploy to Netlify
|
||||
uses: jsmrcaga/action-netlify-deploy@v1.7.2
|
||||
uses: jsmrcaga/action-netlify-deploy@fb6a5f936a4b06a8f7793e69fc5a022ffe39807a
|
||||
with:
|
||||
install_command: "npm ci"
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
|
|
64
.github/workflows/prod-deploy.yml
vendored
64
.github/workflows/prod-deploy.yml
vendored
|
@ -5,14 +5,48 @@ on:
|
|||
types: [published]
|
||||
|
||||
jobs:
|
||||
deploy-to-netlify:
|
||||
name: 'Deploy to Netlify'
|
||||
create-release-tar:
|
||||
name: 'Create release tar'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3.0.1
|
||||
uses: actions/checkout@v3.0.2
|
||||
- name: Build
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
- name: Get version from tag
|
||||
id: vars
|
||||
run: echo ::set-output name=tag::${GITHUB_REF#refs/*/}
|
||||
- name: Create tar.gz
|
||||
run: tar -czvf cinny-${{ steps.vars.outputs.tag }}.tar.gz dist
|
||||
- name: Sign tar.gz
|
||||
run: |
|
||||
echo '${{ secrets.GNUPG_KEY }}' | gpg --batch --import
|
||||
# Sadly a few lines in the private key match a few lines in the public key,
|
||||
# As a result just --export --armor gives us a few lines replaced with ***
|
||||
# making it useless for importing the signing key. Instead, we dump it as
|
||||
# non-armored and hex-encode it so that its printable.
|
||||
echo "PGP Signing key, in raw PGP format in hex. Import with cat ... | xxd -r -p - | gpg --import"
|
||||
gpg --export | xxd -p
|
||||
echo '${{ secrets.GNUPG_PASSPHRASE }}' | gpg --batch --yes --pinentry-mode loopback --passphrase-fd 0 --armor --detach-sign cinny-${{ steps.vars.outputs.tag }}.tar.gz
|
||||
- name: Upload tagged release
|
||||
uses: softprops/action-gh-release@1e07f4398721186383de40550babbdf2b84acfc5
|
||||
with:
|
||||
files: |
|
||||
cinny-${{ steps.vars.outputs.tag }}.tar.gz
|
||||
cinny-${{ steps.vars.outputs.tag }}.tar.gz.asc
|
||||
|
||||
deploy-to-netlify:
|
||||
name: 'Deploy to Netlify'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3.0.2
|
||||
- name: Build and deploy to Netlify
|
||||
uses: jsmrcaga/action-netlify-deploy@v1.7.2
|
||||
uses: jsmrcaga/action-netlify-deploy@fb6a5f936a4b06a8f7793e69fc5a022ffe39807a
|
||||
with:
|
||||
install_command: "npm ci"
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
|
@ -20,39 +54,31 @@ jobs:
|
|||
BUILD_DIRECTORY: "dist"
|
||||
NETLIFY_DEPLOY_MESSAGE: "Prod deploy v${{ github.ref }}"
|
||||
NETLIFY_DEPLOY_TO_PROD: true
|
||||
- name: Get version from tag
|
||||
id: vars
|
||||
run: echo ::set-output name=tag::${GITHUB_REF#refs/*/}
|
||||
- name: Create tar.gz
|
||||
run: tar -czvf cinny-${{ steps.vars.outputs.tag }}.tar.gz dist
|
||||
- name: Upload tagged release
|
||||
uses: softprops/action-gh-release@v0.1.14
|
||||
with:
|
||||
files: |
|
||||
cinny-${{ steps.vars.outputs.tag }}.tar.gz
|
||||
|
||||
push_to_dockerhub:
|
||||
push-to-dockerhub:
|
||||
name: Push Docker image to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3.0.1
|
||||
uses: actions/checkout@v3.0.2
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1.2.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1.6.0
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v1.14.1
|
||||
uses: docker/login-action@v2.0.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v3.7.0
|
||||
uses: docker/metadata-action@v4.0.1
|
||||
with:
|
||||
images: ajbura/cinny
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v2.10.0
|
||||
uses: docker/build-push-action@v3.0.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
|
2
LICENSE
2
LICENSE
|
@ -1,6 +1,6 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2021 Ajay Bura (ajbura) and contributors
|
||||
Copyright (c) 2021 Ajay Bura (ajbura)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
|
83
README.md
83
README.md
|
@ -1,18 +1,27 @@
|
|||
# Cinny
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/ajbura/cinny/dev/public/res/svg/cinny.svg?sanitize=true"
|
||||
height="16">
|
||||
<span><b>Cinny</b></span>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://github.com/ajbura/cinny/releases">
|
||||
<img alt="GitHub release downloads" src="https://img.shields.io/github/downloads/ajbura/cinny/total?logo=github&style=social"></a>
|
||||
<a href="https://hub.docker.com/r/ajbura/cinny">
|
||||
<img alt="DockerHub downloads" src="https://img.shields.io/docker/pulls/ajbura/cinny?logo=docker&style=social"></a>
|
||||
<a href="https://fosstodon.org/@cinnyapp">
|
||||
<img alt="Follow on Mastodon" src="https://img.shields.io/mastodon/follow/106845779685925461?domain=https%3A%2F%2Ffosstodon.org&logo=mastodon&style=social"></a>
|
||||
<a href="https://twitter.com/intent/follow?screen_name=cinnyapp">
|
||||
<img alt="Follow on Twitter" src="https://img.shields.io/twitter/follow/cinnyapp?logo=twitter&style=social"></a>
|
||||
<a href="https://cinny.in/#sponsor">
|
||||
<img alt="Sponsor Cinny" src="https://img.shields.io/opencollective/all/cinny?logo=opencollective&style=social"></a>
|
||||
</p>
|
||||
|
||||
## Table of Contents
|
||||
**Cinny** is a Matrix client focusing primarily on simple, elegant and secure interface. The main goal is to have a client that is easy on end user
|
||||
and feels a modern chat application.
|
||||
|
||||
- [About](#about)
|
||||
- [Getting Started](https://cinny.in)
|
||||
- [Contributing](./CONTRIBUTING.md)
|
||||
- [Roadmap](https://github.com/ajbura/cinny/projects/11)
|
||||
|
||||
## About <a name = "about"></a>
|
||||
|
||||
Cinny is a [Matrix](https://matrix.org) client focusing primarily on simple, elegant and secure interface.
|
||||
|
||||

|
||||
|
||||
## Building and Running
|
||||
|
||||
### Running pre-compiled
|
||||
|
@ -20,7 +29,57 @@ Cinny is a [Matrix](https://matrix.org) client focusing primarily on simple, ele
|
|||
A tarball of pre-compiled version of the app is provided with each [release](https://github.com/ajbura/cinny/releases).
|
||||
You can serve the application with a webserver of your choosing by simply copying `dist/` directory to the webroot.
|
||||
|
||||
<details>
|
||||
<summary>PGP Public Key to verify pre-compiled tarball</summary>
|
||||
|
||||
```
|
||||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
|
||||
mQGNBGJw/g0BDAC8qQeLqDMzYzfPyOmRlHVEoguVTo+eo1aVdQH2X7OELdjjBlyj
|
||||
6d6c1adv/uF2g83NNMoQY7GEeHjRnXE4m8kYSaarb840pxrYUagDc0dAbJOGaCBY
|
||||
FKTo7U1Kvg0vdiaRuus0pvc1NVdXSxRNQbFXBSwduD+zn66TI3HfcEHNN62FG1cE
|
||||
K1jWDwLAU0P3kKmj8+CAc3h9ZklPu0k/+t5bf/LJkvdBJAUzGZpehbPL5f3u3BZ0
|
||||
leZLIrR8uV7PiV5jKFahxlKR5KQHld8qQm+qVhYbUzpuMBGmh419I6UvTzxuRcvU
|
||||
Frn9ttCEzV55Y+so4X2e4ZnB+5gOnNw+ecifGVdj/+UyWnqvqqDvLrEjjK890nLb
|
||||
Pil4siecNMEpiwAN6WSmKpWaCwQAHEGDVeZCc/kT0iYfj5FBcsTVqWiO6eaxkUlm
|
||||
jnulqWqRrlB8CJQQvih/g//uSEBdzIibo+ro+3Jpe120U/XVUH62i9HoRQEm6ADG
|
||||
4zS5hIq4xyA8fL8AEQEAAbQdQ2lubnlBcHAgPGNpbm55YXBwQGdtYWlsLmNvbT6J
|
||||
AdQEEwEIAD4WIQSRri2MHidaaZv+vvuUMwx6UK/M8wUCYnD+DQIbAwUJA8JnAAUL
|
||||
CQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRCUMwx6UK/M88ApC/9HAdbum1lYBC0s
|
||||
1k7GwP2A7B4sQtBWjy771BzybWlHeaeG+BGJwg4YiuowXZMm5dubFJFoI/CfeY07
|
||||
B5aK40/bmT6Xcfkp0VA74c1wUpubBUEJN7tH5HG/OGd9BKeq9E/HHtVaJLVT1k3w
|
||||
Rhv9VuHO6nR30EEp7IDthftotl5S4lio3+W0pKk4TAKV8vjaCNp3y/lAHzoP1BU9
|
||||
bUSao+7GXVeArKBjuqxN+t1uuiaxPH4L0oe2pMVjTig04zGJM5fTVoly859MEcC/
|
||||
R7Taq9RWGfXFmgCXy8Dviz3eOD90vqpCzhX4+ypK0cp2X0UwhMH4dpKUzExmdbhl
|
||||
eBO5GcHB4VxvloRBNf9/Lr7YOTgWejMUw+MlhZE2RE8unfW1LnM/cjL4dhXzO/XB
|
||||
FUHHNq8d6d4e02rfWqw7mZo2/NVJgFRcvzw2rgx7w7CKtCNwF4lNjUetB2waZzDb
|
||||
fAE0kwhK4Iuwvy12JOBzL0Yy9MxANtwUryr/LQz9AmdT4Rwnp0S5AY0EYnD+DQEM
|
||||
ANOu/d6ZMF8bW+Df9RDCUQKytbaZfa+ZbIHBus7whCD/SQMOhPKntv3HX7SmMCs+
|
||||
5i27kJMu4YN623JCS7hdCoXVO1R5kXCEcneW/rPBMDutaM472YvIWMIqK9Wwl5+0
|
||||
Piu2N+uTkKhe9uS2u7eN+Khef3d7xfjGRxoppM+xI9dZO+jhYiy8LuC0oBohTjJq
|
||||
QPqfGDpowBwRkkOsGz/XVcesJ1Pzg4bKivTS9kZjZSyT9RRSY8As0sVUN57AwYul
|
||||
s1+eh00n/tVpi2Jj9pCm7S0csSXvXj8v2OTdK1jt4YjpzR0/rwh4+/xlOjDjZEqH
|
||||
vMPhpzpbgnwkxZ3X8BFne9dJ3maC5zQ3LAeCP5m1W0hXzagYhfyjo74slJgD1O8c
|
||||
LDf2Oxc5MyM8Y/UK497zfqSPfgT3NhQmhHzk83DjXw3I6Z3A3U+Jp61w0eBRI1nx
|
||||
H1UIG+gldcAKUTcfwL0lghoT3nmi9JAbvek0Smhz00Bbo8/dx8vwQRxDUxlt7Exx
|
||||
NwARAQABiQG8BBgBCAAmFiEEka4tjB4nWmmb/r77lDMMelCvzPMFAmJw/g0CGwwF
|
||||
CQPCZwAACgkQlDMMelCvzPPT7Qv8CjXUEhphZFLwpBfaNOzRNfIXJST9aDit8zHW
|
||||
IMmfSpORVfpU71IyIB3o/DtTUPwCeb8nvNJs7aj1QT1ZUSsqFa3yY2S16V/g8+WN
|
||||
sHca6oDSc1J+A0eEpEL1HbG1b5OPBC0AeGvvMOoqrbqThBZVKg1Jc/0SD3cvKElv
|
||||
aHeCZCNNmfcZ2Ib4HYhhc8//ZtC9TeI+5J/YesctY1M12EoWMxMrc27Y3P5Pa0BI
|
||||
Uc3qxWggPq1vOFYsEshL0w99HyJvREJmQA7Fa0crV+rICxyrBxJeNnEvjH/0KCBU
|
||||
LCkEonLY1QwrxyeeV3VpxGE3zHHE3azOdAjTIoAdzX5f/qhbgYlM68GL2f8xdDkp
|
||||
O0igSGHWhO4F8BfmE7IOTx1Bi7daczp8nCFxh73cKpKB0RUsd9xxrqYpovjmEAlo
|
||||
w7aHpdzt64NQcsrbK10OSVDF3gFa9Vz20/NQvdUrp8jGmAb/8+nYqI94Jsc28H36
|
||||
UeGsouhyuITLwEhScounZDqop+Dx
|
||||
=Zg+6
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
||||
```
|
||||
</details>
|
||||
|
||||
### Building from source
|
||||
> 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 is 16.15.0 LTS.
|
||||
|
||||
Execute the following commands to compile the app from its source code:
|
||||
|
||||
|
@ -31,7 +90,7 @@ npm run build # Compiles the app into the dist/ directory
|
|||
|
||||
You can then copy the files to a webserver's webroot of your choice.
|
||||
|
||||
To serve a development version of the app locally for testing, you may also use the command `npm start`.
|
||||
To serve a development version of the app locally for testing, you need to use the command `npm start`.
|
||||
|
||||
### Running with Docker
|
||||
|
||||
|
@ -59,7 +118,7 @@ To set default Homeserver on login and register page, place a customized [`confi
|
|||
|
||||
## License
|
||||
|
||||
Copyright (c) 2021 Ajay Bura (ajbura) and contributors
|
||||
Copyright (c) 2021 Ajay Bura (ajbura)
|
||||
|
||||
Code licensed under the MIT License: <http://opensource.org/licenses/MIT>
|
||||
|
||||
|
|
|
@ -7,5 +7,6 @@
|
|||
"kde.org",
|
||||
"matrix.org",
|
||||
"chat.mozilla.org"
|
||||
]
|
||||
],
|
||||
"allowCustomHomeservers": true
|
||||
}
|
3105
package-lock.json
generated
3105
package-lock.json
generated
File diff suppressed because it is too large
Load diff
44
package.json
44
package.json
|
@ -1,11 +1,11 @@
|
|||
{
|
||||
"name": "cinny",
|
||||
"version": "1.8.2",
|
||||
"version": "2.0.3",
|
||||
"description": "Yet another matrix client",
|
||||
"main": "index.js",
|
||||
"engines": {
|
||||
"npm": ">=6.14.11",
|
||||
"node": ">=14.6.0"
|
||||
"npm": ">=6.14.8 <=8.5.5",
|
||||
"node": ">=14.15.0 <=17.9.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "webpack serve --config ./webpack.dev.js --open",
|
||||
|
@ -15,8 +15,8 @@
|
|||
"author": "Ajay Bura",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fontsource/inter": "^4.5.7",
|
||||
"@fontsource/roboto": "^4.5.5",
|
||||
"@fontsource/inter": "^4.5.10",
|
||||
"@fontsource/roboto": "^4.5.7",
|
||||
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz",
|
||||
"@tippyjs/react": "^4.2.6",
|
||||
"babel-polyfill": "^6.26.0",
|
||||
|
@ -26,11 +26,13 @@
|
|||
"file-saver": "^2.0.5",
|
||||
"flux": "^4.0.3",
|
||||
"formik": "^2.2.9",
|
||||
"html-react-parser": "^1.4.11",
|
||||
"html-react-parser": "^1.4.12",
|
||||
"katex": "^0.15.6",
|
||||
"linkifyjs": "^2.1.9",
|
||||
"matrix-js-sdk": "^17.0.0",
|
||||
"matrix-js-sdk": "^17.2.0",
|
||||
"micromark": "^3.0.10",
|
||||
"micromark-extension-gfm": "^2.0.1",
|
||||
"micromark-extension-math": "^2.0.2",
|
||||
"micromark-util-chunked": "^1.0.0",
|
||||
"micromark-util-resolve-all": "^1.0.0",
|
||||
"micromark-util-symbol": "^1.0.1",
|
||||
|
@ -41,45 +43,45 @@
|
|||
"react-dnd-html5-backend": "^15.1.3",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-google-recaptcha": "^2.1.0",
|
||||
"react-modal": "^3.14.4",
|
||||
"react-modal": "^3.15.1",
|
||||
"sanitize-html": "^2.7.0",
|
||||
"tippy.js": "^6.3.7",
|
||||
"twemoji": "^14.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.17.9",
|
||||
"@babel/preset-env": "^7.16.11",
|
||||
"@babel/preset-react": "^7.16.7",
|
||||
"@babel/core": "^7.18.0",
|
||||
"@babel/preset-env": "^7.18.0",
|
||||
"@babel/preset-react": "^7.17.12",
|
||||
"assert": "^2.0.0",
|
||||
"babel-loader": "^8.2.4",
|
||||
"babel-loader": "^8.2.5",
|
||||
"browserify-fs": "^1.0.0",
|
||||
"buffer": "^6.0.3",
|
||||
"clean-webpack-plugin": "^4.0.0",
|
||||
"copy-webpack-plugin": "^10.2.4",
|
||||
"copy-webpack-plugin": "^11.0.0",
|
||||
"crypto-browserify": "^3.12.0",
|
||||
"css-loader": "^6.7.1",
|
||||
"css-minimizer-webpack-plugin": "^3.4.1",
|
||||
"eslint": "^8.13.0",
|
||||
"css-minimizer-webpack-plugin": "^4.0.0",
|
||||
"eslint": "^8.16.0",
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.4.1",
|
||||
"eslint-plugin-react": "^7.29.4",
|
||||
"eslint-plugin-react-hooks": "^4.4.0",
|
||||
"eslint-plugin-react": "^7.30.0",
|
||||
"eslint-plugin-react-hooks": "^4.5.0",
|
||||
"favicons": "^6.2.2",
|
||||
"favicons-webpack-plugin": "^5.0.2",
|
||||
"html-loader": "^3.1.0",
|
||||
"html-webpack-plugin": "^5.3.1",
|
||||
"mini-css-extract-plugin": "^2.6.0",
|
||||
"path-browserify": "^1.0.1",
|
||||
"sass": "^1.50.0",
|
||||
"sass-loader": "^12.6.0",
|
||||
"sass": "^1.52.1",
|
||||
"sass-loader": "^13.0.0",
|
||||
"stream-browserify": "^3.0.0",
|
||||
"style-loader": "^3.3.1",
|
||||
"url": "^0.11.0",
|
||||
"util": "^0.12.4",
|
||||
"webpack": "^5.72.0",
|
||||
"webpack": "^5.72.1",
|
||||
"webpack-cli": "^4.9.2",
|
||||
"webpack-dev-server": "^4.8.1",
|
||||
"webpack-dev-server": "^4.9.0",
|
||||
"webpack-merge": "^5.7.3"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,5 +18,11 @@
|
|||
</head>
|
||||
<body id="appBody">
|
||||
<div id="root"></div>
|
||||
<audio id="notificationSound">
|
||||
<source src="./sound/notification.ogg" type="audio/ogg" />
|
||||
</audio>
|
||||
<audio id="inviteSound">
|
||||
<source src="./sound/invite.ogg" type="audio/ogg" />
|
||||
</audio>
|
||||
</body>
|
||||
</html>
|
|
@ -11,11 +11,12 @@ const IconButton = React.forwardRef(({
|
|||
variant, size, type,
|
||||
tooltip, tooltipPlacement, src,
|
||||
onClick, tabIndex, disabled, isImage,
|
||||
className,
|
||||
}, ref) => {
|
||||
const btn = (
|
||||
<button
|
||||
ref={ref}
|
||||
className={`ic-btn ic-btn-${variant}`}
|
||||
className={`ic-btn ic-btn-${variant} ${className}`}
|
||||
onMouseUp={(e) => blurOnBubbling(e, `.ic-btn-${variant}`)}
|
||||
onClick={onClick}
|
||||
// eslint-disable-next-line react/button-has-type
|
||||
|
@ -47,6 +48,7 @@ IconButton.defaultProps = {
|
|||
tabIndex: 0,
|
||||
disabled: false,
|
||||
isImage: false,
|
||||
className: '',
|
||||
};
|
||||
|
||||
IconButton.propTypes = {
|
||||
|
@ -60,6 +62,7 @@ IconButton.propTypes = {
|
|||
tabIndex: PropTypes.number,
|
||||
disabled: PropTypes.bool,
|
||||
isImage: PropTypes.bool,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
export default IconButton;
|
||||
|
|
59
src/app/atoms/card/InfoCard.jsx
Normal file
59
src/app/atoms/card/InfoCard.jsx
Normal file
|
@ -0,0 +1,59 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './InfoCard.scss';
|
||||
|
||||
import Text from '../text/Text';
|
||||
import RawIcon from '../system-icons/RawIcon';
|
||||
import IconButton from '../button/IconButton';
|
||||
|
||||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||
|
||||
function InfoCard({
|
||||
className, style,
|
||||
variant, iconSrc,
|
||||
title, content,
|
||||
rounded, requestClose,
|
||||
}) {
|
||||
const classes = [`info-card info-card--${variant}`];
|
||||
if (rounded) classes.push('info-card--rounded');
|
||||
if (className) classes.push(className);
|
||||
return (
|
||||
<div className={classes.join(' ')} style={style}>
|
||||
{iconSrc && (
|
||||
<div className="info-card__icon">
|
||||
<RawIcon color={`var(--ic-${variant}-high)`} src={iconSrc} />
|
||||
</div>
|
||||
)}
|
||||
<div className="info-card__content">
|
||||
<Text>{title}</Text>
|
||||
{content}
|
||||
</div>
|
||||
{requestClose && (
|
||||
<IconButton src={CrossIC} variant={variant} onClick={requestClose} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
InfoCard.defaultProps = {
|
||||
className: null,
|
||||
style: null,
|
||||
variant: 'surface',
|
||||
iconSrc: null,
|
||||
content: null,
|
||||
rounded: false,
|
||||
requestClose: null,
|
||||
};
|
||||
|
||||
InfoCard.propTypes = {
|
||||
className: PropTypes.string,
|
||||
style: PropTypes.shape({}),
|
||||
variant: PropTypes.oneOf(['surface', 'primary', 'positive', 'caution', 'danger']),
|
||||
iconSrc: PropTypes.string,
|
||||
title: PropTypes.string.isRequired,
|
||||
content: PropTypes.node,
|
||||
rounded: PropTypes.bool,
|
||||
requestClose: PropTypes.func,
|
||||
};
|
||||
|
||||
export default InfoCard;
|
79
src/app/atoms/card/InfoCard.scss
Normal file
79
src/app/atoms/card/InfoCard.scss
Normal file
|
@ -0,0 +1,79 @@
|
|||
@use '.././../partials/flex';
|
||||
@use '.././../partials/dir';
|
||||
|
||||
.info-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
line-height: 0;
|
||||
padding: var(--sp-tight);
|
||||
@include dir.prop(border-left, 4px solid transparent, none);
|
||||
@include dir.prop(border-right, none, 4px solid transparent);
|
||||
|
||||
& > .ic-btn {
|
||||
padding: 0;
|
||||
border-radius: 4;
|
||||
}
|
||||
|
||||
&__content {
|
||||
margin: 0 var(--sp-tight);
|
||||
@extend .cp-fx__item-one;
|
||||
|
||||
& > *:nth-child(2) {
|
||||
margin-top: var(--sp-ultra-tight);
|
||||
}
|
||||
}
|
||||
|
||||
&--rounded {
|
||||
@include dir.prop(
|
||||
border-radius,
|
||||
0 var(--bo-radius) var(--bo-radius) 0,
|
||||
var(--bo-radius) 0 0 var(--bo-radius)
|
||||
);
|
||||
}
|
||||
|
||||
&--surface {
|
||||
border-color: var(--bg-surface-border);
|
||||
background-color: var(--bg-surface-hover);
|
||||
|
||||
}
|
||||
&--primary {
|
||||
border-color: var(--bg-primary);
|
||||
background-color: var(--bg-primary-hover);
|
||||
& .text {
|
||||
color: var(--tc-primary-high);
|
||||
&-b3 {
|
||||
color: var(--tc-primary-normal);
|
||||
}
|
||||
}
|
||||
}
|
||||
&--positive {
|
||||
border-color: var(--bg-positive-border);
|
||||
background-color: var(--bg-positive-hover);
|
||||
& .text {
|
||||
color: var(--tc-positive-high);
|
||||
&-b3 {
|
||||
color: var(--tc-positive-normal);
|
||||
}
|
||||
}
|
||||
}
|
||||
&--caution {
|
||||
border-color: var(--bg-caution-border);
|
||||
background-color: var(--bg-caution-hover);
|
||||
& .text {
|
||||
color: var(--tc-caution-high);
|
||||
&-b3 {
|
||||
color: var(--tc-caution-normal);
|
||||
}
|
||||
}
|
||||
}
|
||||
&--danger {
|
||||
border-color: var(--bg-danger-border);
|
||||
background-color: var(--bg-danger-hover);
|
||||
& .text {
|
||||
color: var(--tc-danger-high);
|
||||
&-b3 {
|
||||
color: var(--tc-danger-normal);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
33
src/app/atoms/math/Math.jsx
Normal file
33
src/app/atoms/math/Math.jsx
Normal file
|
@ -0,0 +1,33 @@
|
|||
import React, { useEffect, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import katex from 'katex';
|
||||
import 'katex/dist/katex.min.css';
|
||||
|
||||
import 'katex/dist/contrib/copy-tex';
|
||||
import 'katex/dist/contrib/copy-tex.css';
|
||||
|
||||
const Math = React.memo(({
|
||||
content, throwOnError, errorColor, displayMode,
|
||||
}) => {
|
||||
const ref = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
katex.render(content, ref.current, { throwOnError, errorColor, displayMode });
|
||||
}, [content, throwOnError, errorColor, displayMode]);
|
||||
|
||||
return <span ref={ref} />;
|
||||
});
|
||||
Math.defaultProps = {
|
||||
throwOnError: null,
|
||||
errorColor: null,
|
||||
displayMode: null,
|
||||
};
|
||||
Math.propTypes = {
|
||||
content: PropTypes.string.isRequired,
|
||||
throwOnError: PropTypes.bool,
|
||||
errorColor: PropTypes.string,
|
||||
displayMode: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default Math;
|
25
src/app/hooks/useCrossSigningStatus.js
Normal file
25
src/app/hooks/useCrossSigningStatus.js
Normal file
|
@ -0,0 +1,25 @@
|
|||
/* eslint-disable import/prefer-default-export */
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
import initMatrix from '../../client/initMatrix';
|
||||
import { hasCrossSigningAccountData } from '../../util/matrixUtil';
|
||||
|
||||
export function useCrossSigningStatus() {
|
||||
const mx = initMatrix.matrixClient;
|
||||
const [isCSEnabled, setIsCSEnabled] = useState(hasCrossSigningAccountData());
|
||||
|
||||
useEffect(() => {
|
||||
if (isCSEnabled) return null;
|
||||
const handleAccountData = (event) => {
|
||||
if (event.getType() === 'm.cross_signing.master') {
|
||||
setIsCSEnabled(true);
|
||||
}
|
||||
};
|
||||
|
||||
mx.on('accountData', handleAccountData);
|
||||
return () => {
|
||||
mx.removeListener('accountData', handleAccountData);
|
||||
};
|
||||
}, [isCSEnabled === false]);
|
||||
return isCSEnabled;
|
||||
}
|
32
src/app/hooks/useDeviceList.js
Normal file
32
src/app/hooks/useDeviceList.js
Normal file
|
@ -0,0 +1,32 @@
|
|||
/* eslint-disable import/prefer-default-export */
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
import initMatrix from '../../client/initMatrix';
|
||||
|
||||
export function useDeviceList() {
|
||||
const mx = initMatrix.matrixClient;
|
||||
const [deviceList, setDeviceList] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const updateDevices = () => mx.getDevices().then((data) => {
|
||||
if (!isMounted) return;
|
||||
setDeviceList(data.devices || []);
|
||||
});
|
||||
updateDevices();
|
||||
|
||||
const handleDevicesUpdate = (users) => {
|
||||
if (users.includes(mx.getUserId())) {
|
||||
updateDevices();
|
||||
}
|
||||
};
|
||||
|
||||
mx.on('crypto.devicesUpdated', handleDevicesUpdate);
|
||||
return () => {
|
||||
mx.removeListener('crypto.devicesUpdated', handleDevicesUpdate);
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
return deviceList;
|
||||
}
|
58
src/app/molecules/confirm-dialog/ConfirmDialog.jsx
Normal file
58
src/app/molecules/confirm-dialog/ConfirmDialog.jsx
Normal file
|
@ -0,0 +1,58 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './ConfirmDialog.scss';
|
||||
|
||||
import { openReusableDialog } from '../../../client/action/navigation';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import Button from '../../atoms/button/Button';
|
||||
|
||||
function ConfirmDialog({
|
||||
desc, actionTitle, actionType, onComplete,
|
||||
}) {
|
||||
return (
|
||||
<div className="confirm-dialog">
|
||||
<Text>{desc}</Text>
|
||||
<div className="confirm-dialog__btn">
|
||||
<Button variant={actionType} onClick={() => onComplete(true)}>{actionTitle}</Button>
|
||||
<Button onClick={() => onComplete(false)}>Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
ConfirmDialog.propTypes = {
|
||||
desc: PropTypes.string.isRequired,
|
||||
actionTitle: PropTypes.string.isRequired,
|
||||
actionType: PropTypes.oneOf(['primary', 'positive', 'danger', 'caution']).isRequired,
|
||||
onComplete: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string} title title of confirm dialog
|
||||
* @param {string} desc description of confirm dialog
|
||||
* @param {string} actionTitle title of main action to take
|
||||
* @param {'primary' | 'positive' | 'danger' | 'caution'} actionType type of action. default=primary
|
||||
* @return {Promise<boolean>} does it get's confirmed or not
|
||||
*/
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const confirmDialog = (title, desc, actionTitle, actionType = 'primary') => new Promise((resolve) => {
|
||||
let isCompleted = false;
|
||||
openReusableDialog(
|
||||
<Text variant="s1" weight="medium">{title}</Text>,
|
||||
(requestClose) => (
|
||||
<ConfirmDialog
|
||||
desc={desc}
|
||||
actionTitle={actionTitle}
|
||||
actionType={actionType}
|
||||
onComplete={(isConfirmed) => {
|
||||
isCompleted = true;
|
||||
resolve(isConfirmed);
|
||||
requestClose();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
() => {
|
||||
if (!isCompleted) resolve(false);
|
||||
},
|
||||
);
|
||||
});
|
11
src/app/molecules/confirm-dialog/ConfirmDialog.scss
Normal file
11
src/app/molecules/confirm-dialog/ConfirmDialog.scss
Normal file
|
@ -0,0 +1,11 @@
|
|||
.confirm-dialog {
|
||||
padding: var(--sp-normal);
|
||||
|
||||
& > .text {
|
||||
padding-bottom: var(--sp-normal);
|
||||
}
|
||||
&__btn {
|
||||
display: flex;
|
||||
gap: var(--sp-normal);
|
||||
}
|
||||
}
|
|
@ -16,7 +16,7 @@ function Dialog({
|
|||
}) {
|
||||
return (
|
||||
<RawModal
|
||||
className={`${className === null ? '' : `${className} `}dialog-model`}
|
||||
className={`${className === null ? '' : `${className} `}dialog-modal`}
|
||||
isOpen={isOpen}
|
||||
onAfterOpen={onAfterOpen}
|
||||
onAfterClose={onAfterClose}
|
||||
|
@ -37,7 +37,7 @@ function Dialog({
|
|||
{contentOptions}
|
||||
</Header>
|
||||
<div className="dialog__content__wrapper">
|
||||
<ScrollView autoHide invisible={invisibleScroll}>
|
||||
<ScrollView autoHide={!invisibleScroll} invisible={invisibleScroll}>
|
||||
<div className="dialog__content-container">
|
||||
{children}
|
||||
</div>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
.dialog-model {
|
||||
.dialog-modal {
|
||||
--modal-height: 656px;
|
||||
max-height: min(100%, var(--modal-height));
|
||||
display: flex;
|
||||
|
|
|
@ -24,7 +24,7 @@ function ReusableDialog() {
|
|||
}, []);
|
||||
|
||||
const handleAfterClose = () => {
|
||||
data.afterClose();
|
||||
data.afterClose?.();
|
||||
setData(null);
|
||||
};
|
||||
|
||||
|
|
|
@ -36,6 +36,8 @@ import TickMarkIC from '../../../../public/res/ic/outlined/tick-mark.svg';
|
|||
import CmdIC from '../../../../public/res/ic/outlined/cmd.svg';
|
||||
import BinIC from '../../../../public/res/ic/outlined/bin.svg';
|
||||
|
||||
import { confirmDialog } from '../confirm-dialog/ConfirmDialog';
|
||||
|
||||
function PlaceholderMessage() {
|
||||
return (
|
||||
<div className="ph-msg">
|
||||
|
@ -121,17 +123,26 @@ const MessageReplyWrapper = React.memo(({ roomTimeline, eventId }) => {
|
|||
const eTimeline = await mx.getEventTimeline(timelineSet, eventId);
|
||||
await roomTimeline.decryptAllEventsOfTimeline(eTimeline);
|
||||
|
||||
const mEvent = eTimeline.getTimelineSet().findEventById(eventId);
|
||||
let mEvent = eTimeline.getTimelineSet().findEventById(eventId);
|
||||
const editedList = roomTimeline.editedTimeline.get(mEvent.getId());
|
||||
if (editedList) {
|
||||
mEvent = editedList[editedList.length - 1];
|
||||
}
|
||||
|
||||
const rawBody = mEvent.getContent().body;
|
||||
const username = getUsernameOfRoomMember(mEvent.sender);
|
||||
|
||||
if (isMountedRef.current === false) return;
|
||||
const fallbackBody = mEvent.isRedacted() ? '*** This message has been deleted ***' : '*** Unable to load reply ***';
|
||||
let parsedBody = parseReply(rawBody)?.body ?? rawBody ?? fallbackBody;
|
||||
if (editedList && parsedBody.startsWith(' * ')) {
|
||||
parsedBody = parsedBody.slice(3);
|
||||
}
|
||||
|
||||
setReply({
|
||||
to: username,
|
||||
color: colorMXID(mEvent.getSender()),
|
||||
body: parseReply(rawBody)?.body ?? rawBody ?? fallbackBody,
|
||||
body: parsedBody,
|
||||
event: mEvent,
|
||||
});
|
||||
} catch {
|
||||
|
@ -189,7 +200,7 @@ const MessageBody = React.memo(({
|
|||
let content = null;
|
||||
if (isCustomHTML) {
|
||||
try {
|
||||
content = twemojify(sanitizeCustomHtml(body), undefined, true, false);
|
||||
content = twemojify(sanitizeCustomHtml(body), undefined, true, false, true);
|
||||
} catch {
|
||||
console.error('Malformed custom html: ', body);
|
||||
content = twemojify(body, undefined);
|
||||
|
@ -546,10 +557,15 @@ const MessageOptions = React.memo(({
|
|||
<MenuItem
|
||||
variant="danger"
|
||||
iconSrc={BinIC}
|
||||
onClick={() => {
|
||||
if (window.confirm('Are you sure that you want to delete this event?')) {
|
||||
redactEvent(roomId, mEvent.getId());
|
||||
}
|
||||
onClick={async () => {
|
||||
const isConfirmed = await confirmDialog(
|
||||
'Delete message',
|
||||
'Are you sure that you want to delete this message?',
|
||||
'Delete',
|
||||
'danger',
|
||||
);
|
||||
if (!isConfirmed) return;
|
||||
redactEvent(roomId, mEvent.getId());
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
@use '../../atoms/scroll/scrollbar';
|
||||
@use '../../partials/text';
|
||||
@use '../../partials/dir';
|
||||
@use '../../partials/screen';
|
||||
|
||||
.message,
|
||||
.ph-msg {
|
||||
|
@ -95,7 +96,7 @@
|
|||
.message__reactions {
|
||||
max-width: calc(100% - 88px);
|
||||
min-width: 0;
|
||||
@media (max-width: 1124px) {
|
||||
@include screen.smallerThan(tabletBreakpoint) {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -58,7 +58,8 @@ function PopupWindow({
|
|||
|
||||
return (
|
||||
<RawModal
|
||||
className={`${className === null ? '' : `${className} `}pw-model`}
|
||||
className={`${className === null ? '' : `${className} `}pw-modal`}
|
||||
overlayClassName="pw-modal__overlay"
|
||||
isOpen={isOpen}
|
||||
onAfterClose={onAfterClose}
|
||||
onRequestClose={onRequestClose}
|
||||
|
|
|
@ -1,9 +1,18 @@
|
|||
@use '../../partials/dir';
|
||||
@use '../../partials/screen';
|
||||
|
||||
.pw-model {
|
||||
.pw-modal {
|
||||
--modal-height: 774px;
|
||||
max-height: var(--modal-height) !important;
|
||||
height: 100%;
|
||||
|
||||
@include screen.smallerThan(mobileBreakpoint) {
|
||||
--modal-height: 100%;
|
||||
border-radius: 0 !important;
|
||||
&__overlay {
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pw {
|
||||
|
@ -72,4 +81,4 @@
|
|||
@include dir.side(margin, 0, var(--sp-tight));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,6 +8,8 @@ import Text from '../../atoms/text/Text';
|
|||
import Toggle from '../../atoms/button/Toggle';
|
||||
import SettingTile from '../setting-tile/SettingTile';
|
||||
|
||||
import { confirmDialog } from '../confirm-dialog/ConfirmDialog';
|
||||
|
||||
function RoomEncryption({ roomId }) {
|
||||
const mx = initMatrix.matrixClient;
|
||||
const room = mx.getRoom(roomId);
|
||||
|
@ -15,17 +17,20 @@ function RoomEncryption({ roomId }) {
|
|||
const [isEncrypted, setIsEncrypted] = useState(encryptionEvents.length > 0);
|
||||
const canEnableEncryption = room.currentState.maySendStateEvent('m.room.encryption', mx.getUserId());
|
||||
|
||||
const handleEncryptionEnable = () => {
|
||||
const handleEncryptionEnable = async () => {
|
||||
const joinRule = room.getJoinRule();
|
||||
const confirmMsg1 = 'It is not recommended to add encryption in public room. Anyone can find and join public rooms, so anyone can read messages in them.';
|
||||
const confirmMsg2 = 'Once enabled, encryption for a room cannot be disabled. Messages sent in an encrypted room cannot be seen by the server, only by the participants of the room. Enabling encryption may prevent many bots and bridges from working correctly';
|
||||
if (joinRule === 'public' ? confirm(confirmMsg1) : true) {
|
||||
if (confirm(confirmMsg2)) {
|
||||
setIsEncrypted(true);
|
||||
mx.sendStateEvent(roomId, 'm.room.encryption', {
|
||||
algorithm: 'm.megolm.v1.aes-sha2',
|
||||
});
|
||||
}
|
||||
|
||||
const isConfirmed1 = (joinRule === 'public')
|
||||
? await confirmDialog('Enable encryption', confirmMsg1, 'Continue', 'caution')
|
||||
: true;
|
||||
if (!isConfirmed1) return;
|
||||
if (await confirmDialog('Enable encryption', confirmMsg2, 'Enable', 'caution')) {
|
||||
setIsEncrypted(true);
|
||||
mx.sendStateEvent(roomId, 'm.room.encryption', {
|
||||
algorithm: 'm.megolm.v1.aes-sha2',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -2,7 +2,6 @@ import React from 'react';
|
|||
import PropTypes from 'prop-types';
|
||||
import './RoomIntro.scss';
|
||||
|
||||
import { twemojify } from '../../../util/twemojify';
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
|
@ -15,8 +14,8 @@ function RoomIntro({
|
|||
<div className="room-intro">
|
||||
<Avatar imageSrc={avatarSrc} text={name} bgColor={colorMXID(roomId)} size="large" />
|
||||
<div className="room-intro__content">
|
||||
<Text className="room-intro__name" variant="h1" weight="medium" primary>{twemojify(heading)}</Text>
|
||||
<Text className="room-intro__desc" variant="b1">{twemojify(desc, undefined, true)}</Text>
|
||||
<Text className="room-intro__name" variant="h1" weight="medium" primary>{heading}</Text>
|
||||
<Text className="room-intro__desc" variant="b1">{desc}</Text>
|
||||
{ time !== null && <Text className="room-intro__time" variant="b3">{time}</Text>}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -35,9 +34,9 @@ RoomIntro.propTypes = {
|
|||
PropTypes.bool,
|
||||
]),
|
||||
name: PropTypes.string.isRequired,
|
||||
heading: PropTypes.string.isRequired,
|
||||
desc: PropTypes.string.isRequired,
|
||||
time: PropTypes.string,
|
||||
heading: PropTypes.node.isRequired,
|
||||
desc: PropTypes.node.isRequired,
|
||||
time: PropTypes.node,
|
||||
};
|
||||
|
||||
export default RoomIntro;
|
||||
|
|
|
@ -15,6 +15,8 @@ import TickMarkIC from '../../../../public/res/ic/outlined/tick-mark.svg';
|
|||
import AddUserIC from '../../../../public/res/ic/outlined/add-user.svg';
|
||||
import LeaveArrowIC from '../../../../public/res/ic/outlined/leave-arrow.svg';
|
||||
|
||||
import { confirmDialog } from '../confirm-dialog/ConfirmDialog';
|
||||
|
||||
function RoomOptions({ roomId, afterOptionSelect }) {
|
||||
const mx = initMatrix.matrixClient;
|
||||
const room = mx.getRoom(roomId);
|
||||
|
@ -29,11 +31,16 @@ function RoomOptions({ roomId, afterOptionSelect }) {
|
|||
openInviteUser(roomId);
|
||||
afterOptionSelect();
|
||||
};
|
||||
const handleLeaveClick = () => {
|
||||
if (confirm('Are you sure that you want to leave this room?')) {
|
||||
roomActions.leave(roomId);
|
||||
afterOptionSelect();
|
||||
}
|
||||
const handleLeaveClick = async () => {
|
||||
afterOptionSelect();
|
||||
const isConfirmed = await confirmDialog(
|
||||
'Leave room',
|
||||
`Are you sure that you want to leave "${room.name}" room?`,
|
||||
'Leave',
|
||||
'danger',
|
||||
);
|
||||
if (!isConfirmed) return;
|
||||
roomActions.leave(roomId);
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
@ -19,6 +19,7 @@ import PencilIC from '../../../../public/res/ic/outlined/pencil.svg';
|
|||
|
||||
import { useStore } from '../../hooks/useStore';
|
||||
import { useForceUpdate } from '../../hooks/useForceUpdate';
|
||||
import { confirmDialog } from '../confirm-dialog/ConfirmDialog';
|
||||
|
||||
function RoomProfile({ roomId }) {
|
||||
const isMountStore = useStore();
|
||||
|
@ -117,7 +118,13 @@ function RoomProfile({ roomId }) {
|
|||
|
||||
const handleAvatarUpload = async (url) => {
|
||||
if (url === null) {
|
||||
if (confirm('Are you sure that you want to remove room avatar?')) {
|
||||
const isConfirmed = await confirmDialog(
|
||||
'Remove avatar',
|
||||
'Are you sure that you want to remove room avatar?',
|
||||
'Remove',
|
||||
'caution',
|
||||
);
|
||||
if (isConfirmed) {
|
||||
await mx.sendStateEvent(roomId, 'm.room.avatar', { url }, '');
|
||||
}
|
||||
} else await mx.sendStateEvent(roomId, 'm.room.avatar', { url }, '');
|
||||
|
|
|
@ -3,7 +3,6 @@ import PropTypes from 'prop-types';
|
|||
import './RoomTile.scss';
|
||||
|
||||
import { twemojify } from '../../../util/twemojify';
|
||||
import { sanitizeText } from '../../../util/sanitize';
|
||||
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
|
||||
|
|
|
@ -70,7 +70,7 @@ function RoomVisibility({ roomId }) {
|
|||
|
||||
const noSpaceParent = currentState.getStateEvents('m.space.parent').length === 0;
|
||||
const mCreate = currentState.getStateEvents('m.room.create')[0]?.getContent();
|
||||
const roomVersion = Number(mCreate.room_version);
|
||||
const roomVersion = Number(mCreate?.room_version ?? 0);
|
||||
|
||||
const myPowerlevel = room.getMember(mx.getUserId())?.powerLevel || 0;
|
||||
const canChange = room.currentState.hasSufficientPowerLevelFor('state_default', myPowerlevel);
|
||||
|
|
|
@ -9,11 +9,12 @@ import Tooltip from '../../atoms/tooltip/Tooltip';
|
|||
import { blurOnBubbling } from '../../atoms/button/script';
|
||||
|
||||
const SidebarAvatar = React.forwardRef(({
|
||||
tooltip, active, onClick, onContextMenu,
|
||||
avatar, notificationBadge,
|
||||
className, tooltip, active, onClick,
|
||||
onContextMenu, avatar, notificationBadge,
|
||||
}, ref) => {
|
||||
let activeClass = '';
|
||||
if (active) activeClass = ' sidebar-avatar--active';
|
||||
const classes = ['sidebar-avatar'];
|
||||
if (active) classes.push('sidebar-avatar--active');
|
||||
if (className) classes.push(className);
|
||||
return (
|
||||
<Tooltip
|
||||
content={<Text variant="b1">{twemojify(tooltip)}</Text>}
|
||||
|
@ -21,7 +22,7 @@ const SidebarAvatar = React.forwardRef(({
|
|||
>
|
||||
<button
|
||||
ref={ref}
|
||||
className={`sidebar-avatar${activeClass}`}
|
||||
className={classes.join(' ')}
|
||||
type="button"
|
||||
onMouseUp={(e) => blurOnBubbling(e, '.sidebar-avatar')}
|
||||
onClick={onClick}
|
||||
|
@ -34,6 +35,7 @@ const SidebarAvatar = React.forwardRef(({
|
|||
);
|
||||
});
|
||||
SidebarAvatar.defaultProps = {
|
||||
className: null,
|
||||
active: false,
|
||||
onClick: null,
|
||||
onContextMenu: null,
|
||||
|
@ -41,6 +43,7 @@ SidebarAvatar.defaultProps = {
|
|||
};
|
||||
|
||||
SidebarAvatar.propTypes = {
|
||||
className: PropTypes.string,
|
||||
tooltip: PropTypes.string.isRequired,
|
||||
active: PropTypes.bool,
|
||||
onClick: PropTypes.func,
|
||||
|
|
|
@ -24,6 +24,8 @@ import LeaveArrowIC from '../../../../public/res/ic/outlined/leave-arrow.svg';
|
|||
import PinIC from '../../../../public/res/ic/outlined/pin.svg';
|
||||
import PinFilledIC from '../../../../public/res/ic/filled/pin.svg';
|
||||
|
||||
import { confirmDialog } from '../confirm-dialog/ConfirmDialog';
|
||||
|
||||
function SpaceOptions({ roomId, afterOptionSelect }) {
|
||||
const mx = initMatrix.matrixClient;
|
||||
const room = mx.getRoom(roomId);
|
||||
|
@ -54,11 +56,16 @@ function SpaceOptions({ roomId, afterOptionSelect }) {
|
|||
afterOptionSelect();
|
||||
};
|
||||
|
||||
const handleLeaveClick = () => {
|
||||
if (confirm('Are you sure that you want to leave this space?')) {
|
||||
leave(roomId);
|
||||
afterOptionSelect();
|
||||
}
|
||||
const handleLeaveClick = async () => {
|
||||
afterOptionSelect();
|
||||
const isConfirmed = await confirmDialog(
|
||||
'Leave space',
|
||||
`Are you sure that you want to leave "${room.name}" space?`,
|
||||
'Leave',
|
||||
'danger',
|
||||
);
|
||||
if (!isConfirmed) return;
|
||||
leave(roomId);
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
@ -8,7 +8,7 @@ import Text from '../../atoms/text/Text';
|
|||
function DragDrop({ isOpen }) {
|
||||
return (
|
||||
<RawModal
|
||||
className="drag-drop__model"
|
||||
className="drag-drop__modal"
|
||||
overlayClassName="drag-drop__overlay"
|
||||
isOpen={isOpen}
|
||||
>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
.drag-drop__model {
|
||||
.drag-drop__modal {
|
||||
box-shadow: none;
|
||||
text-align: center;
|
||||
|
||||
|
|
|
@ -6,6 +6,8 @@
|
|||
--emoji-board-height: 390px;
|
||||
--emoji-board-width: 286px;
|
||||
display: flex;
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
|
||||
&__content {
|
||||
@extend .cp-fx__item-one;
|
||||
|
@ -91,6 +93,10 @@
|
|||
}
|
||||
}
|
||||
|
||||
.emoji-row {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.emoji-group {
|
||||
--emoji-padding: 6px;
|
||||
position: relative;
|
||||
|
|
200
src/app/organisms/emoji-verification/EmojiVerification.jsx
Normal file
200
src/app/organisms/emoji-verification/EmojiVerification.jsx
Normal file
|
@ -0,0 +1,200 @@
|
|||
/* eslint-disable react/prop-types */
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './EmojiVerification.scss';
|
||||
import { twemojify } from '../../../util/twemojify';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import cons from '../../../client/state/cons';
|
||||
import navigation from '../../../client/state/navigation';
|
||||
import { hasPrivateKey } from '../../../client/state/secretStorageKeys';
|
||||
import { getDefaultSSKey, isCrossVerified } from '../../../util/matrixUtil';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import IconButton from '../../atoms/button/IconButton';
|
||||
import Button from '../../atoms/button/Button';
|
||||
import Spinner from '../../atoms/spinner/Spinner';
|
||||
import Dialog from '../../molecules/dialog/Dialog';
|
||||
|
||||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||
import { useStore } from '../../hooks/useStore';
|
||||
import { accessSecretStorage } from '../settings/SecretStorageAccess';
|
||||
|
||||
function EmojiVerificationContent({ data, requestClose }) {
|
||||
const [sas, setSas] = useState(null);
|
||||
const [process, setProcess] = useState(false);
|
||||
const { request, targetDevice } = data;
|
||||
const mx = initMatrix.matrixClient;
|
||||
const mountStore = useStore();
|
||||
const beginStore = useStore();
|
||||
|
||||
const beginVerification = async () => {
|
||||
if (
|
||||
isCrossVerified(mx.deviceId)
|
||||
&& (mx.getCrossSigningId() === null || await mx.crypto.crossSigningInfo.isStoredInKeyCache('self_signing') === false)
|
||||
) {
|
||||
if (!hasPrivateKey(getDefaultSSKey())) {
|
||||
const keyData = await accessSecretStorage('Emoji verification');
|
||||
if (!keyData) {
|
||||
request.cancel();
|
||||
return;
|
||||
}
|
||||
}
|
||||
await mx.checkOwnCrossSigningTrust();
|
||||
}
|
||||
setProcess(true);
|
||||
await request.accept();
|
||||
|
||||
const verifier = request.beginKeyVerification('m.sas.v1', targetDevice);
|
||||
|
||||
const handleVerifier = (sasData) => {
|
||||
verifier.off('show_sas', handleVerifier);
|
||||
if (!mountStore.getItem()) return;
|
||||
setSas(sasData);
|
||||
setProcess(false);
|
||||
};
|
||||
verifier.on('show_sas', handleVerifier);
|
||||
await verifier.verify();
|
||||
};
|
||||
|
||||
const sasMismatch = () => {
|
||||
sas.mismatch();
|
||||
setProcess(true);
|
||||
};
|
||||
|
||||
const sasConfirm = () => {
|
||||
sas.confirm();
|
||||
setProcess(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
mountStore.setItem(true);
|
||||
const handleChange = () => {
|
||||
if (request.done || request.cancelled) {
|
||||
requestClose();
|
||||
return;
|
||||
}
|
||||
if (targetDevice && !beginStore.getItem()) {
|
||||
beginStore.setItem(true);
|
||||
beginVerification();
|
||||
}
|
||||
};
|
||||
|
||||
if (request === null) return null;
|
||||
const req = request;
|
||||
req.on('change', handleChange);
|
||||
return () => {
|
||||
req.off('change', handleChange);
|
||||
if (req.cancelled === false && req.done === false) {
|
||||
req.cancel();
|
||||
}
|
||||
};
|
||||
}, [request]);
|
||||
|
||||
const renderWait = () => (
|
||||
<>
|
||||
<Spinner size="small" />
|
||||
<Text>Waiting for response from other device...</Text>
|
||||
</>
|
||||
);
|
||||
|
||||
if (sas !== null) {
|
||||
return (
|
||||
<div className="emoji-verification__content">
|
||||
<Text>Confirm the emoji below are displayed on both devices, in the same order:</Text>
|
||||
<div className="emoji-verification__emojis">
|
||||
{sas.sas.emoji.map((emoji, i) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<div className="emoji-verification__emoji-block" key={`${emoji[1]}-${i}`}>
|
||||
<Text variant="h1">{twemojify(emoji[0])}</Text>
|
||||
<Text>{emoji[1]}</Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="emoji-verification__buttons">
|
||||
{process ? renderWait() : (
|
||||
<>
|
||||
<Button variant="primary" onClick={sasConfirm}>They match</Button>
|
||||
<Button onClick={sasMismatch}>{'They don\'t match'}</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (targetDevice) {
|
||||
return (
|
||||
<div className="emoji-verification__content">
|
||||
<Text>Please accept the request from other device.</Text>
|
||||
<div className="emoji-verification__buttons">
|
||||
{renderWait()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="emoji-verification__content">
|
||||
<Text>Click accept to start the verification process.</Text>
|
||||
<div className="emoji-verification__buttons">
|
||||
{
|
||||
process
|
||||
? renderWait()
|
||||
: <Button variant="primary" onClick={beginVerification}>Accept</Button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
EmojiVerificationContent.propTypes = {
|
||||
data: PropTypes.shape({}).isRequired,
|
||||
requestClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
function useVisibilityToggle() {
|
||||
const [data, setData] = useState(null);
|
||||
const mx = initMatrix.matrixClient;
|
||||
|
||||
useEffect(() => {
|
||||
const handleOpen = (request, targetDevice) => {
|
||||
setData({ request, targetDevice });
|
||||
};
|
||||
navigation.on(cons.events.navigation.EMOJI_VERIFICATION_OPENED, handleOpen);
|
||||
mx.on('crypto.verification.request', handleOpen);
|
||||
return () => {
|
||||
navigation.removeListener(cons.events.navigation.EMOJI_VERIFICATION_OPENED, handleOpen);
|
||||
mx.removeListener('crypto.verification.request', handleOpen);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const requestClose = () => setData(null);
|
||||
|
||||
return [data, requestClose];
|
||||
}
|
||||
|
||||
function EmojiVerification() {
|
||||
const [data, requestClose] = useVisibilityToggle();
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={data !== null}
|
||||
className="emoji-verification"
|
||||
title={(
|
||||
<Text variant="s1" weight="medium" primary>
|
||||
Emoji verification
|
||||
</Text>
|
||||
)}
|
||||
contentOptions={<IconButton src={CrossIC} onClick={requestClose} tooltip="Close" />}
|
||||
onRequestClose={requestClose}
|
||||
>
|
||||
{
|
||||
data !== null
|
||||
? <EmojiVerificationContent data={data} requestClose={requestClose} />
|
||||
: <div />
|
||||
}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default EmojiVerification;
|
35
src/app/organisms/emoji-verification/EmojiVerification.scss
Normal file
35
src/app/organisms/emoji-verification/EmojiVerification.scss
Normal file
|
@ -0,0 +1,35 @@
|
|||
@use '../../partials/flex';
|
||||
@use '../../partials/dir';
|
||||
|
||||
.emoji-verification {
|
||||
&__content {
|
||||
padding: var(--sp-normal);
|
||||
@include dir.side(padding, var(--sp-normal), var(--sp-extra-tight));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-normal);
|
||||
}
|
||||
|
||||
&__emojis {
|
||||
margin: var(--sp-loose) 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
gap: var(--sp-extra-tight);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__emoji-block {
|
||||
@extend .cp-fx__column;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: var(--sp-extra-tight);
|
||||
white-space: nowrap;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
&__buttons {
|
||||
display: flex;
|
||||
gap: var(--sp-normal);
|
||||
}
|
||||
}
|
|
@ -54,17 +54,20 @@ function InviteList({ isOpen, onRequestClose }) {
|
|||
}, [procInvite]);
|
||||
|
||||
function renderRoomTile(roomId) {
|
||||
const myRoom = initMatrix.matrixClient.getRoom(roomId);
|
||||
const mx = initMatrix.matrixClient;
|
||||
const myRoom = mx.getRoom(roomId);
|
||||
if (!myRoom) return null;
|
||||
const roomName = myRoom.name;
|
||||
let roomAlias = myRoom.getCanonicalAlias();
|
||||
if (roomAlias === null) roomAlias = myRoom.roomId;
|
||||
if (!roomAlias) roomAlias = myRoom.roomId;
|
||||
const inviterName = myRoom.getMember(mx.getUserId())?.events?.member?.getSender?.() ?? '';
|
||||
return (
|
||||
<RoomTile
|
||||
key={myRoom.roomId}
|
||||
name={roomName}
|
||||
avatarSrc={initMatrix.matrixClient.getRoom(roomId).getAvatarUrl(initMatrix.matrixClient.baseUrl, 42, 42, 'crop')}
|
||||
id={roomAlias}
|
||||
inviterName={myRoom.getJoinedMembers()[0].userId}
|
||||
inviterName={inviterName}
|
||||
options={
|
||||
procInvite.has(myRoom.roomId)
|
||||
? (<Spinner size="small" />)
|
||||
|
@ -95,12 +98,13 @@ function InviteList({ isOpen, onRequestClose }) {
|
|||
{
|
||||
Array.from(initMatrix.roomList.inviteDirects).map((roomId) => {
|
||||
const myRoom = initMatrix.matrixClient.getRoom(roomId);
|
||||
if (myRoom === null) return null;
|
||||
const roomName = myRoom.name;
|
||||
return (
|
||||
<RoomTile
|
||||
key={myRoom.roomId}
|
||||
name={roomName}
|
||||
id={myRoom.getDMInviter()}
|
||||
id={myRoom.getDMInviter() || roomId}
|
||||
options={
|
||||
procInvite.has(myRoom.roomId)
|
||||
? (<Spinner size="small" />)
|
||||
|
|
|
@ -103,6 +103,18 @@ function InviteUser({
|
|||
updateIsSearching(false);
|
||||
}
|
||||
|
||||
async function hasDevices(userId) {
|
||||
try {
|
||||
const usersDeviceMap = await mx.downloadKeys([userId, mx.getUserId()]);
|
||||
return Object.values(usersDeviceMap).every((userDevices) =>
|
||||
Object.keys(userDevices).length > 0,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("Error determining if it's possible to encrypt to all users: ", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createDM(userId) {
|
||||
if (mx.getUserId() === userId) return;
|
||||
const dmRoomId = hasDMWith(userId);
|
||||
|
@ -117,7 +129,7 @@ function InviteUser({
|
|||
procUserError.delete(userId);
|
||||
updateUserProcError(getMapCopy(procUserError));
|
||||
|
||||
const result = await roomActions.createDM(userId);
|
||||
const result = await roomActions.createDM(userId, await hasDevices(userId));
|
||||
roomIdToUserId.set(result.room_id, userId);
|
||||
updateRoomIdToUserId(getMapCopy(roomIdToUserId));
|
||||
} catch (e) {
|
||||
|
|
155
src/app/organisms/join-alias/JoinAlias.jsx
Normal file
155
src/app/organisms/join-alias/JoinAlias.jsx
Normal file
|
@ -0,0 +1,155 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './JoinAlias.scss';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import cons from '../../../client/state/cons';
|
||||
import navigation from '../../../client/state/navigation';
|
||||
import { join } from '../../../client/action/room';
|
||||
import { selectRoom, selectSpace } from '../../../client/action/navigation';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import IconButton from '../../atoms/button/IconButton';
|
||||
import Button from '../../atoms/button/Button';
|
||||
import Input from '../../atoms/input/Input';
|
||||
import Spinner from '../../atoms/spinner/Spinner';
|
||||
import Dialog from '../../molecules/dialog/Dialog';
|
||||
|
||||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||
|
||||
import { useStore } from '../../hooks/useStore';
|
||||
|
||||
const ALIAS_OR_ID_REG = /^[#|!].+:.+\..+$/;
|
||||
|
||||
function JoinAliasContent({ term, requestClose }) {
|
||||
const [process, setProcess] = useState(false);
|
||||
const [error, setError] = useState(undefined);
|
||||
const [lastJoinId, setLastJoinId] = useState(undefined);
|
||||
|
||||
const mx = initMatrix.matrixClient;
|
||||
const mountStore = useStore();
|
||||
|
||||
const openRoom = (roomId) => {
|
||||
const room = mx.getRoom(roomId);
|
||||
if (!room) return;
|
||||
if (room.isSpaceRoom()) selectSpace(roomId);
|
||||
else selectRoom(roomId);
|
||||
requestClose();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleJoin = (roomId) => {
|
||||
if (lastJoinId !== roomId) return;
|
||||
openRoom(roomId);
|
||||
};
|
||||
initMatrix.roomList.on(cons.events.roomList.ROOM_JOINED, handleJoin);
|
||||
return () => {
|
||||
initMatrix.roomList.removeListener(cons.events.roomList.ROOM_JOINED, handleJoin);
|
||||
};
|
||||
}, [lastJoinId]);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
mountStore.setItem(true);
|
||||
const alias = e.target.alias.value;
|
||||
if (alias?.trim() === '') return;
|
||||
if (alias.match(ALIAS_OR_ID_REG) === null) {
|
||||
setError('Invalid address.');
|
||||
return;
|
||||
}
|
||||
setProcess('Looking for address...');
|
||||
setError(undefined);
|
||||
let via;
|
||||
if (alias.startsWith('#')) {
|
||||
try {
|
||||
const aliasData = await mx.resolveRoomAlias(alias);
|
||||
via = aliasData?.servers.slice(0, 3) || [];
|
||||
if (mountStore.getItem()) {
|
||||
setProcess(`Joining ${alias}...`);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!mountStore.getItem()) return;
|
||||
setProcess(false);
|
||||
setError(`Unable to find room/space with ${alias}. Either room/space is private or doesn't exist.`);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const roomId = await join(alias, false, via);
|
||||
if (!mountStore.getItem()) return;
|
||||
setLastJoinId(roomId);
|
||||
openRoom(roomId);
|
||||
} catch {
|
||||
if (!mountStore.getItem()) return;
|
||||
setProcess(false);
|
||||
setError(`Unable to join ${alias}. Either room/space is private or doesn't exist.`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="join-alias" onSubmit={handleSubmit}>
|
||||
<Input
|
||||
label="Address"
|
||||
value={term}
|
||||
name="alias"
|
||||
required
|
||||
/>
|
||||
{error && <Text className="join-alias__error" variant="b3">{error}</Text>}
|
||||
<div className="join-alias__btn">
|
||||
{
|
||||
process
|
||||
? (
|
||||
<>
|
||||
<Spinner size="small" />
|
||||
<Text>{process}</Text>
|
||||
</>
|
||||
)
|
||||
: <Button variant="primary" type="submit">Join</Button>
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
JoinAliasContent.defaultProps = {
|
||||
term: undefined,
|
||||
};
|
||||
JoinAliasContent.propTypes = {
|
||||
term: PropTypes.string,
|
||||
requestClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
function useWindowToggle() {
|
||||
const [data, setData] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleOpen = (term) => {
|
||||
setData({ term });
|
||||
};
|
||||
navigation.on(cons.events.navigation.JOIN_ALIAS_OPENED, handleOpen);
|
||||
return () => {
|
||||
navigation.removeListener(cons.events.navigation.JOIN_ALIAS_OPENED, handleOpen);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onRequestClose = () => setData(null);
|
||||
|
||||
return [data, onRequestClose];
|
||||
}
|
||||
|
||||
function JoinAlias() {
|
||||
const [data, requestClose] = useWindowToggle();
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={data !== null}
|
||||
title={(
|
||||
<Text variant="s1" weight="medium" primary>Join with address</Text>
|
||||
)}
|
||||
contentOptions={<IconButton src={CrossIC} onClick={requestClose} tooltip="Close" />}
|
||||
onRequestClose={requestClose}
|
||||
>
|
||||
{ data ? <JoinAliasContent term={data.term} requestClose={requestClose} /> : <div /> }
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default JoinAlias;
|
20
src/app/organisms/join-alias/JoinAlias.scss
Normal file
20
src/app/organisms/join-alias/JoinAlias.scss
Normal file
|
@ -0,0 +1,20 @@
|
|||
@use '../../partials/dir';
|
||||
|
||||
.join-alias {
|
||||
padding: var(--sp-normal);
|
||||
@include dir.side(padding, var(--sp-normal), var(--sp-extra-tight));
|
||||
|
||||
& > *:not(:first-child) {
|
||||
margin-top: var(--sp-normal);
|
||||
}
|
||||
|
||||
&__error {
|
||||
color: var(--tc-danger-high);
|
||||
margin-top: var(--sp-extra-tight) !important;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
display: flex;
|
||||
gap: var(--sp-normal);
|
||||
}
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import cons from '../../../client/state/cons';
|
||||
|
@ -9,12 +10,12 @@ import { roomIdByActivity } from '../../../util/sort';
|
|||
import RoomsCategory from './RoomsCategory';
|
||||
|
||||
const drawerPostie = new Postie();
|
||||
function Directs() {
|
||||
function Directs({ size }) {
|
||||
const mx = initMatrix.matrixClient;
|
||||
const { roomList, notifications } = initMatrix;
|
||||
const [directIds, setDirectIds] = useState([]);
|
||||
|
||||
useEffect(() => setDirectIds([...roomList.directs].sort(roomIdByActivity)), []);
|
||||
useEffect(() => setDirectIds([...roomList.directs].sort(roomIdByActivity)), [size]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleTimeline = (event, room, toStartOfTimeline, removed, data) => {
|
||||
|
@ -63,5 +64,8 @@ function Directs() {
|
|||
|
||||
return <RoomsCategory name="People" hideHeader roomIds={directIds} drawerPostie={drawerPostie} />;
|
||||
}
|
||||
Directs.propTypes = {
|
||||
size: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
export default Directs;
|
||||
|
|
|
@ -42,12 +42,15 @@ function Drawer() {
|
|||
const [spaceId] = useSelectedSpace();
|
||||
const [, forceUpdate] = useForceUpdate();
|
||||
const scrollRef = useRef(null);
|
||||
const { roomList } = initMatrix;
|
||||
|
||||
useEffect(() => {
|
||||
const { roomList } = initMatrix;
|
||||
roomList.on(cons.events.roomList.ROOMLIST_UPDATED, forceUpdate);
|
||||
const handleUpdate = () => {
|
||||
forceUpdate();
|
||||
};
|
||||
roomList.on(cons.events.roomList.ROOMLIST_UPDATED, handleUpdate);
|
||||
return () => {
|
||||
roomList.removeListener(cons.events.roomList.ROOMLIST_UPDATED, forceUpdate);
|
||||
roomList.removeListener(cons.events.roomList.ROOMLIST_UPDATED, handleUpdate);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
@ -61,14 +64,16 @@ function Drawer() {
|
|||
<div className="drawer">
|
||||
<DrawerHeader selectedTab={selectedTab} spaceId={spaceId} />
|
||||
<div className="drawer__content-wrapper">
|
||||
{navigation.selectedSpacePath.length > 1 && <DrawerBreadcrumb spaceId={spaceId} />}
|
||||
{navigation.selectedSpacePath.length > 1 && selectedTab !== cons.tabs.DIRECTS && (
|
||||
<DrawerBreadcrumb spaceId={spaceId} />
|
||||
)}
|
||||
<div className="rooms__wrapper">
|
||||
<ScrollView ref={scrollRef} autoHide>
|
||||
<div className="rooms-container">
|
||||
{
|
||||
selectedTab !== cons.tabs.DIRECTS
|
||||
? <Home spaceId={spaceId} />
|
||||
: <Directs />
|
||||
: <Directs size={roomList.directs.size} />
|
||||
}
|
||||
</div>
|
||||
</ScrollView>
|
||||
|
|
|
@ -7,7 +7,7 @@ import { twemojify } from '../../../util/twemojify';
|
|||
import initMatrix from '../../../client/initMatrix';
|
||||
import cons from '../../../client/state/cons';
|
||||
import {
|
||||
openPublicRooms, openCreateRoom, openSpaceManage,
|
||||
openPublicRooms, openCreateRoom, openSpaceManage, openJoinAlias,
|
||||
openSpaceAddExisting, openInviteUser, openReusableContextMenu,
|
||||
} from '../../../client/action/navigation';
|
||||
import { getEventCords } from '../../../util/common';
|
||||
|
@ -60,6 +60,14 @@ export function HomeSpaceOptions({ spaceId, afterOptionSelect }) {
|
|||
Join public room
|
||||
</MenuItem>
|
||||
)}
|
||||
{ !spaceId && (
|
||||
<MenuItem
|
||||
iconSrc={PlusIC}
|
||||
onClick={() => { afterOptionSelect(); openJoinAlias(); }}
|
||||
>
|
||||
Join with address
|
||||
</MenuItem>
|
||||
)}
|
||||
{ spaceId && (
|
||||
<MenuItem
|
||||
iconSrc={PlusIC}
|
||||
|
|
|
@ -14,6 +14,7 @@ import {
|
|||
} from '../../../client/action/navigation';
|
||||
import { moveSpaceShortcut } from '../../../client/action/accountData';
|
||||
import { abbreviateNumber, getEventCords } from '../../../util/common';
|
||||
import { isCrossVerified } from '../../../util/matrixUtil';
|
||||
|
||||
import Avatar from '../../atoms/avatar/Avatar';
|
||||
import NotificationBadge from '../../atoms/badge/NotificationBadge';
|
||||
|
@ -26,8 +27,12 @@ import UserIC from '../../../../public/res/ic/outlined/user.svg';
|
|||
import AddPinIC from '../../../../public/res/ic/outlined/add-pin.svg';
|
||||
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
|
||||
import InviteIC from '../../../../public/res/ic/outlined/invite.svg';
|
||||
import ShieldUserIC from '../../../../public/res/ic/outlined/shield-user.svg';
|
||||
|
||||
import { useSelectedTab } from '../../hooks/useSelectedTab';
|
||||
import { useDeviceList } from '../../hooks/useDeviceList';
|
||||
|
||||
import { tabText as settingTabText } from '../settings/Settings';
|
||||
|
||||
function useNotificationUpdate() {
|
||||
const { notifications } = initMatrix;
|
||||
|
@ -85,6 +90,22 @@ function ProfileAvatarMenu() {
|
|||
);
|
||||
}
|
||||
|
||||
function CrossSigninAlert() {
|
||||
const deviceList = useDeviceList();
|
||||
const unverified = deviceList?.filter((device) => isCrossVerified(device.device_id) === false);
|
||||
|
||||
if (!unverified?.length) return null;
|
||||
|
||||
return (
|
||||
<SidebarAvatar
|
||||
className="sidebar__cross-signin-alert"
|
||||
tooltip={`${unverified.length} unverified sessions`}
|
||||
onClick={() => openSettings(settingTabText.SECURITY)}
|
||||
avatar={<Avatar iconSrc={ShieldUserIC} iconColor="var(--ic-danger-normal)" size="normal" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FeaturedTab() {
|
||||
const { roomList, accountData, notifications } = initMatrix;
|
||||
const [selectedTab] = useSelectedTab();
|
||||
|
@ -358,6 +379,7 @@ function SideBar() {
|
|||
notificationBadge={<NotificationBadge alert content={totalInvites} />}
|
||||
/>
|
||||
)}
|
||||
<CrossSigninAlert />
|
||||
<ProfileAvatarMenu />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -57,4 +57,21 @@
|
|||
width: 24px;
|
||||
height: 1px;
|
||||
background-color: var(--bg-surface-border);
|
||||
}
|
||||
|
||||
.sidebar__cross-signin-alert .avatar-container {
|
||||
box-shadow: var(--bs-danger-border);
|
||||
animation-name: pushRight;
|
||||
animation-duration: 400ms;
|
||||
animation-iteration-count: infinite;
|
||||
animation-direction: alternate;
|
||||
}
|
||||
|
||||
@keyframes pushRight {
|
||||
from {
|
||||
transform: translateX(4px) scale(1);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
}
|
|
@ -12,6 +12,7 @@ import ImageUpload from '../../molecules/image-upload/ImageUpload';
|
|||
import Input from '../../atoms/input/Input';
|
||||
|
||||
import PencilIC from '../../../../public/res/ic/outlined/pencil.svg';
|
||||
import { confirmDialog } from '../../molecules/confirm-dialog/ConfirmDialog';
|
||||
|
||||
import './ProfileEditor.scss';
|
||||
|
||||
|
@ -38,9 +39,15 @@ function ProfileEditor({ userId }) {
|
|||
};
|
||||
}, [userId]);
|
||||
|
||||
const handleAvatarUpload = (url) => {
|
||||
const handleAvatarUpload = async (url) => {
|
||||
if (url === null) {
|
||||
if (confirm('Are you sure that you want to remove avatar?')) {
|
||||
const isConfirmed = await confirmDialog(
|
||||
'Remove avatar',
|
||||
'Are you sure that you want to remove avatar?',
|
||||
'Remove',
|
||||
'caution',
|
||||
);
|
||||
if (isConfirmed) {
|
||||
mx.setAvatarUrl('');
|
||||
setAvatarSrc(null);
|
||||
}
|
||||
|
|
|
@ -32,6 +32,7 @@ import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.s
|
|||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||
|
||||
import { useForceUpdate } from '../../hooks/useForceUpdate';
|
||||
import { confirmDialog } from '../../molecules/confirm-dialog/ConfirmDialog';
|
||||
|
||||
function ModerationTools({
|
||||
roomId, userId,
|
||||
|
@ -362,7 +363,7 @@ function ProfileViewer() {
|
|||
&& (powerLevel < myPowerLevel || userId === mx.getUserId())
|
||||
);
|
||||
|
||||
const handleChangePowerLevel = (newPowerLevel) => {
|
||||
const handleChangePowerLevel = async (newPowerLevel) => {
|
||||
if (newPowerLevel === powerLevel) return;
|
||||
const SHARED_POWER_MSG = 'You will not be able to undo this change as you are promoting the user to have the same power level as yourself. Are you sure?';
|
||||
const DEMOTING_MYSELF_MSG = 'You will not be able to undo this change as you are demoting yourself. Are you sure?';
|
||||
|
@ -370,9 +371,14 @@ function ProfileViewer() {
|
|||
const isSharedPower = newPowerLevel === myPowerLevel;
|
||||
const isDemotingMyself = userId === mx.getUserId();
|
||||
if (isSharedPower || isDemotingMyself) {
|
||||
if (confirm(isSharedPower ? SHARED_POWER_MSG : DEMOTING_MYSELF_MSG)) {
|
||||
roomActions.setPowerLevel(roomId, userId, newPowerLevel);
|
||||
}
|
||||
const isConfirmed = await confirmDialog(
|
||||
'Change power level',
|
||||
isSharedPower ? SHARED_POWER_MSG : DEMOTING_MYSELF_MSG,
|
||||
'Change',
|
||||
'caution',
|
||||
);
|
||||
if (!isConfirmed) return;
|
||||
roomActions.setPowerLevel(roomId, userId, newPowerLevel);
|
||||
} else {
|
||||
roomActions.setPowerLevel(roomId, userId, newPowerLevel);
|
||||
}
|
||||
|
|
|
@ -195,7 +195,7 @@ function PublicRooms({ isOpen, searchTerm, onRequestClose }) {
|
|||
return rooms.map((room) => {
|
||||
const alias = typeof room.canonical_alias === 'string' ? room.canonical_alias : room.room_id;
|
||||
const name = typeof room.name === 'string' ? room.name : alias;
|
||||
const isJoined = initMatrix.matrixClient.getRoom(room.room_id) !== null;
|
||||
const isJoined = initMatrix.matrixClient.getRoom(room.room_id)?.getMyMembership() === 'join';
|
||||
return (
|
||||
<RoomTile
|
||||
key={room.room_id}
|
||||
|
|
|
@ -7,6 +7,8 @@ import SpaceAddExisting from '../../molecules/space-add-existing/SpaceAddExistin
|
|||
import Search from '../search/Search';
|
||||
import ViewSource from '../view-source/ViewSource';
|
||||
import CreateRoom from '../create-room/CreateRoom';
|
||||
import JoinAlias from '../join-alias/JoinAlias';
|
||||
import EmojiVerification from '../emoji-verification/EmojiVerification';
|
||||
|
||||
import ReusableDialog from '../../molecules/dialog/ReusableDialog';
|
||||
|
||||
|
@ -18,8 +20,10 @@ function Dialogs() {
|
|||
<ProfileViewer />
|
||||
<ShortcutSpaces />
|
||||
<CreateRoom />
|
||||
<JoinAlias />
|
||||
<SpaceAddExisting />
|
||||
<Search />
|
||||
<EmojiVerification />
|
||||
|
||||
<ReusableDialog />
|
||||
</>
|
||||
|
|
|
@ -3,9 +3,10 @@ import './Room.scss';
|
|||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import cons from '../../../client/state/cons';
|
||||
import navigation from '../../../client/state/navigation';
|
||||
import settings from '../../../client/state/settings';
|
||||
import RoomTimeline from '../../../client/state/RoomTimeline';
|
||||
import navigation from '../../../client/state/navigation';
|
||||
import { openNavigation } from '../../../client/action/navigation';
|
||||
|
||||
import Welcome from '../welcome/Welcome';
|
||||
import RoomView from './RoomView';
|
||||
|
@ -53,7 +54,10 @@ function Room() {
|
|||
}, []);
|
||||
|
||||
const { roomTimeline, eventId } = roomInfo;
|
||||
if (roomTimeline === null) return <Welcome />;
|
||||
if (roomTimeline === null) {
|
||||
setTimeout(() => openNavigation());
|
||||
return <Welcome />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="room">
|
||||
|
@ -61,7 +65,7 @@ function Room() {
|
|||
<RoomSettings roomId={roomTimeline.roomId} />
|
||||
<RoomView roomTimeline={roomTimeline} eventId={eventId} />
|
||||
</div>
|
||||
{ isDrawer && <PeopleDrawer roomId={roomTimeline.roomId} />}
|
||||
{isDrawer && <PeopleDrawer roomId={roomTimeline.roomId} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
@use '../../partials/flex';
|
||||
@use '../../partials/screen';
|
||||
|
||||
.room {
|
||||
@extend .cp-fx__row;
|
||||
|
@ -9,4 +10,10 @@
|
|||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.room .people-drawer {
|
||||
@include screen.smallerThan(tabletBreakpoint) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -36,6 +36,7 @@ import LeaveArrowIC from '../../../../public/res/ic/outlined/leave-arrow.svg';
|
|||
import ChevronTopIC from '../../../../public/res/ic/outlined/chevron-top.svg';
|
||||
|
||||
import { useForceUpdate } from '../../hooks/useForceUpdate';
|
||||
import { confirmDialog } from '../../molecules/confirm-dialog/ConfirmDialog';
|
||||
|
||||
const tabText = {
|
||||
GENERAL: 'General',
|
||||
|
@ -85,10 +86,15 @@ function GeneralSettings({ roomId }) {
|
|||
</MenuItem>
|
||||
<MenuItem
|
||||
variant="danger"
|
||||
onClick={() => {
|
||||
if (confirm('Are you sure that you want to leave this room?')) {
|
||||
roomActions.leave(roomId);
|
||||
}
|
||||
onClick={async () => {
|
||||
const isConfirmed = await confirmDialog(
|
||||
'Leave room',
|
||||
`Are you sure that you want to leave "${room.name}" room?`,
|
||||
'Leave',
|
||||
'danger',
|
||||
);
|
||||
if (!isConfirmed) return;
|
||||
roomActions.leave(roomId);
|
||||
}}
|
||||
iconSrc={LeaveArrowIC}
|
||||
>
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
@use '../../partials/flex';
|
||||
@use '../../partials/screen';
|
||||
@use '../../partials/dir';
|
||||
|
||||
.room-view {
|
||||
@extend .cp-fx__column;
|
||||
|
@ -18,6 +20,12 @@
|
|||
box-shadow: var(--bs-popup);
|
||||
}
|
||||
|
||||
& .header {
|
||||
@include screen.smallerThan(mobileBreakpoint) {
|
||||
padding: 0 var(--sp-tight);
|
||||
}
|
||||
}
|
||||
|
||||
&__content-wrapper {
|
||||
@extend .cp-fx__item-one;
|
||||
@extend .cp-fx__column;
|
||||
|
|
|
@ -44,7 +44,9 @@ const commands = [{
|
|||
}, {
|
||||
name: 'leave',
|
||||
description: 'Leave current room',
|
||||
exe: (roomId) => roomActions.leave(roomId),
|
||||
exe: (roomId) => {
|
||||
roomActions.leave(roomId);
|
||||
},
|
||||
}, {
|
||||
name: 'invite',
|
||||
isOptions: true,
|
||||
|
|
|
@ -8,6 +8,7 @@ import PropTypes from 'prop-types';
|
|||
import './RoomViewContent.scss';
|
||||
|
||||
import dateFormat from 'dateformat';
|
||||
import { twemojify } from '../../../util/twemojify';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import cons from '../../../client/state/cons';
|
||||
|
@ -50,21 +51,54 @@ function loadingMsgPlaceholders(key, count = 2) {
|
|||
);
|
||||
}
|
||||
|
||||
function genRoomIntro(mEvent, roomTimeline) {
|
||||
function RoomIntroContainer({ event, timeline }) {
|
||||
const [, nameForceUpdate] = useForceUpdate();
|
||||
const mx = initMatrix.matrixClient;
|
||||
const roomTopic = roomTimeline.room.currentState.getStateEvents('m.room.topic')[0]?.getContent().topic;
|
||||
const isDM = initMatrix.roomList.directs.has(roomTimeline.roomId);
|
||||
let avatarSrc = roomTimeline.room.getAvatarUrl(mx.baseUrl, 80, 80, 'crop');
|
||||
avatarSrc = isDM ? roomTimeline.room.getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 80, 80, 'crop') : avatarSrc;
|
||||
const { roomList } = initMatrix;
|
||||
const { room } = timeline;
|
||||
const roomTopic = room.currentState.getStateEvents('m.room.topic')[0]?.getContent().topic;
|
||||
const isDM = roomList.directs.has(timeline.roomId);
|
||||
let avatarSrc = room.getAvatarUrl(mx.baseUrl, 80, 80, 'crop');
|
||||
avatarSrc = isDM ? room.getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 80, 80, 'crop') : avatarSrc;
|
||||
|
||||
const heading = isDM ? room.name : `Welcome to ${room.name}`;
|
||||
const topic = twemojify(roomTopic || '', undefined, true);
|
||||
const nameJsx = twemojify(room.name);
|
||||
const desc = isDM
|
||||
? (
|
||||
<>
|
||||
This is the beginning of your direct message history with @
|
||||
<b>{nameJsx}</b>
|
||||
{'. '}
|
||||
{topic}
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
{'This is the beginning of the '}
|
||||
<b>{nameJsx}</b>
|
||||
{' room. '}
|
||||
{topic}
|
||||
</>
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleUpdate = () => nameForceUpdate();
|
||||
|
||||
roomList.on(cons.events.roomList.ROOM_PROFILE_UPDATED, handleUpdate);
|
||||
return () => {
|
||||
roomList.removeListener(cons.events.roomList.ROOM_PROFILE_UPDATED, handleUpdate);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<RoomIntro
|
||||
key={mEvent ? mEvent.getId() : 'room-intro'}
|
||||
roomId={roomTimeline.roomId}
|
||||
roomId={timeline.roomId}
|
||||
avatarSrc={avatarSrc}
|
||||
name={roomTimeline.room.name}
|
||||
heading={`Welcome to ${roomTimeline.room.name}`}
|
||||
desc={`This is the beginning of the ${roomTimeline.room.name} room.${typeof roomTopic !== 'undefined' ? (` Topic: ${roomTopic}`) : ''}`}
|
||||
time={mEvent ? `Created at ${dateFormat(mEvent.getDate(), 'dd mmmm yyyy, hh:MM TT')}` : null}
|
||||
name={room.name}
|
||||
heading={twemojify(heading)}
|
||||
desc={desc}
|
||||
time={event ? `Created at ${dateFormat(event.getDate(), 'dd mmmm yyyy, hh:MM TT')}` : null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -199,7 +233,7 @@ function usePaginate(
|
|||
};
|
||||
roomTimeline.on(cons.events.roomTimeline.PAGINATED, handlePaginatedFromServer);
|
||||
return () => {
|
||||
roomTimeline.on(cons.events.roomTimeline.PAGINATED, handlePaginatedFromServer);
|
||||
roomTimeline.removeListener(cons.events.roomTimeline.PAGINATED, handlePaginatedFromServer);
|
||||
};
|
||||
}, [roomTimeline]);
|
||||
|
||||
|
@ -470,12 +504,14 @@ function RoomViewContent({ eventId, roomTimeline }) {
|
|||
|
||||
if (i === 0 && !roomTimeline.canPaginateBackward()) {
|
||||
if (mEvent.getType() === 'm.room.create') {
|
||||
tl.push(genRoomIntro(mEvent, roomTimeline));
|
||||
tl.push(
|
||||
<RoomIntroContainer key={mEvent.getId()} event={mEvent} timeline={roomTimeline} />,
|
||||
);
|
||||
itemCountIndex += 1;
|
||||
// eslint-disable-next-line no-continue
|
||||
continue;
|
||||
} else {
|
||||
tl.push(genRoomIntro(undefined, roomTimeline));
|
||||
tl.push(<RoomIntroContainer key="room-intro" event={null} timeline={roomTimeline} />);
|
||||
itemCountIndex += 1;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@ import { blurOnBubbling } from '../../atoms/button/script';
|
|||
import initMatrix from '../../../client/initMatrix';
|
||||
import cons from '../../../client/state/cons';
|
||||
import navigation from '../../../client/state/navigation';
|
||||
import { toggleRoomSettings, openReusableContextMenu } from '../../../client/action/navigation';
|
||||
import { toggleRoomSettings, openReusableContextMenu, openNavigation } from '../../../client/action/navigation';
|
||||
import { togglePeopleDrawer } from '../../../client/action/settings';
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
import { getEventCords } from '../../../util/common';
|
||||
|
@ -25,6 +25,7 @@ import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.s
|
|||
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
|
||||
import UserIC from '../../../../public/res/ic/outlined/user.svg';
|
||||
import VerticalMenuIC from '../../../../public/res/ic/outlined/vertical-menu.svg';
|
||||
import BackArrowIC from '../../../../public/res/ic/outlined/chevron-left.svg';
|
||||
|
||||
import { useForceUpdate } from '../../hooks/useForceUpdate';
|
||||
|
||||
|
@ -73,6 +74,12 @@ function RoomViewHeader({ roomId }) {
|
|||
|
||||
return (
|
||||
<Header>
|
||||
<IconButton
|
||||
src={BackArrowIC}
|
||||
className="room-header__back-btn"
|
||||
tooltip="Return to navigation"
|
||||
onClick={() => openNavigation()}
|
||||
/>
|
||||
<button
|
||||
ref={roomHeaderBtnRef}
|
||||
className="room-header__btn"
|
||||
|
@ -87,7 +94,8 @@ function RoomViewHeader({ roomId }) {
|
|||
<RawIcon src={ChevronBottomIC} />
|
||||
</button>
|
||||
<IconButton onClick={() => toggleRoomSettings(tabText.SEARCH)} tooltip="Search" src={SearchIC} />
|
||||
<IconButton onClick={togglePeopleDrawer} tooltip="People" src={UserIC} />
|
||||
<IconButton className="room-header__drawer-btn" onClick={togglePeopleDrawer} tooltip="People" src={UserIC} />
|
||||
<IconButton className="room-header__members-btn" onClick={() => toggleRoomSettings(tabText.MEMBERS)} tooltip="Members" src={UserIC} />
|
||||
<IconButton
|
||||
onClick={openRoomOptions}
|
||||
tooltip="Options"
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
@use '../../partials/flex';
|
||||
@use '../../partials/dir';
|
||||
@use '../../partials/screen';
|
||||
|
||||
.room-header__btn {
|
||||
min-width: 0;
|
||||
|
@ -24,4 +25,23 @@
|
|||
box-shadow: var(--bs-surface-outline);
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.room-header__drawer-btn {
|
||||
@include screen.smallerThan(tabletBreakpoint) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.room-header__members-btn {
|
||||
@include screen.biggerThan(tabletBreakpoint) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.room-header__back-btn {
|
||||
@include dir.side(margin, 0, var(--sp-tight));
|
||||
|
||||
@include screen.biggerThan(mobileBreakpoint) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -199,7 +199,7 @@ function Search() {
|
|||
|
||||
return (
|
||||
<RawModal
|
||||
className="search-dialog__model dialog-model"
|
||||
className="search-dialog__modal dialog-modal"
|
||||
isOpen={isOpen}
|
||||
onAfterOpen={handleAfterOpen}
|
||||
onAfterClose={handleAfterClose}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
@use '../../partials/dir';
|
||||
|
||||
.search-dialog__model {
|
||||
.search-dialog__modal {
|
||||
--modal-height: 380px;
|
||||
height: 100%;
|
||||
background-color: var(--bg-surface);
|
||||
|
|
117
src/app/organisms/settings/AuthRequest.jsx
Normal file
117
src/app/organisms/settings/AuthRequest.jsx
Normal file
|
@ -0,0 +1,117 @@
|
|||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './AuthRequest.scss';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import { openReusableDialog } from '../../../client/action/navigation';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import Button from '../../atoms/button/Button';
|
||||
import Input from '../../atoms/input/Input';
|
||||
import Spinner from '../../atoms/spinner/Spinner';
|
||||
|
||||
import { useStore } from '../../hooks/useStore';
|
||||
|
||||
let lastUsedPassword;
|
||||
const getAuthId = (password) => ({
|
||||
type: 'm.login.password',
|
||||
password,
|
||||
identifier: {
|
||||
type: 'm.id.user',
|
||||
user: initMatrix.matrixClient.getUserId(),
|
||||
},
|
||||
});
|
||||
|
||||
function AuthRequest({ onComplete, makeRequest }) {
|
||||
const [status, setStatus] = useState(false);
|
||||
const mountStore = useStore();
|
||||
|
||||
const handleForm = async (e) => {
|
||||
mountStore.setItem(true);
|
||||
e.preventDefault();
|
||||
const password = e.target.password.value;
|
||||
if (password.trim() === '') return;
|
||||
try {
|
||||
setStatus({ ongoing: true });
|
||||
await makeRequest(getAuthId(password));
|
||||
lastUsedPassword = password;
|
||||
if (!mountStore.getItem()) return;
|
||||
onComplete(true);
|
||||
} catch (err) {
|
||||
lastUsedPassword = undefined;
|
||||
if (!mountStore.getItem()) return;
|
||||
if (err.errcode === 'M_FORBIDDEN') {
|
||||
setStatus({ error: 'Wrong password. Please enter correct password.' });
|
||||
return;
|
||||
}
|
||||
setStatus({ error: 'Request failed!' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = () => {
|
||||
setStatus(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auth-request">
|
||||
<form onSubmit={handleForm}>
|
||||
<Input
|
||||
name="password"
|
||||
label="Account password"
|
||||
type="password"
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
{status.ongoing && <Spinner size="small" />}
|
||||
{status.error && <Text variant="b3">{status.error}</Text>}
|
||||
{(status === false || status.error) && <Button variant="primary" type="submit" disabled={!!status.error}>Continue</Button>}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
AuthRequest.propTypes = {
|
||||
onComplete: PropTypes.func.isRequired,
|
||||
makeRequest: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string} title Title of dialog
|
||||
* @param {(auth) => void} makeRequest request to make
|
||||
* @returns {Promise<boolean>} whether the request succeed or not.
|
||||
*/
|
||||
export const authRequest = async (title, makeRequest) => {
|
||||
try {
|
||||
const auth = lastUsedPassword ? getAuthId(lastUsedPassword) : undefined;
|
||||
await makeRequest(auth);
|
||||
return true;
|
||||
} catch (e) {
|
||||
lastUsedPassword = undefined;
|
||||
if (e.httpStatus !== 401 || e.data?.flows === undefined) return false;
|
||||
|
||||
const { flows } = e.data;
|
||||
const canUsePassword = flows.find((f) => f.stages.includes('m.login.password'));
|
||||
if (!canUsePassword) return false;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let isCompleted = false;
|
||||
openReusableDialog(
|
||||
<Text variant="s1" weight="medium">{title}</Text>,
|
||||
(requestClose) => (
|
||||
<AuthRequest
|
||||
onComplete={(done) => {
|
||||
isCompleted = true;
|
||||
resolve(done);
|
||||
requestClose();
|
||||
}}
|
||||
makeRequest={makeRequest}
|
||||
/>
|
||||
),
|
||||
() => {
|
||||
if (!isCompleted) resolve(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default AuthRequest;
|
12
src/app/organisms/settings/AuthRequest.scss
Normal file
12
src/app/organisms/settings/AuthRequest.scss
Normal file
|
@ -0,0 +1,12 @@
|
|||
.auth-request {
|
||||
padding: var(--sp-normal);
|
||||
|
||||
& form > *:not(:first-child) {
|
||||
margin-top: var(--sp-normal);
|
||||
}
|
||||
|
||||
& .text-b3 {
|
||||
color: var(--tc-danger-high);
|
||||
margin-top: var(--sp-ultra-tight) !important;
|
||||
}
|
||||
}
|
223
src/app/organisms/settings/CrossSigning.jsx
Normal file
223
src/app/organisms/settings/CrossSigning.jsx
Normal file
|
@ -0,0 +1,223 @@
|
|||
/* eslint-disable react/jsx-one-expression-per-line */
|
||||
import React, { useState } from 'react';
|
||||
import './CrossSigning.scss';
|
||||
import FileSaver from 'file-saver';
|
||||
import { Formik } from 'formik';
|
||||
import { twemojify } from '../../../util/twemojify';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import { openReusableDialog } from '../../../client/action/navigation';
|
||||
import { copyToClipboard } from '../../../util/common';
|
||||
import { clearSecretStorageKeys } from '../../../client/state/secretStorageKeys';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import Button from '../../atoms/button/Button';
|
||||
import Input from '../../atoms/input/Input';
|
||||
import Spinner from '../../atoms/spinner/Spinner';
|
||||
import SettingTile from '../../molecules/setting-tile/SettingTile';
|
||||
|
||||
import { authRequest } from './AuthRequest';
|
||||
import { useCrossSigningStatus } from '../../hooks/useCrossSigningStatus';
|
||||
|
||||
const failedDialog = () => {
|
||||
const renderFailure = (requestClose) => (
|
||||
<div className="cross-signing__failure">
|
||||
<Text variant="h1">{twemojify('❌')}</Text>
|
||||
<Text weight="medium">Failed to setup cross signing. Please try again.</Text>
|
||||
<Button onClick={requestClose}>Close</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
openReusableDialog(
|
||||
<Text variant="s1" weight="medium">Setup cross signing</Text>,
|
||||
renderFailure,
|
||||
);
|
||||
};
|
||||
|
||||
const securityKeyDialog = (key) => {
|
||||
const downloadKey = () => {
|
||||
const blob = new Blob([key.encodedPrivateKey], {
|
||||
type: 'text/plain;charset=us-ascii',
|
||||
});
|
||||
FileSaver.saveAs(blob, 'security-key.txt');
|
||||
};
|
||||
const copyKey = () => {
|
||||
copyToClipboard(key.encodedPrivateKey);
|
||||
};
|
||||
|
||||
const renderSecurityKey = () => (
|
||||
<div className="cross-signing__key">
|
||||
<Text weight="medium">Please save this security key somewhere safe.</Text>
|
||||
<Text className="cross-signing__key-text">
|
||||
{key.encodedPrivateKey}
|
||||
</Text>
|
||||
<div className="cross-signing__key-btn">
|
||||
<Button variant="primary" onClick={() => copyKey(key)}>Copy</Button>
|
||||
<Button onClick={() => downloadKey(key)}>Download</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Download automatically.
|
||||
downloadKey();
|
||||
|
||||
openReusableDialog(
|
||||
<Text variant="s1" weight="medium">Security Key</Text>,
|
||||
() => renderSecurityKey(),
|
||||
);
|
||||
};
|
||||
|
||||
function CrossSigningSetup() {
|
||||
const initialValues = { phrase: '', confirmPhrase: '' };
|
||||
const [genWithPhrase, setGenWithPhrase] = useState(undefined);
|
||||
|
||||
const setup = async (securityPhrase = undefined) => {
|
||||
const mx = initMatrix.matrixClient;
|
||||
setGenWithPhrase(typeof securityPhrase === 'string');
|
||||
const recoveryKey = await mx.createRecoveryKeyFromPassphrase(securityPhrase);
|
||||
clearSecretStorageKeys();
|
||||
|
||||
await mx.bootstrapSecretStorage({
|
||||
createSecretStorageKey: async () => recoveryKey,
|
||||
setupNewKeyBackup: true,
|
||||
setupNewSecretStorage: true,
|
||||
});
|
||||
|
||||
const authUploadDeviceSigningKeys = async (makeRequest) => {
|
||||
const isDone = await authRequest('Setup cross signing', async (auth) => {
|
||||
await makeRequest(auth);
|
||||
});
|
||||
setTimeout(() => {
|
||||
if (isDone) securityKeyDialog(recoveryKey);
|
||||
else failedDialog();
|
||||
});
|
||||
};
|
||||
|
||||
await mx.bootstrapCrossSigning({
|
||||
authUploadDeviceSigningKeys,
|
||||
setupNewCrossSigning: true,
|
||||
});
|
||||
};
|
||||
|
||||
const validator = (values) => {
|
||||
const errors = {};
|
||||
if (values.phrase === '12345678') {
|
||||
errors.phrase = 'How about 87654321 ?';
|
||||
}
|
||||
if (values.phrase === '87654321') {
|
||||
errors.phrase = 'Your are playing with 🔥';
|
||||
}
|
||||
const PHRASE_REGEX = /^([^\s]){8,127}$/;
|
||||
if (values.phrase.length > 0 && !PHRASE_REGEX.test(values.phrase)) {
|
||||
errors.phrase = 'Phrase must contain 8-127 characters with no space.';
|
||||
}
|
||||
if (values.confirmPhrase.length > 0 && values.confirmPhrase !== values.phrase) {
|
||||
errors.confirmPhrase = 'Phrase don\'t match.';
|
||||
}
|
||||
return errors;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="cross-signing__setup">
|
||||
<div className="cross-signing__setup-entry">
|
||||
<Text>
|
||||
We will generate a <b>Security Key</b>,
|
||||
which you can use to manage messages backup and session verification.
|
||||
</Text>
|
||||
{genWithPhrase !== false && <Button variant="primary" onClick={() => setup()} disabled={genWithPhrase !== undefined}>Generate Key</Button>}
|
||||
{genWithPhrase === false && <Spinner size="small" />}
|
||||
</div>
|
||||
<Text className="cross-signing__setup-divider">OR</Text>
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
onSubmit={(values) => setup(values.phrase)}
|
||||
validate={validator}
|
||||
>
|
||||
{({
|
||||
values, errors, handleChange, handleSubmit,
|
||||
}) => (
|
||||
<form
|
||||
className="cross-signing__setup-entry"
|
||||
onSubmit={handleSubmit}
|
||||
disabled={genWithPhrase !== undefined}
|
||||
>
|
||||
<Text>
|
||||
Alternatively you can also set a <b>Security Phrase </b>
|
||||
so you don't have to remember long Security Key,
|
||||
and optionally save the Key as backup.
|
||||
</Text>
|
||||
<Input
|
||||
name="phrase"
|
||||
value={values.phrase}
|
||||
onChange={handleChange}
|
||||
label="Security Phrase"
|
||||
type="password"
|
||||
required
|
||||
disabled={genWithPhrase !== undefined}
|
||||
/>
|
||||
{errors.phrase && <Text variant="b3" className="cross-signing__error">{errors.phrase}</Text>}
|
||||
<Input
|
||||
name="confirmPhrase"
|
||||
value={values.confirmPhrase}
|
||||
onChange={handleChange}
|
||||
label="Confirm Security Phrase"
|
||||
type="password"
|
||||
required
|
||||
disabled={genWithPhrase !== undefined}
|
||||
/>
|
||||
{errors.confirmPhrase && <Text variant="b3" className="cross-signing__error">{errors.confirmPhrase}</Text>}
|
||||
{genWithPhrase !== true && <Button variant="primary" type="submit" disabled={genWithPhrase !== undefined}>Set Phrase & Generate Key</Button>}
|
||||
{genWithPhrase === true && <Spinner size="small" />}
|
||||
</form>
|
||||
)}
|
||||
</Formik>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const setupDialog = () => {
|
||||
openReusableDialog(
|
||||
<Text variant="s1" weight="medium">Setup cross signing</Text>,
|
||||
() => <CrossSigningSetup />,
|
||||
);
|
||||
};
|
||||
|
||||
function CrossSigningReset() {
|
||||
return (
|
||||
<div className="cross-signing__reset">
|
||||
<Text variant="h1">{twemojify('✋🧑🚒🤚')}</Text>
|
||||
<Text weight="medium">Resetting cross-signing keys is permanent.</Text>
|
||||
<Text>
|
||||
Anyone you have verified with will see security alerts and your message backup will lost.
|
||||
You almost certainly do not want to do this,
|
||||
unless you have lost <b>Security Key</b> or <b>Phrase</b> and
|
||||
every session you can cross-sign from.
|
||||
</Text>
|
||||
<Button variant="danger" onClick={setupDialog}>Reset</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const resetDialog = () => {
|
||||
openReusableDialog(
|
||||
<Text variant="s1" weight="medium">Reset cross signing</Text>,
|
||||
() => <CrossSigningReset />,
|
||||
);
|
||||
};
|
||||
|
||||
function CrossSignin() {
|
||||
const isCSEnabled = useCrossSigningStatus();
|
||||
return (
|
||||
<SettingTile
|
||||
title="Cross signing"
|
||||
content={<Text variant="b3">Setup to verify and keep track of all your sessions. Also required to backup encrypted message.</Text>}
|
||||
options={(
|
||||
isCSEnabled
|
||||
? <Button variant="danger" onClick={resetDialog}>Reset</Button>
|
||||
: <Button variant="primary" onClick={setupDialog}>Setup</Button>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default CrossSignin;
|
55
src/app/organisms/settings/CrossSigning.scss
Normal file
55
src/app/organisms/settings/CrossSigning.scss
Normal file
|
@ -0,0 +1,55 @@
|
|||
.cross-signing {
|
||||
&__setup {
|
||||
padding: var(--sp-normal);
|
||||
}
|
||||
&__setup-entry {
|
||||
& > *:not(:first-child) {
|
||||
margin-top: var(--sp-normal);
|
||||
}
|
||||
}
|
||||
|
||||
&__error {
|
||||
color: var(--tc-danger-high);
|
||||
margin-top: var(--sp-ultra-tight) !important;
|
||||
}
|
||||
|
||||
&__setup-divider {
|
||||
margin: var(--sp-tight) 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
flex: 1;
|
||||
content: '';
|
||||
margin: var(--sp-tight) 0;
|
||||
border-bottom: 1px solid var(--bg-surface-border);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cross-signing__key {
|
||||
padding: var(--sp-normal);
|
||||
|
||||
&-text {
|
||||
margin: var(--sp-normal) 0;
|
||||
padding: var(--sp-extra-tight);
|
||||
background-color: var(--bg-surface-low);
|
||||
border-radius: var(--bo-radius);
|
||||
}
|
||||
&-btn {
|
||||
display: flex;
|
||||
& > button:last-child {
|
||||
margin: 0 var(--sp-normal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cross-signing__failure,
|
||||
.cross-signing__reset {
|
||||
padding: var(--sp-normal);
|
||||
padding-top: var(--sp-extra-loose);
|
||||
& > .text {
|
||||
padding-bottom: var(--sp-normal);
|
||||
}
|
||||
}
|
|
@ -3,67 +3,74 @@ import './DeviceManage.scss';
|
|||
import dateFormat from 'dateformat';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import { isCrossVerified } from '../../../util/matrixUtil';
|
||||
import { openReusableDialog, openEmojiVerification } from '../../../client/action/navigation';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import Button from '../../atoms/button/Button';
|
||||
import IconButton from '../../atoms/button/IconButton';
|
||||
import Input from '../../atoms/input/Input';
|
||||
import { MenuHeader } from '../../atoms/context-menu/ContextMenu';
|
||||
import InfoCard from '../../atoms/card/InfoCard';
|
||||
import Spinner from '../../atoms/spinner/Spinner';
|
||||
import SettingTile from '../../molecules/setting-tile/SettingTile';
|
||||
|
||||
import PencilIC from '../../../../public/res/ic/outlined/pencil.svg';
|
||||
import BinIC from '../../../../public/res/ic/outlined/bin.svg';
|
||||
import InfoIC from '../../../../public/res/ic/outlined/info.svg';
|
||||
|
||||
import { authRequest } from './AuthRequest';
|
||||
import { confirmDialog } from '../../molecules/confirm-dialog/ConfirmDialog';
|
||||
|
||||
import { useStore } from '../../hooks/useStore';
|
||||
import { useDeviceList } from '../../hooks/useDeviceList';
|
||||
import { useCrossSigningStatus } from '../../hooks/useCrossSigningStatus';
|
||||
import { accessSecretStorage } from './SecretStorageAccess';
|
||||
|
||||
function useDeviceList() {
|
||||
const mx = initMatrix.matrixClient;
|
||||
const [deviceList, setDeviceList] = useState(null);
|
||||
const promptDeviceName = async (deviceName) => new Promise((resolve) => {
|
||||
let isCompleted = false;
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const updateDevices = () => mx.getDevices().then((data) => {
|
||||
if (!isMounted) return;
|
||||
setDeviceList(data.devices || []);
|
||||
});
|
||||
updateDevices();
|
||||
|
||||
const handleDevicesUpdate = (users) => {
|
||||
if (users.includes(mx.getUserId())) {
|
||||
updateDevices();
|
||||
}
|
||||
const renderContent = (onComplete) => {
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
const name = e.target.session.value;
|
||||
if (typeof name !== 'string') onComplete(null);
|
||||
onComplete(name);
|
||||
};
|
||||
return (
|
||||
<form className="device-manage__rename" onSubmit={handleSubmit}>
|
||||
<Input value={deviceName} label="Session name" name="session" />
|
||||
<div className="device-manage__rename-btn">
|
||||
<Button variant="primary" type="submit">Save</Button>
|
||||
<Button onClick={() => onComplete(null)}>Cancel</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
mx.on('crypto.devicesUpdated', handleDevicesUpdate);
|
||||
return () => {
|
||||
mx.removeListener('crypto.devicesUpdated', handleDevicesUpdate);
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
return deviceList;
|
||||
}
|
||||
|
||||
function isCrossVerified(deviceId) {
|
||||
try {
|
||||
const mx = initMatrix.matrixClient;
|
||||
const crossSignInfo = mx.getStoredCrossSigningForUser(mx.getUserId());
|
||||
const deviceInfo = mx.getStoredDevice(mx.getUserId(), deviceId);
|
||||
const deviceTrust = crossSignInfo.checkDeviceTrust(crossSignInfo, deviceInfo, false, true);
|
||||
return deviceTrust.isCrossSigningVerified();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
openReusableDialog(
|
||||
<Text variant="s1" weight="medium">Edit session name</Text>,
|
||||
(requestClose) => renderContent((name) => {
|
||||
isCompleted = true;
|
||||
resolve(name);
|
||||
requestClose();
|
||||
}),
|
||||
() => {
|
||||
if (!isCompleted) resolve(null);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
function DeviceManage() {
|
||||
const TRUNCATED_COUNT = 4;
|
||||
const mx = initMatrix.matrixClient;
|
||||
const isCSEnabled = useCrossSigningStatus();
|
||||
const deviceList = useDeviceList();
|
||||
const [processing, setProcessing] = useState([]);
|
||||
const [truncated, setTruncated] = useState(true);
|
||||
const mountStore = useStore();
|
||||
mountStore.setItem(true);
|
||||
const isMeVerified = isCrossVerified(mx.deviceId);
|
||||
|
||||
useEffect(() => {
|
||||
setProcessing([]);
|
||||
|
@ -91,7 +98,7 @@ function DeviceManage() {
|
|||
}
|
||||
|
||||
const handleRename = async (device) => {
|
||||
const newName = window.prompt('Edit session name', device.display_name);
|
||||
const newName = await promptDeviceName(device.display_name);
|
||||
if (newName === null || newName.trim() === '') return;
|
||||
if (newName.trim() === device.display_name) return;
|
||||
addToProcessing(device);
|
||||
|
@ -105,39 +112,41 @@ function DeviceManage() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (device, auth = undefined) => {
|
||||
if (auth === undefined
|
||||
? window.confirm(`You are about to logout "${device.display_name}" session.`)
|
||||
: true
|
||||
) {
|
||||
addToProcessing(device);
|
||||
try {
|
||||
await mx.deleteDevice(device.device_id, auth);
|
||||
} catch (e) {
|
||||
if (e.httpStatus === 401 && e.data?.flows) {
|
||||
const { flows } = e.data;
|
||||
const flow = flows.find((f) => f.stages.includes('m.login.password'));
|
||||
if (flow) {
|
||||
const password = window.prompt('Please enter account password', '');
|
||||
if (password && password.trim() !== '') {
|
||||
handleRemove(device, {
|
||||
session: e.data.session,
|
||||
type: 'm.login.password',
|
||||
password,
|
||||
identifier: {
|
||||
type: 'm.id.user',
|
||||
user: mx.getUserId(),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
window.alert('Failed to remove session!');
|
||||
if (!mountStore.getItem()) return;
|
||||
removeFromProcessing(device);
|
||||
}
|
||||
const handleRemove = async (device) => {
|
||||
const isConfirmed = await confirmDialog(
|
||||
`Logout ${device.display_name}`,
|
||||
`You are about to logout "${device.display_name}" session.`,
|
||||
'Logout',
|
||||
'danger',
|
||||
);
|
||||
if (!isConfirmed) return;
|
||||
addToProcessing(device);
|
||||
await authRequest(`Logout "${device.display_name}"`, async (auth) => {
|
||||
await mx.deleteDevice(device.device_id, auth);
|
||||
});
|
||||
|
||||
if (!mountStore.getItem()) return;
|
||||
removeFromProcessing(device);
|
||||
};
|
||||
|
||||
const verifyWithKey = async (device) => {
|
||||
const keyData = await accessSecretStorage('Session verification');
|
||||
if (!keyData) return;
|
||||
addToProcessing(device);
|
||||
await mx.checkOwnCrossSigningTrust();
|
||||
};
|
||||
|
||||
const verifyWithEmojis = async (deviceId) => {
|
||||
const req = await mx.requestVerification(mx.getUserId(), [deviceId]);
|
||||
openEmojiVerification(req, { userId: mx.getUserId(), deviceId });
|
||||
};
|
||||
|
||||
const verify = (deviceId, isCurrentDevice) => {
|
||||
if (isCurrentDevice) {
|
||||
verifyWithKey(deviceId);
|
||||
return;
|
||||
}
|
||||
verifyWithEmojis(deviceId);
|
||||
};
|
||||
|
||||
const renderDevice = (device, isVerified) => {
|
||||
|
@ -145,13 +154,16 @@ function DeviceManage() {
|
|||
const displayName = device.display_name;
|
||||
const lastIP = device.last_seen_ip;
|
||||
const lastTS = device.last_seen_ts;
|
||||
const isCurrentDevice = mx.deviceId === deviceId;
|
||||
|
||||
return (
|
||||
<SettingTile
|
||||
key={deviceId}
|
||||
title={(
|
||||
<Text style={{ color: isVerified ? '' : 'var(--tc-danger-high)' }}>
|
||||
<Text style={{ color: isVerified !== false ? '' : 'var(--tc-danger-high)' }}>
|
||||
{displayName}
|
||||
<Text variant="b3" span>{` — ${deviceId}${mx.deviceId === deviceId ? ' (current)' : ''}`}</Text>
|
||||
<Text variant="b3" span>{`${displayName ? ' — ' : ''}${deviceId}`}</Text>
|
||||
{isCurrentDevice && <Text span className="device-manage__current-label" variant="b3">Current</Text>}
|
||||
</Text>
|
||||
)}
|
||||
options={
|
||||
|
@ -159,19 +171,27 @@ function DeviceManage() {
|
|||
? <Spinner size="small" />
|
||||
: (
|
||||
<>
|
||||
{((isMeVerified && isVerified === false) || (isCurrentDevice && isVerified === false)) && <Button onClick={() => verify(deviceId, isCurrentDevice)} variant="positive">Verify</Button>}
|
||||
<IconButton size="small" onClick={() => handleRename(device)} src={PencilIC} tooltip="Rename" />
|
||||
<IconButton size="small" onClick={() => handleRemove(device)} src={BinIC} tooltip="Remove session" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
content={(
|
||||
<Text variant="b3">
|
||||
Last activity
|
||||
<span style={{ color: 'var(--tc-surface-normal)' }}>
|
||||
{dateFormat(new Date(lastTS), ' hh:MM TT, dd/mm/yyyy')}
|
||||
</span>
|
||||
{lastIP ? ` at ${lastIP}` : ''}
|
||||
</Text>
|
||||
<>
|
||||
<Text variant="b3">
|
||||
Last activity
|
||||
<span style={{ color: 'var(--tc-surface-normal)' }}>
|
||||
{dateFormat(new Date(lastTS), ' hh:MM TT, dd/mm/yyyy')}
|
||||
</span>
|
||||
{lastIP ? ` at ${lastIP}` : ''}
|
||||
</Text>
|
||||
{isCurrentDevice && (
|
||||
<Text style={{ marginTop: 'var(--sp-ultra-tight)' }} variant="b3">
|
||||
{`Session Key: ${initMatrix.matrixClient.getDeviceEd25519Key().match(/.{1,4}/g).join(' ')}`}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
@ -179,20 +199,43 @@ function DeviceManage() {
|
|||
|
||||
const unverified = [];
|
||||
const verified = [];
|
||||
const noEncryption = [];
|
||||
deviceList.sort((a, b) => b.last_seen_ts - a.last_seen_ts).forEach((device) => {
|
||||
if (isCrossVerified(device.device_id)) verified.push(device);
|
||||
else unverified.push(device);
|
||||
const isVerified = isCrossVerified(device.device_id);
|
||||
if (isVerified === true) {
|
||||
verified.push(device);
|
||||
} else if (isVerified === false) {
|
||||
unverified.push(device);
|
||||
} else {
|
||||
noEncryption.push(device);
|
||||
}
|
||||
});
|
||||
return (
|
||||
<div className="device-manage">
|
||||
<div>
|
||||
<MenuHeader>Unverified sessions</MenuHeader>
|
||||
{!isCSEnabled && (
|
||||
<div style={{ padding: 'var(--sp-extra-tight) var(--sp-normal)' }}>
|
||||
<InfoCard
|
||||
rounded
|
||||
variant="caution"
|
||||
iconSrc={InfoIC}
|
||||
title="Setup cross signing in case you lose all your sessions."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{
|
||||
unverified.length > 0
|
||||
? unverified.map((device) => renderDevice(device, false))
|
||||
: <Text className="device-manage__info">No unverified session</Text>
|
||||
: <Text className="device-manage__info">No unverified sessions</Text>
|
||||
}
|
||||
</div>
|
||||
{noEncryption.length > 0 && (
|
||||
<div>
|
||||
<MenuHeader>Sessions without encryption support</MenuHeader>
|
||||
{noEncryption.map((device) => renderDevice(device, null))}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<MenuHeader>Verified sessions</MenuHeader>
|
||||
{
|
||||
|
@ -201,7 +244,7 @@ function DeviceManage() {
|
|||
if (truncated && index >= TRUNCATED_COUNT) return null;
|
||||
return renderDevice(device, true);
|
||||
})
|
||||
: <Text className="device-manage__info">No verified session</Text>
|
||||
: <Text className="device-manage__info">No verified sessions</Text>
|
||||
}
|
||||
{ verified.length > TRUNCATED_COUNT && (
|
||||
<Button className="device-manage__info" onClick={() => setTruncated(!truncated)}>
|
||||
|
|
|
@ -15,4 +15,32 @@
|
|||
& .setting-tile:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
& .setting-tile__options {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-ultra-tight);
|
||||
& .btn-positive {
|
||||
padding: 6px var(--sp-tight);
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__current-label {
|
||||
margin: 0 var(--sp-extra-tight);
|
||||
padding: 2px var(--sp-ultra-tight);
|
||||
color: var(--bg-surface);
|
||||
background-color: var(--tc-surface-low);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&__rename {
|
||||
padding: var(--sp-normal);
|
||||
& > *:not(:last-child) {
|
||||
margin-bottom: var(--sp-normal);
|
||||
}
|
||||
&-btn {
|
||||
display: flex;
|
||||
gap: var(--sp-normal);
|
||||
}
|
||||
}
|
||||
}
|
288
src/app/organisms/settings/KeyBackup.jsx
Normal file
288
src/app/organisms/settings/KeyBackup.jsx
Normal file
|
@ -0,0 +1,288 @@
|
|||
/* eslint-disable react/prop-types */
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './KeyBackup.scss';
|
||||
import { twemojify } from '../../../util/twemojify';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import { openReusableDialog } from '../../../client/action/navigation';
|
||||
import { deletePrivateKey } from '../../../client/state/secretStorageKeys';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import Button from '../../atoms/button/Button';
|
||||
import IconButton from '../../atoms/button/IconButton';
|
||||
import Spinner from '../../atoms/spinner/Spinner';
|
||||
import InfoCard from '../../atoms/card/InfoCard';
|
||||
import SettingTile from '../../molecules/setting-tile/SettingTile';
|
||||
|
||||
import { accessSecretStorage } from './SecretStorageAccess';
|
||||
|
||||
import InfoIC from '../../../../public/res/ic/outlined/info.svg';
|
||||
import BinIC from '../../../../public/res/ic/outlined/bin.svg';
|
||||
import DownloadIC from '../../../../public/res/ic/outlined/download.svg';
|
||||
|
||||
import { useStore } from '../../hooks/useStore';
|
||||
import { useCrossSigningStatus } from '../../hooks/useCrossSigningStatus';
|
||||
|
||||
function CreateKeyBackupDialog({ keyData }) {
|
||||
const [done, setDone] = useState(false);
|
||||
const mx = initMatrix.matrixClient;
|
||||
const mountStore = useStore();
|
||||
|
||||
const doBackup = async () => {
|
||||
setDone(false);
|
||||
let info;
|
||||
|
||||
try {
|
||||
info = await mx.prepareKeyBackupVersion(
|
||||
null,
|
||||
{ secureSecretStorage: true },
|
||||
);
|
||||
info = await mx.createKeyBackupVersion(info);
|
||||
await mx.scheduleAllGroupSessionsForBackup();
|
||||
if (!mountStore.getItem()) return;
|
||||
setDone(true);
|
||||
} catch (e) {
|
||||
deletePrivateKey(keyData.keyId);
|
||||
await mx.deleteKeyBackupVersion(info.version);
|
||||
if (!mountStore.getItem()) return;
|
||||
setDone(null);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
mountStore.setItem(true);
|
||||
doBackup();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="key-backup__create">
|
||||
{done === false && (
|
||||
<div>
|
||||
<Spinner size="small" />
|
||||
<Text>Creating backup...</Text>
|
||||
</div>
|
||||
)}
|
||||
{done === true && (
|
||||
<>
|
||||
<Text variant="h1">{twemojify('✅')}</Text>
|
||||
<Text>Successfully created backup</Text>
|
||||
</>
|
||||
)}
|
||||
{done === null && (
|
||||
<>
|
||||
<Text>Failed to create backup</Text>
|
||||
<Button onClick={doBackup}>Retry</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
CreateKeyBackupDialog.propTypes = {
|
||||
keyData: PropTypes.shape({}).isRequired,
|
||||
};
|
||||
|
||||
function RestoreKeyBackupDialog({ keyData }) {
|
||||
const [status, setStatus] = useState(false);
|
||||
const mx = initMatrix.matrixClient;
|
||||
const mountStore = useStore();
|
||||
|
||||
const restoreBackup = async () => {
|
||||
setStatus(false);
|
||||
|
||||
let meBreath = true;
|
||||
const progressCallback = (progress) => {
|
||||
if (!progress.successes) return;
|
||||
if (meBreath === false) return;
|
||||
meBreath = false;
|
||||
setTimeout(() => {
|
||||
meBreath = true;
|
||||
}, 200);
|
||||
|
||||
setStatus({ message: `Restoring backup keys... (${progress.successes}/${progress.total})` });
|
||||
};
|
||||
|
||||
try {
|
||||
const backupInfo = await mx.getKeyBackupVersion();
|
||||
const info = await mx.restoreKeyBackupWithSecretStorage(
|
||||
backupInfo,
|
||||
undefined,
|
||||
undefined,
|
||||
{ progressCallback },
|
||||
);
|
||||
if (!mountStore.getItem()) return;
|
||||
setStatus({ done: `Successfully restored backup keys (${info.imported}/${info.total}).` });
|
||||
} catch (e) {
|
||||
if (!mountStore.getItem()) return;
|
||||
if (e.errcode === 'RESTORE_BACKUP_ERROR_BAD_KEY') {
|
||||
deletePrivateKey(keyData.keyId);
|
||||
setStatus({ error: 'Failed to restore backup. Key is invalid!', errorCode: 'BAD_KEY' });
|
||||
} else {
|
||||
setStatus({ error: 'Failed to restore backup.', errCode: 'UNKNOWN' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
mountStore.setItem(true);
|
||||
restoreBackup();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="key-backup__restore">
|
||||
{(status === false || status.message) && (
|
||||
<div>
|
||||
<Spinner size="small" />
|
||||
<Text>{status.message ?? 'Restoring backup keys...'}</Text>
|
||||
</div>
|
||||
)}
|
||||
{status.done && (
|
||||
<>
|
||||
<Text variant="h1">{twemojify('✅')}</Text>
|
||||
<Text>{status.done}</Text>
|
||||
</>
|
||||
)}
|
||||
{status.error && (
|
||||
<>
|
||||
<Text>{status.error}</Text>
|
||||
<Button onClick={restoreBackup}>Retry</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
RestoreKeyBackupDialog.propTypes = {
|
||||
keyData: PropTypes.shape({}).isRequired,
|
||||
};
|
||||
|
||||
function DeleteKeyBackupDialog({ requestClose }) {
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const mx = initMatrix.matrixClient;
|
||||
const mountStore = useStore();
|
||||
|
||||
const deleteBackup = async () => {
|
||||
mountStore.setItem(true);
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const backupInfo = await mx.getKeyBackupVersion();
|
||||
if (backupInfo) await mx.deleteKeyBackupVersion(backupInfo.version);
|
||||
if (!mountStore.getItem()) return;
|
||||
requestClose(true);
|
||||
} catch {
|
||||
if (!mountStore.getItem()) return;
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="key-backup__delete">
|
||||
<Text variant="h1">{twemojify('🗑')}</Text>
|
||||
<Text weight="medium">Deleting key backup is permanent.</Text>
|
||||
<Text>All encrypted messages keys stored on server will be deleted.</Text>
|
||||
{
|
||||
isDeleting
|
||||
? <Spinner size="small" />
|
||||
: <Button variant="danger" onClick={deleteBackup}>Delete</Button>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
DeleteKeyBackupDialog.propTypes = {
|
||||
requestClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
function KeyBackup() {
|
||||
const mx = initMatrix.matrixClient;
|
||||
const isCSEnabled = useCrossSigningStatus();
|
||||
const [keyBackup, setKeyBackup] = useState(undefined);
|
||||
const mountStore = useStore();
|
||||
|
||||
const fetchKeyBackupVersion = async () => {
|
||||
const info = await mx.getKeyBackupVersion();
|
||||
if (!mountStore.getItem()) return;
|
||||
setKeyBackup(info);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
mountStore.setItem(true);
|
||||
fetchKeyBackupVersion();
|
||||
|
||||
const handleAccountData = (event) => {
|
||||
if (event.getType() === 'm.megolm_backup.v1') {
|
||||
fetchKeyBackupVersion();
|
||||
}
|
||||
};
|
||||
|
||||
mx.on('accountData', handleAccountData);
|
||||
return () => {
|
||||
mx.removeListener('accountData', handleAccountData);
|
||||
};
|
||||
}, [isCSEnabled]);
|
||||
|
||||
const openCreateKeyBackup = async () => {
|
||||
const keyData = await accessSecretStorage('Create Key Backup');
|
||||
if (keyData === null) return;
|
||||
|
||||
openReusableDialog(
|
||||
<Text variant="s1" weight="medium">Create Key Backup</Text>,
|
||||
() => <CreateKeyBackupDialog keyData={keyData} />,
|
||||
() => fetchKeyBackupVersion(),
|
||||
);
|
||||
};
|
||||
|
||||
const openRestoreKeyBackup = async () => {
|
||||
const keyData = await accessSecretStorage('Restore Key Backup');
|
||||
if (keyData === null) return;
|
||||
|
||||
openReusableDialog(
|
||||
<Text variant="s1" weight="medium">Restore Key Backup</Text>,
|
||||
() => <RestoreKeyBackupDialog keyData={keyData} />,
|
||||
);
|
||||
};
|
||||
|
||||
const openDeleteKeyBackup = () => openReusableDialog(
|
||||
<Text variant="s1" weight="medium">Delete Key Backup</Text>,
|
||||
(requestClose) => (
|
||||
<DeleteKeyBackupDialog
|
||||
requestClose={(isDone) => {
|
||||
if (isDone) setKeyBackup(null);
|
||||
requestClose();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
);
|
||||
|
||||
const renderOptions = () => {
|
||||
if (keyBackup === undefined) return <Spinner size="small" />;
|
||||
if (keyBackup === null) return <Button variant="primary" onClick={openCreateKeyBackup}>Create Backup</Button>;
|
||||
return (
|
||||
<>
|
||||
<IconButton src={DownloadIC} variant="positive" onClick={openRestoreKeyBackup} tooltip="Restore backup" />
|
||||
<IconButton src={BinIC} onClick={openDeleteKeyBackup} tooltip="Delete backup" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingTile
|
||||
title="Encrypted messages backup"
|
||||
content={(
|
||||
<>
|
||||
<Text variant="b3">Online backup your encrypted messages keys with your account data in case you lose access to your sessions. Your keys will be secured with a unique Security Key.</Text>
|
||||
{!isCSEnabled && (
|
||||
<InfoCard
|
||||
style={{ marginTop: 'var(--sp-ultra-tight)' }}
|
||||
rounded
|
||||
variant="caution"
|
||||
iconSrc={InfoIC}
|
||||
title="Setup cross signing to backup your encrypted messages."
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
options={isCSEnabled ? renderOptions() : null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default KeyBackup;
|
27
src/app/organisms/settings/KeyBackup.scss
Normal file
27
src/app/organisms/settings/KeyBackup.scss
Normal file
|
@ -0,0 +1,27 @@
|
|||
.key-backup__create,
|
||||
.key-backup__restore {
|
||||
padding: var(--sp-normal);
|
||||
|
||||
& > div {
|
||||
padding: var(--sp-normal) 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
& > .text {
|
||||
margin: 0 var(--sp-normal);
|
||||
}
|
||||
}
|
||||
|
||||
& > .text {
|
||||
margin-bottom: var(--sp-normal);
|
||||
}
|
||||
}
|
||||
|
||||
.key-backup__delete {
|
||||
padding: var(--sp-normal);
|
||||
padding-top: var(--sp-extra-loose);
|
||||
|
||||
& > .text {
|
||||
padding-bottom: var(--sp-normal);
|
||||
}
|
||||
}
|
133
src/app/organisms/settings/SecretStorageAccess.jsx
Normal file
133
src/app/organisms/settings/SecretStorageAccess.jsx
Normal file
|
@ -0,0 +1,133 @@
|
|||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './SecretStorageAccess.scss';
|
||||
import { deriveKey } from 'matrix-js-sdk/lib/crypto/key_passphrase';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import { openReusableDialog } from '../../../client/action/navigation';
|
||||
import { getDefaultSSKey, getSSKeyInfo } from '../../../util/matrixUtil';
|
||||
import { storePrivateKey, hasPrivateKey, getPrivateKey } from '../../../client/state/secretStorageKeys';
|
||||
|
||||
import Text from '../../atoms/text/Text';
|
||||
import Button from '../../atoms/button/Button';
|
||||
import Input from '../../atoms/input/Input';
|
||||
import Spinner from '../../atoms/spinner/Spinner';
|
||||
|
||||
import { useStore } from '../../hooks/useStore';
|
||||
|
||||
function SecretStorageAccess({ onComplete }) {
|
||||
const mx = initMatrix.matrixClient;
|
||||
const sSKeyId = getDefaultSSKey();
|
||||
const sSKeyInfo = getSSKeyInfo(sSKeyId);
|
||||
const isPassphrase = !!sSKeyInfo.passphrase;
|
||||
const [withPhrase, setWithPhrase] = useState(isPassphrase);
|
||||
const [process, setProcess] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const mountStore = useStore();
|
||||
|
||||
const toggleWithPhrase = () => setWithPhrase(!withPhrase);
|
||||
|
||||
const processInput = async ({ key, phrase }) => {
|
||||
mountStore.setItem(true);
|
||||
setProcess(true);
|
||||
try {
|
||||
const { salt, iterations } = sSKeyInfo.passphrase || {};
|
||||
const privateKey = key
|
||||
? mx.keyBackupKeyFromRecoveryKey(key)
|
||||
: await deriveKey(phrase, salt, iterations);
|
||||
const isCorrect = await mx.checkSecretStorageKey(privateKey, sSKeyInfo);
|
||||
|
||||
if (!mountStore.getItem()) return;
|
||||
if (!isCorrect) {
|
||||
setError(`Incorrect Security ${key ? 'Key' : 'Phrase'}`);
|
||||
setProcess(false);
|
||||
return;
|
||||
}
|
||||
onComplete({
|
||||
keyId: sSKeyId,
|
||||
key,
|
||||
phrase,
|
||||
privateKey,
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mountStore.getItem()) return;
|
||||
setError(`Incorrect Security ${key ? 'Key' : 'Phrase'}`);
|
||||
setProcess(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleForm = async (e) => {
|
||||
e.preventDefault();
|
||||
const password = e.target.password.value;
|
||||
if (password.trim() === '') return;
|
||||
const data = {};
|
||||
if (withPhrase) data.phrase = password;
|
||||
else data.key = password;
|
||||
processInput(data);
|
||||
};
|
||||
|
||||
const handleChange = () => {
|
||||
setError(null);
|
||||
setProcess(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="secret-storage-access">
|
||||
<form onSubmit={handleForm}>
|
||||
<Input
|
||||
name="password"
|
||||
label={`Security ${withPhrase ? 'Phrase' : 'Key'}`}
|
||||
type="password"
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
{error && <Text variant="b3">{error}</Text>}
|
||||
{!process && (
|
||||
<div className="secret-storage-access__btn">
|
||||
<Button variant="primary" type="submit">Continue</Button>
|
||||
{isPassphrase && <Button onClick={toggleWithPhrase}>{`Use Security ${withPhrase ? 'Key' : 'Phrase'}`}</Button>}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
{process && <Spinner size="small" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
SecretStorageAccess.propTypes = {
|
||||
onComplete: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string} title Title of secret storage access dialog
|
||||
* @returns {Promise<keyData | null>} resolve to keyData or null
|
||||
*/
|
||||
export const accessSecretStorage = (title) => new Promise((resolve) => {
|
||||
let isCompleted = false;
|
||||
const defaultSSKey = getDefaultSSKey();
|
||||
if (hasPrivateKey(defaultSSKey)) {
|
||||
resolve({ keyId: defaultSSKey, privateKey: getPrivateKey(defaultSSKey) });
|
||||
return;
|
||||
}
|
||||
const handleComplete = (keyData) => {
|
||||
isCompleted = true;
|
||||
storePrivateKey(keyData.keyId, keyData.privateKey);
|
||||
resolve(keyData);
|
||||
};
|
||||
|
||||
openReusableDialog(
|
||||
<Text variant="s1" weight="medium">{title}</Text>,
|
||||
(requestClose) => (
|
||||
<SecretStorageAccess
|
||||
onComplete={(keyData) => {
|
||||
handleComplete(keyData);
|
||||
requestClose(requestClose);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
() => {
|
||||
if (!isCompleted) resolve(null);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
export default SecretStorageAccess;
|
20
src/app/organisms/settings/SecretStorageAccess.scss
Normal file
20
src/app/organisms/settings/SecretStorageAccess.scss
Normal file
|
@ -0,0 +1,20 @@
|
|||
.secret-storage-access {
|
||||
padding: var(--sp-normal);
|
||||
|
||||
& form > *:not(:first-child) {
|
||||
margin-top: var(--sp-normal);
|
||||
}
|
||||
|
||||
& .text-b3 {
|
||||
color: var(--tc-danger-high);
|
||||
margin-top: var(--sp-ultra-tight) !important;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
& .donut-spinner {
|
||||
margin-top: var(--sp-normal);
|
||||
}
|
||||
}
|
|
@ -26,6 +26,8 @@ import ImportE2ERoomKeys from '../../molecules/import-export-e2e-room-keys/Impor
|
|||
import ExportE2ERoomKeys from '../../molecules/import-export-e2e-room-keys/ExportE2ERoomKeys';
|
||||
|
||||
import ProfileEditor from '../profile-editor/ProfileEditor';
|
||||
import CrossSigning from './CrossSigning';
|
||||
import KeyBackup from './KeyBackup';
|
||||
import DeviceManage from './DeviceManage';
|
||||
|
||||
import SunIC from '../../../../public/res/ic/outlined/sun.svg';
|
||||
|
@ -36,6 +38,7 @@ import PowerIC from '../../../../public/res/ic/outlined/power.svg';
|
|||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||
|
||||
import CinnySVG from '../../../../public/res/svg/cinny.svg';
|
||||
import { confirmDialog } from '../../molecules/confirm-dialog/ConfirmDialog';
|
||||
|
||||
function AppearanceSection() {
|
||||
const [, updateState] = useState({});
|
||||
|
@ -168,18 +171,13 @@ function SecuritySection() {
|
|||
return (
|
||||
<div className="settings-security">
|
||||
<div className="settings-security__card">
|
||||
<MenuHeader>Session Info</MenuHeader>
|
||||
<SettingTile
|
||||
title={`Session ID: ${initMatrix.matrixClient.getDeviceId()}`}
|
||||
/>
|
||||
<SettingTile
|
||||
title={`Session key: ${initMatrix.matrixClient.getDeviceEd25519Key().match(/.{1,4}/g).join(' ')}`}
|
||||
content={<Text variant="b3">Use this session ID-key combo to verify or manage this session.</Text>}
|
||||
/>
|
||||
<MenuHeader>Cross signing and backup</MenuHeader>
|
||||
<CrossSigning />
|
||||
<KeyBackup />
|
||||
</div>
|
||||
<DeviceManage />
|
||||
<div className="settings-security__card">
|
||||
<MenuHeader>Encryption</MenuHeader>
|
||||
<MenuHeader>Export/Import encryption keys</MenuHeader>
|
||||
<SettingTile
|
||||
title="Export E2E room keys"
|
||||
content={(
|
||||
|
@ -247,7 +245,7 @@ function AboutSection() {
|
|||
);
|
||||
}
|
||||
|
||||
const tabText = {
|
||||
export const tabText = {
|
||||
APPEARANCE: 'Appearance',
|
||||
NOTIFICATIONS: 'Notifications',
|
||||
SECURITY: 'Security',
|
||||
|
@ -300,8 +298,10 @@ function Settings() {
|
|||
const [isOpen, requestClose] = useWindowToggle(setSelectedTab);
|
||||
|
||||
const handleTabChange = (tabItem) => setSelectedTab(tabItem);
|
||||
const handleLogout = () => {
|
||||
if (confirm('Confirm logout')) logout();
|
||||
const handleLogout = async () => {
|
||||
if (await confirmDialog('Logout', 'Are you sure that you want to logout your session?', 'Logout', 'danger')) {
|
||||
logout();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
@use '../../partials/flex';
|
||||
@use '../../partials/dir';
|
||||
@use '../../partials/screen';
|
||||
|
||||
.settings-window {
|
||||
& .pw {
|
||||
|
@ -86,4 +87,4 @@
|
|||
margin: var(--sp-extra-tight) 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -36,6 +36,7 @@ import PinFilledIC from '../../../../public/res/ic/filled/pin.svg';
|
|||
import CategoryIC from '../../../../public/res/ic/outlined/category.svg';
|
||||
import CategoryFilledIC from '../../../../public/res/ic/filled/category.svg';
|
||||
|
||||
import { confirmDialog } from '../../molecules/confirm-dialog/ConfirmDialog';
|
||||
import { useForceUpdate } from '../../hooks/useForceUpdate';
|
||||
|
||||
const tabText = {
|
||||
|
@ -61,6 +62,7 @@ const tabItems = [{
|
|||
function GeneralSettings({ roomId }) {
|
||||
const isPinned = initMatrix.accountData.spaceShortcut.has(roomId);
|
||||
const isCategorized = initMatrix.accountData.categorizedSpaces.has(roomId);
|
||||
const roomName = initMatrix.matrixClient.getRoom(roomId)?.name;
|
||||
const [, forceUpdate] = useForceUpdate();
|
||||
|
||||
return (
|
||||
|
@ -89,10 +91,14 @@ function GeneralSettings({ roomId }) {
|
|||
</MenuItem>
|
||||
<MenuItem
|
||||
variant="danger"
|
||||
onClick={() => {
|
||||
if (confirm('Are you sure that you want to leave this space?')) {
|
||||
leave(roomId);
|
||||
}
|
||||
onClick={async () => {
|
||||
const isConfirmed = await confirmDialog(
|
||||
'Leave space',
|
||||
`Are you sure that you want to leave "${roomName}" space?`,
|
||||
'Leave',
|
||||
'danger',
|
||||
);
|
||||
if (isConfirmed) leave(roomId);
|
||||
}}
|
||||
iconSrc={LeaveArrowIC}
|
||||
>
|
||||
|
|
28
src/app/partials/_screen.scss
Normal file
28
src/app/partials/_screen.scss
Normal file
|
@ -0,0 +1,28 @@
|
|||
|
||||
$breakpoint-tablet: 1124px;
|
||||
$breakpoint-mobile: 750px;
|
||||
|
||||
@mixin smallerThan($deviceBreakpoint) {
|
||||
@if $deviceBreakpoint==mobileBreakpoint {
|
||||
@media screen and (max-width: $breakpoint-mobile) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
@else if $deviceBreakpoint==tabletBreakpoint {
|
||||
@media screen and (max-width: $breakpoint-tablet) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@mixin biggerThan($deviceBreakpoint) {
|
||||
@if $deviceBreakpoint==mobileBreakpoint {
|
||||
@media screen and (min-width: $breakpoint-mobile) {
|
||||
@content;
|
||||
}
|
||||
} @else if $deviceBreakpoint==tabletBreakpoint {
|
||||
@media screen and (min-width: $breakpoint-tablet) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -93,12 +93,13 @@ function Homeserver({ onChange }) {
|
|||
const result = await (await fetch(configFileUrl, { method: 'GET' })).json();
|
||||
const selectedHs = result?.defaultHomeserver;
|
||||
const hsList = result?.homeserverList;
|
||||
const allowCustom = result?.allowCustomHomeservers ?? true;
|
||||
if (!hsList?.length > 0 || selectedHs < 0 || selectedHs >= hsList?.length) {
|
||||
throw new Error();
|
||||
}
|
||||
setHs({ selected: hsList[selectedHs], list: hsList });
|
||||
setHs({ selected: hsList[selectedHs], list: hsList, allowCustom: allowCustom });
|
||||
} catch {
|
||||
setHs({ selected: 'matrix.org', list: ['matrix.org'] });
|
||||
setHs({ selected: 'matrix.org', list: ['matrix.org'], allowCustom: true });
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
@ -106,14 +107,15 @@ function Homeserver({ onChange }) {
|
|||
const { value } = e.target;
|
||||
setProcess({ isLoading: false });
|
||||
debounce._(async () => {
|
||||
setHs({ selected: value.trim(), list: hs.list });
|
||||
setHs({ ...hs, selected: value.trim() });
|
||||
}, 700)();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="homeserver-form">
|
||||
<Input name="homeserver" onChange={handleHsInput} value={hs?.selected} forwardRef={hsRef} label="Homeserver" />
|
||||
<Input name="homeserver" onChange={handleHsInput} value={hs?.selected} forwardRef={hsRef} label="Homeserver"
|
||||
disabled={hs === null || !hs.allowCustom} />
|
||||
<ContextMenu
|
||||
placement="right"
|
||||
content={(hideMenu) => (
|
||||
|
@ -126,7 +128,7 @@ function Homeserver({ onChange }) {
|
|||
onClick={() => {
|
||||
hideMenu();
|
||||
hsRef.current.value = hsName;
|
||||
setHs({ selected: hsName, list: hs.list });
|
||||
setHs({ ...hs, selected: hsName });
|
||||
}}
|
||||
>
|
||||
{hsName}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import './Client.scss';
|
||||
|
||||
import { initHotkeys } from '../../../client/event/hotkeys';
|
||||
|
@ -23,6 +23,29 @@ function Client() {
|
|||
const [isLoading, changeLoading] = useState(true);
|
||||
const [loadingMsg, setLoadingMsg] = useState('Heating up');
|
||||
const [dragCounter, setDragCounter] = useState(0);
|
||||
const classNameHidden = 'client__item-hidden';
|
||||
|
||||
const navWrapperRef = useRef(null);
|
||||
const roomWrapperRef = useRef(null);
|
||||
|
||||
function onRoomSelected() {
|
||||
navWrapperRef.current?.classList.add(classNameHidden);
|
||||
roomWrapperRef.current?.classList.remove(classNameHidden);
|
||||
}
|
||||
function onNavigationSelected() {
|
||||
navWrapperRef.current?.classList.remove(classNameHidden);
|
||||
roomWrapperRef.current?.classList.add(classNameHidden);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
navigation.on(cons.events.navigation.ROOM_SELECTED, onRoomSelected);
|
||||
navigation.on(cons.events.navigation.NAVIGATION_OPENED, onNavigationSelected);
|
||||
|
||||
return (() => {
|
||||
navigation.removeListener(cons.events.navigation.ROOM_SELECTED, onRoomSelected);
|
||||
navigation.removeListener(cons.events.navigation.NAVIGATION_OPENED, onNavigationSelected);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let counter = 0;
|
||||
|
@ -128,10 +151,10 @@ function Client() {
|
|||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="navigation__wrapper">
|
||||
<div className="navigation__wrapper" ref={navWrapperRef}>
|
||||
<Navigation />
|
||||
</div>
|
||||
<div className="room__wrapper">
|
||||
<div className={`room__wrapper ${classNameHidden}`} ref={roomWrapperRef}>
|
||||
<Room />
|
||||
</div>
|
||||
<Windows />
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
@use '../../partials/screen';
|
||||
|
||||
.client-container {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
|
@ -5,12 +7,22 @@
|
|||
|
||||
.navigation__wrapper {
|
||||
width: var(--navigation-width);
|
||||
|
||||
@include screen.smallerThan(mobileBreakpoint) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.room__wrapper {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@include screen.smallerThan(mobileBreakpoint) {
|
||||
.client__item-hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-display {
|
||||
position: absolute;
|
||||
|
@ -41,4 +53,4 @@
|
|||
.text {
|
||||
color: var(--tc-link);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,6 +23,13 @@ export function selectRoom(roomId, eventId) {
|
|||
});
|
||||
}
|
||||
|
||||
// Open navigation on compact screen sizes
|
||||
export function openNavigation() {
|
||||
appDispatcher.dispatch({
|
||||
type: cons.actions.navigation.OPEN_NAVIGATION,
|
||||
});
|
||||
}
|
||||
|
||||
export function openSpaceSettings(roomId, tabText) {
|
||||
appDispatcher.dispatch({
|
||||
type: cons.actions.navigation.OPEN_SPACE_SETTINGS,
|
||||
|
@ -79,6 +86,13 @@ export function openCreateRoom(isSpace = false, parentId = null) {
|
|||
});
|
||||
}
|
||||
|
||||
export function openJoinAlias(term) {
|
||||
appDispatcher.dispatch({
|
||||
type: cons.actions.navigation.OPEN_JOIN_ALIAS,
|
||||
term,
|
||||
});
|
||||
}
|
||||
|
||||
export function openInviteUser(roomId, searchTerm) {
|
||||
appDispatcher.dispatch({
|
||||
type: cons.actions.navigation.OPEN_INVITE_USER,
|
||||
|
@ -159,3 +173,11 @@ export function openReusableDialog(title, render, afterClose) {
|
|||
afterClose,
|
||||
});
|
||||
}
|
||||
|
||||
export function openEmojiVerification(request, targetDevice) {
|
||||
appDispatcher.dispatch({
|
||||
type: cons.actions.navigation.OPEN_EMOJI_VERIFICATION,
|
||||
request,
|
||||
targetDevice,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -113,17 +113,19 @@ async function join(roomIdOrAlias, isDM, via) {
|
|||
* @param {string} roomId
|
||||
* @param {boolean} isDM
|
||||
*/
|
||||
function leave(roomId) {
|
||||
async function leave(roomId) {
|
||||
const mx = initMatrix.matrixClient;
|
||||
const isDM = initMatrix.roomList.directs.has(roomId);
|
||||
mx.leave(roomId)
|
||||
.then(() => {
|
||||
appDispatcher.dispatch({
|
||||
type: cons.actions.room.LEAVE,
|
||||
roomId,
|
||||
isDM,
|
||||
});
|
||||
}).catch();
|
||||
try {
|
||||
await mx.leave(roomId);
|
||||
appDispatcher.dispatch({
|
||||
type: cons.actions.room.LEAVE,
|
||||
roomId,
|
||||
isDM,
|
||||
});
|
||||
} catch {
|
||||
console.error('Unable to leave room.');
|
||||
}
|
||||
}
|
||||
|
||||
async function create(options, isDM = false) {
|
||||
|
|
|
@ -2,25 +2,60 @@ import { openSearch, toggleRoomSettings } from '../action/navigation';
|
|||
import navigation from '../state/navigation';
|
||||
import { markAsRead } from '../action/notifications';
|
||||
|
||||
function shouldFocusMessageField(code) {
|
||||
// do not focus on F keys
|
||||
if (/^F\d+$/.test(code)) return false;
|
||||
|
||||
// do not focus on numlock/scroll lock
|
||||
if (
|
||||
code.metaKey
|
||||
|| code.startsWith('OS')
|
||||
|| code.startsWith('Meta')
|
||||
|| code.startsWith('Shift')
|
||||
|| code.startsWith('Alt')
|
||||
|| code.startsWith('Control')
|
||||
|| code.startsWith('Arrow')
|
||||
|| code === 'Tab'
|
||||
|| code === 'Space'
|
||||
|| code === 'Enter'
|
||||
|| code === 'NumLock'
|
||||
|| code === 'ScrollLock'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function listenKeyboard(event) {
|
||||
// Ctrl/Cmd +
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
// k - for search Modal
|
||||
if (event.keyCode === 75) {
|
||||
// open search modal
|
||||
if (event.code === 'KeyK') {
|
||||
event.preventDefault();
|
||||
if (navigation.isRawModalVisible) return;
|
||||
openSearch();
|
||||
}
|
||||
|
||||
// focus message field on paste
|
||||
if (event.code === 'KeyV') {
|
||||
if (navigation.isRawModalVisible) return;
|
||||
const msgTextarea = document.getElementById('message-textarea');
|
||||
const { activeElement } = document;
|
||||
if (activeElement !== msgTextarea
|
||||
&& ['input', 'textarea'].includes(activeElement.tagName.toLowerCase())
|
||||
) return;
|
||||
msgTextarea?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
if (!event.ctrlKey && !event.altKey) {
|
||||
if (!event.ctrlKey && !event.altKey && !event.metaKey) {
|
||||
if (navigation.isRawModalVisible) return;
|
||||
if (['text', 'textarea'].includes(document.activeElement.type)) {
|
||||
if (['input', 'textarea'].includes(document.activeElement.tagName.toLowerCase())) {
|
||||
return;
|
||||
}
|
||||
|
||||
// esc
|
||||
if (event.keyCode === 27) {
|
||||
if (event.code === 'Escape') {
|
||||
if (navigation.isRoomSettings) {
|
||||
toggleRoomSettings();
|
||||
return;
|
||||
|
@ -31,16 +66,12 @@ function listenKeyboard(event) {
|
|||
}
|
||||
}
|
||||
|
||||
// Don't allow these keys to type/focus message field
|
||||
if ((event.keyCode !== 8 && event.keyCode < 48)
|
||||
|| (event.keyCode >= 91 && event.keyCode <= 93)
|
||||
|| (event.keyCode >= 112 && event.keyCode <= 183)) {
|
||||
return;
|
||||
// focus the text field on most keypresses
|
||||
if (shouldFocusMessageField(event.code)) {
|
||||
// press any key to focus and type in message field
|
||||
const msgTextarea = document.getElementById('message-textarea');
|
||||
msgTextarea?.focus();
|
||||
}
|
||||
|
||||
// press any key to focus and type in message field
|
||||
const msgTextarea = document.getElementById('message-textarea');
|
||||
msgTextarea?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -7,6 +7,7 @@ import RoomList from './state/RoomList';
|
|||
import AccountData from './state/AccountData';
|
||||
import RoomsInput from './state/RoomsInput';
|
||||
import Notifications from './state/Notifications';
|
||||
import { cryptoCallbacks } from './state/secretStorageKeys';
|
||||
|
||||
global.Olm = require('@matrix-org/olm');
|
||||
|
||||
|
@ -36,6 +37,10 @@ class InitMatrix extends EventEmitter {
|
|||
cryptoStore: new sdk.IndexedDBCryptoStore(global.indexedDB, 'crypto-store'),
|
||||
deviceId: secret.deviceId,
|
||||
timelineSupport: true,
|
||||
cryptoCallbacks,
|
||||
verificationMethods: [
|
||||
'm.sas.v1',
|
||||
],
|
||||
});
|
||||
|
||||
await this.matrixClient.initCrypto();
|
||||
|
|
|
@ -6,9 +6,6 @@ import cons from './cons';
|
|||
import navigation from './navigation';
|
||||
import settings from './settings';
|
||||
|
||||
import NotificationSound from '../../../public/sound/notification.ogg';
|
||||
import InviteSound from '../../../public/sound/invite.ogg';
|
||||
|
||||
function isNotifEvent(mEvent) {
|
||||
const eType = mEvent.getType();
|
||||
if (!cons.supportEventTypes.includes(eType)) return false;
|
||||
|
@ -238,14 +235,14 @@ class Notifications extends EventEmitter {
|
|||
|
||||
_playNotiSound() {
|
||||
if (!this._notiAudio) {
|
||||
this._notiAudio = new Audio(NotificationSound);
|
||||
this._notiAudio = document.getElementById('notificationSound');
|
||||
}
|
||||
this._notiAudio.play();
|
||||
}
|
||||
|
||||
_playInviteSound() {
|
||||
if (!this._inviteAudio) {
|
||||
this._inviteAudio = new Audio(InviteSound);
|
||||
this._inviteAudio = document.getElementById('inviteSound');
|
||||
}
|
||||
this._inviteAudio.play();
|
||||
}
|
||||
|
|
|
@ -6,6 +6,21 @@ function isMEventSpaceChild(mEvent) {
|
|||
return mEvent.getType() === 'm.space.child' && Object.keys(mEvent.getContent()).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {() => boolean} callback if return true wait will over else callback will be called again.
|
||||
* @param {number} timeout timeout to callback
|
||||
* @param {number} maxTry maximum callback try > 0. -1 means no limit
|
||||
*/
|
||||
async function waitFor(callback, timeout = 400, maxTry = -1) {
|
||||
if (maxTry === 0) return false;
|
||||
const isOver = async () => new Promise((resolve) => {
|
||||
setTimeout(() => resolve(callback()), timeout);
|
||||
});
|
||||
|
||||
if (await isOver()) return true;
|
||||
return waitFor(callback, timeout, maxTry - 1);
|
||||
}
|
||||
|
||||
class RoomList extends EventEmitter {
|
||||
constructor(matrixClient) {
|
||||
super();
|
||||
|
@ -228,6 +243,7 @@ class RoomList extends EventEmitter {
|
|||
}
|
||||
|
||||
_isDMInvite(room) {
|
||||
if (this.mDirects.has(room.roomId)) return true;
|
||||
const me = room.getMember(this.matrixClient.getUserId());
|
||||
const myEventContent = me.events.member.getContent();
|
||||
return myEventContent.membership === 'invite' && myEventContent.is_direct;
|
||||
|
@ -243,22 +259,11 @@ class RoomList extends EventEmitter {
|
|||
latestMDirects.forEach((directId) => {
|
||||
const myRoom = this.matrixClient.getRoom(directId);
|
||||
if (this.mDirects.has(directId)) return;
|
||||
|
||||
// Update mDirects
|
||||
this.mDirects.add(directId);
|
||||
|
||||
if (myRoom === null) return;
|
||||
|
||||
if (this._isDMInvite(myRoom)) return;
|
||||
|
||||
if (myRoom.getMyMembership === 'join' && !this.directs.has(directId)) {
|
||||
if (myRoom.getMyMembership() === 'join') {
|
||||
this.directs.add(directId);
|
||||
}
|
||||
|
||||
// Newly added room.
|
||||
// at this time my membership can be invite | join
|
||||
if (myRoom.getMyMembership() === 'join' && this.rooms.has(directId)) {
|
||||
// found a DM which accidentally gets added to this.rooms
|
||||
this.rooms.delete(directId);
|
||||
this.emit(cons.events.roomList.ROOMLIST_UPDATED);
|
||||
}
|
||||
|
@ -298,23 +303,17 @@ class RoomList extends EventEmitter {
|
|||
}
|
||||
});
|
||||
|
||||
this.matrixClient.on('Room.myMembership', (room, membership, prevMembership) => {
|
||||
this.matrixClient.on('Room.myMembership', async (room, membership, prevMembership) => {
|
||||
// room => prevMembership = null | invite | join | leave | kick | ban | unban
|
||||
// room => membership = invite | join | leave | kick | ban | unban
|
||||
const { roomId } = room;
|
||||
const isRoomReady = () => this.matrixClient.getRoom(roomId) !== null;
|
||||
if (['join', 'invite'].includes(membership) && isRoomReady() === false) {
|
||||
if (await waitFor(isRoomReady, 200, 100) === false) return;
|
||||
}
|
||||
|
||||
if (membership === 'unban') return;
|
||||
|
||||
// When user_reject/sender_undo room invite
|
||||
if (prevMembership === 'invite') {
|
||||
if (this.inviteDirects.has(roomId)) this.inviteDirects.delete(roomId);
|
||||
else if (this.inviteSpaces.has(roomId)) this.inviteSpaces.delete(roomId);
|
||||
else this.inviteRooms.delete(roomId);
|
||||
|
||||
this.emit(cons.events.roomList.INVITELIST_UPDATED, roomId);
|
||||
}
|
||||
|
||||
// When user get invited
|
||||
if (membership === 'invite') {
|
||||
if (this._isDMInvite(room)) this.inviteDirects.add(roomId);
|
||||
else if (room.isSpaceRoom()) this.inviteSpaces.add(roomId);
|
||||
|
@ -324,88 +323,53 @@ class RoomList extends EventEmitter {
|
|||
return;
|
||||
}
|
||||
|
||||
// When user join room (first time) or start DM.
|
||||
if ((prevMembership === null || prevMembership === 'invite') && membership === 'join') {
|
||||
// when user create room/DM OR accept room/dm invite from this client.
|
||||
// we will update this.rooms/this.directs with user action
|
||||
if (this.directs.has(roomId) || this.spaces.has(roomId) || this.rooms.has(roomId)) return;
|
||||
if (prevMembership === 'invite') {
|
||||
if (this.inviteDirects.has(roomId)) this.inviteDirects.delete(roomId);
|
||||
else if (this.inviteSpaces.has(roomId)) this.inviteSpaces.delete(roomId);
|
||||
else this.inviteRooms.delete(roomId);
|
||||
|
||||
if (this.processingRooms.has(roomId)) {
|
||||
const procRoomInfo = this.processingRooms.get(roomId);
|
||||
|
||||
if (procRoomInfo.isDM) this.directs.add(roomId);
|
||||
else if (room.isSpaceRoom()) this.addToSpaces(roomId);
|
||||
else this.rooms.add(roomId);
|
||||
|
||||
if (procRoomInfo.task === 'CREATE') this.emit(cons.events.roomList.ROOM_CREATED, roomId);
|
||||
this.emit(cons.events.roomList.ROOM_JOINED, roomId);
|
||||
this.emit(cons.events.roomList.ROOMLIST_UPDATED);
|
||||
|
||||
this.processingRooms.delete(roomId);
|
||||
return;
|
||||
}
|
||||
if (room.isSpaceRoom()) {
|
||||
this.addToSpaces(roomId);
|
||||
|
||||
this.emit(cons.events.roomList.ROOM_JOINED, roomId);
|
||||
this.emit(cons.events.roomList.ROOMLIST_UPDATED);
|
||||
return;
|
||||
}
|
||||
|
||||
// below code intented to work when user create room/DM
|
||||
// OR accept room/dm invite from other client.
|
||||
// and we have to update our client. (it's ok to have 10sec delay)
|
||||
|
||||
// create a buffer of 10sec and HOPE client.accoundData get updated
|
||||
// then accoundData event listener will update this.mDirects.
|
||||
// and we will be able to know if it's a DM.
|
||||
// ----------
|
||||
// less likely situation:
|
||||
// if we don't get accountData with 10sec then:
|
||||
// we will temporary add it to this.rooms.
|
||||
// and in future when accountData get updated
|
||||
// accountData listener will automatically goona REMOVE it from this.rooms
|
||||
// and will ADD it to this.directs
|
||||
// and emit the cons.events.roomList.ROOMLIST_UPDATED to update the UI.
|
||||
|
||||
setTimeout(() => {
|
||||
if (this.directs.has(roomId) || this.spaces.has(roomId) || this.rooms.has(roomId)) return;
|
||||
if (this.mDirects.has(roomId)) this.directs.add(roomId);
|
||||
else this.rooms.add(roomId);
|
||||
|
||||
this.emit(cons.events.roomList.ROOM_JOINED, roomId);
|
||||
this.emit(cons.events.roomList.ROOMLIST_UPDATED);
|
||||
}, 10000);
|
||||
return;
|
||||
this.emit(cons.events.roomList.INVITELIST_UPDATED, roomId);
|
||||
}
|
||||
|
||||
// when room is a DM add/remove it from DM's and return.
|
||||
if (this.directs.has(roomId)) {
|
||||
if (membership === 'leave' || membership === 'kick' || membership === 'ban') {
|
||||
this.directs.delete(roomId);
|
||||
this.emit(cons.events.roomList.ROOM_LEAVED, roomId);
|
||||
}
|
||||
}
|
||||
if (this.mDirects.has(roomId)) {
|
||||
if (membership === 'join') {
|
||||
this.directs.add(roomId);
|
||||
this.emit(cons.events.roomList.ROOM_JOINED, roomId);
|
||||
}
|
||||
if (['leave', 'kick', 'ban'].includes(membership)) {
|
||||
if (this.directs.has(roomId)) this.directs.delete(roomId);
|
||||
else if (this.spaces.has(roomId)) this.deleteFromSpaces(roomId);
|
||||
else this.rooms.delete(roomId);
|
||||
this.emit(cons.events.roomList.ROOM_LEAVED, roomId);
|
||||
this.emit(cons.events.roomList.ROOMLIST_UPDATED);
|
||||
return;
|
||||
}
|
||||
// when room is not a DM add/remove it from rooms.
|
||||
if (membership === 'leave' || membership === 'kick' || membership === 'ban') {
|
||||
if (room.isSpaceRoom()) this.deleteFromSpaces(roomId);
|
||||
else this.rooms.delete(roomId);
|
||||
this.emit(cons.events.roomList.ROOM_LEAVED, roomId);
|
||||
|
||||
// when user create room/DM OR accept room/dm invite from this client.
|
||||
// we will update this.rooms/this.directs with user action
|
||||
if (membership === 'join' && this.processingRooms.has(roomId)) {
|
||||
const procRoomInfo = this.processingRooms.get(roomId);
|
||||
|
||||
if (procRoomInfo.isDM) this.directs.add(roomId);
|
||||
else if (room.isSpaceRoom()) this.addToSpaces(roomId);
|
||||
else this.rooms.add(roomId);
|
||||
|
||||
if (procRoomInfo.task === 'CREATE') this.emit(cons.events.roomList.ROOM_CREATED, roomId);
|
||||
this.emit(cons.events.roomList.ROOM_JOINED, roomId);
|
||||
this.emit(cons.events.roomList.ROOMLIST_UPDATED);
|
||||
|
||||
this.processingRooms.delete(roomId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.mDirects.has(roomId) && membership === 'join') {
|
||||
this.directs.add(roomId);
|
||||
this.emit(cons.events.roomList.ROOM_JOINED, roomId);
|
||||
this.emit(cons.events.roomList.ROOMLIST_UPDATED);
|
||||
return;
|
||||
}
|
||||
|
||||
if (membership === 'join') {
|
||||
if (room.isSpaceRoom()) this.addToSpaces(roomId);
|
||||
else this.rooms.add(roomId);
|
||||
this.emit(cons.events.roomList.ROOM_JOINED, roomId);
|
||||
this.emit(cons.events.roomList.ROOMLIST_UPDATED);
|
||||
}
|
||||
this.emit(cons.events.roomList.ROOMLIST_UPDATED);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,8 +2,9 @@ import EventEmitter from 'events';
|
|||
import { micromark } from 'micromark';
|
||||
import { gfm, gfmHtml } from 'micromark-extension-gfm';
|
||||
import encrypt from 'browser-encrypt-attachment';
|
||||
import { math } from 'micromark-extension-math';
|
||||
import { getShortcodeToEmoji } from '../../app/organisms/emoji-board/custom-emoji';
|
||||
import { spoilerExtension, spoilerExtensionHtml } from '../../util/markdown';
|
||||
import { mathExtensionHtml, spoilerExtension, spoilerExtensionHtml } from '../../util/markdown';
|
||||
import cons from './cons';
|
||||
import settings from './settings';
|
||||
|
||||
|
@ -85,8 +86,8 @@ function getVideoThumbnail(video, width, height, mimeType) {
|
|||
|
||||
function getFormattedBody(markdown) {
|
||||
const result = micromark(markdown, {
|
||||
extensions: [gfm(), spoilerExtension()],
|
||||
htmlExtensions: [gfmHtml(), spoilerExtensionHtml],
|
||||
extensions: [gfm(), spoilerExtension(), math()],
|
||||
htmlExtensions: [gfmHtml(), spoilerExtensionHtml, mathExtensionHtml],
|
||||
});
|
||||
const bodyParts = result.match(/^(<p>)(.*)(<\/p>)$/);
|
||||
if (bodyParts === null) return result;
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
const cons = {
|
||||
version: '1.8.2',
|
||||
version: '2.0.3',
|
||||
secretKey: {
|
||||
ACCESS_TOKEN: 'cinny_access_token',
|
||||
DEVICE_ID: 'cinny_device_id',
|
||||
|
@ -38,6 +38,7 @@ const cons = {
|
|||
OPEN_INVITE_LIST: 'OPEN_INVITE_LIST',
|
||||
OPEN_PUBLIC_ROOMS: 'OPEN_PUBLIC_ROOMS',
|
||||
OPEN_CREATE_ROOM: 'OPEN_CREATE_ROOM',
|
||||
OPEN_JOIN_ALIAS: 'OPEN_JOIN_ALIAS',
|
||||
OPEN_INVITE_USER: 'OPEN_INVITE_USER',
|
||||
OPEN_PROFILE_VIEWER: 'OPEN_PROFILE_VIEWER',
|
||||
OPEN_SETTINGS: 'OPEN_SETTINGS',
|
||||
|
@ -47,7 +48,9 @@ const cons = {
|
|||
CLICK_REPLY_TO: 'CLICK_REPLY_TO',
|
||||
OPEN_SEARCH: 'OPEN_SEARCH',
|
||||
OPEN_REUSABLE_CONTEXT_MENU: 'OPEN_REUSABLE_CONTEXT_MENU',
|
||||
OPEN_NAVIGATION: 'OPEN_NAVIGATION',
|
||||
OPEN_REUSABLE_DIALOG: 'OPEN_REUSABLE_DIALOG',
|
||||
OPEN_EMOJI_VERIFICATION: 'OPEN_EMOJI_VERIFICATION',
|
||||
},
|
||||
room: {
|
||||
JOIN: 'JOIN',
|
||||
|
@ -84,6 +87,7 @@ const cons = {
|
|||
INVITE_LIST_OPENED: 'INVITE_LIST_OPENED',
|
||||
PUBLIC_ROOMS_OPENED: 'PUBLIC_ROOMS_OPENED',
|
||||
CREATE_ROOM_OPENED: 'CREATE_ROOM_OPENED',
|
||||
JOIN_ALIAS_OPENED: 'JOIN_ALIAS_OPENED',
|
||||
INVITE_USER_OPENED: 'INVITE_USER_OPENED',
|
||||
SETTINGS_OPENED: 'SETTINGS_OPENED',
|
||||
PROFILE_VIEWER_OPENED: 'PROFILE_VIEWER_OPENED',
|
||||
|
@ -93,7 +97,9 @@ const cons = {
|
|||
REPLY_TO_CLICKED: 'REPLY_TO_CLICKED',
|
||||
SEARCH_OPENED: 'SEARCH_OPENED',
|
||||
REUSABLE_CONTEXT_MENU_OPENED: 'REUSABLE_CONTEXT_MENU_OPENED',
|
||||
NAVIGATION_OPENED: 'NAVIGATION_OPENED',
|
||||
REUSABLE_DIALOG_OPENED: 'REUSABLE_DIALOG_OPENED',
|
||||
EMOJI_VERIFICATION_OPENED: 'EMOJI_VERIFICATION_OPENED',
|
||||
},
|
||||
roomList: {
|
||||
ROOMLIST_UPDATED: 'ROOMLIST_UPDATED',
|
||||
|
|
|
@ -14,7 +14,7 @@ class Navigation extends EventEmitter {
|
|||
this.isRoomSettings = false;
|
||||
this.recentRooms = [];
|
||||
|
||||
this.isRawModalVisible = false;
|
||||
this.rawModelStack = [];
|
||||
}
|
||||
|
||||
_setSpacePath(roomId) {
|
||||
|
@ -47,8 +47,13 @@ class Navigation extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
get isRawModalVisible() {
|
||||
return this.rawModelStack.length > 0;
|
||||
}
|
||||
|
||||
setIsRawModalVisible(visible) {
|
||||
this.isRawModalVisible = visible;
|
||||
if (visible) this.rawModelStack.push(true);
|
||||
else this.rawModelStack.pop();
|
||||
}
|
||||
|
||||
navigate(action) {
|
||||
|
@ -122,6 +127,12 @@ class Navigation extends EventEmitter {
|
|||
action.parentId,
|
||||
);
|
||||
},
|
||||
[cons.actions.navigation.OPEN_JOIN_ALIAS]: () => {
|
||||
this.emit(
|
||||
cons.events.navigation.JOIN_ALIAS_OPENED,
|
||||
action.term,
|
||||
);
|
||||
},
|
||||
[cons.actions.navigation.OPEN_INVITE_USER]: () => {
|
||||
this.emit(cons.events.navigation.INVITE_USER_OPENED, action.roomId, action.searchTerm);
|
||||
},
|
||||
|
@ -131,6 +142,9 @@ class Navigation extends EventEmitter {
|
|||
[cons.actions.navigation.OPEN_SETTINGS]: () => {
|
||||
this.emit(cons.events.navigation.SETTINGS_OPENED, action.tabText);
|
||||
},
|
||||
[cons.actions.navigation.OPEN_NAVIGATION]: () => {
|
||||
this.emit(cons.events.navigation.NAVIGATION_OPENED);
|
||||
},
|
||||
[cons.actions.navigation.OPEN_EMOJIBOARD]: () => {
|
||||
this.emit(
|
||||
cons.events.navigation.EMOJIBOARD_OPENED,
|
||||
|
@ -182,6 +196,13 @@ class Navigation extends EventEmitter {
|
|||
action.afterClose,
|
||||
);
|
||||
},
|
||||
[cons.actions.navigation.OPEN_EMOJI_VERIFICATION]: () => {
|
||||
this.emit(
|
||||
cons.events.navigation.EMOJI_VERIFICATION_OPENED,
|
||||
action.request,
|
||||
action.targetDevice,
|
||||
);
|
||||
},
|
||||
};
|
||||
actions[action.type]?.();
|
||||
}
|
||||
|
|
41
src/client/state/secretStorageKeys.js
Normal file
41
src/client/state/secretStorageKeys.js
Normal file
|
@ -0,0 +1,41 @@
|
|||
const secretStorageKeys = new Map();
|
||||
|
||||
export function storePrivateKey(keyId, privateKey) {
|
||||
if (privateKey instanceof Uint8Array === false) {
|
||||
throw new Error('Unable to store, privateKey is invalid.');
|
||||
}
|
||||
secretStorageKeys.set(keyId, privateKey);
|
||||
}
|
||||
|
||||
export function hasPrivateKey(keyId) {
|
||||
return secretStorageKeys.get(keyId) instanceof Uint8Array;
|
||||
}
|
||||
|
||||
export function getPrivateKey(keyId) {
|
||||
return secretStorageKeys.get(keyId);
|
||||
}
|
||||
|
||||
export function deletePrivateKey(keyId) {
|
||||
delete secretStorageKeys.delete(keyId);
|
||||
}
|
||||
|
||||
export function clearSecretStorageKeys() {
|
||||
secretStorageKeys.clear();
|
||||
}
|
||||
|
||||
async function getSecretStorageKey({ keys }) {
|
||||
const keyIds = Object.keys(keys);
|
||||
const keyId = keyIds.find(hasPrivateKey);
|
||||
if (!keyId) return undefined;
|
||||
const privateKey = getPrivateKey(keyId);
|
||||
return [keyId, privateKey];
|
||||
}
|
||||
|
||||
function cacheSecretStorageKey(keyId, keyInfo, privateKey) {
|
||||
secretStorageKeys.set(keyId, privateKey);
|
||||
}
|
||||
|
||||
export const cryptoCallbacks = {
|
||||
getSecretStorageKey,
|
||||
cacheSecretStorageKey,
|
||||
};
|
|
@ -1,3 +1,5 @@
|
|||
@use './app/partials/screen';
|
||||
|
||||
:root {
|
||||
|
||||
/* background color | --bg-[background type]: value */
|
||||
|
@ -69,9 +71,13 @@
|
|||
--ic-surface-high: #272727;
|
||||
--ic-surface-normal: #626262;
|
||||
--ic-surface-low: #7c7c7c;
|
||||
--ic-primary-high: #ffffff;
|
||||
--ic-primary-normal: #ffffff;
|
||||
--ic-positive-high: rgba(69, 184, 59);
|
||||
--ic-positive-normal: rgba(69, 184, 59, 80%);
|
||||
--ic-caution-high: rgba(255, 179, 0);
|
||||
--ic-caution-normal: rgba(255, 179, 0, 80%);
|
||||
--ic-danger-high: rgba(240, 71, 71);
|
||||
--ic-danger-normal: rgba(240, 71, 71, 0.7);
|
||||
|
||||
/* user mxid colors */
|
||||
|
@ -175,7 +181,7 @@
|
|||
|
||||
--popup-window-drawer-width: 280px;
|
||||
|
||||
@media (max-width: 1124px) {
|
||||
@include screen.smallerThan(tabletBreakpoint) {
|
||||
--navigation-drawer-width: calc(240px + var(--border-width));
|
||||
--people-drawer-width: calc(256px - var(--border-width));
|
||||
--popup-window-drawer-width: 240px;
|
||||
|
@ -469,6 +475,10 @@ textarea {
|
|||
supported by Chrome, Edge, Opera and Firefox */
|
||||
}
|
||||
|
||||
audio:not([controls]) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.flex--center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
|
|
@ -114,3 +114,21 @@ export function getScrollInfo(target) {
|
|||
export function avatarInitials(text) {
|
||||
return [...text][0];
|
||||
}
|
||||
|
||||
export function copyToClipboard(text) {
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(text);
|
||||
} else {
|
||||
const host = document.body;
|
||||
const copyInput = document.createElement('input');
|
||||
copyInput.style.position = 'fixed';
|
||||
copyInput.style.opacity = '0';
|
||||
copyInput.value = text;
|
||||
host.append(copyInput);
|
||||
|
||||
copyInput.select();
|
||||
copyInput.setSelectionRange(0, 99999);
|
||||
document.execCommand('Copy');
|
||||
copyInput.remove();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -140,4 +140,59 @@ const spoilerExtensionHtml = {
|
|||
},
|
||||
};
|
||||
|
||||
export { inlineExtension, spoilerExtension, spoilerExtensionHtml };
|
||||
const mathExtensionHtml = {
|
||||
enter: {
|
||||
mathFlow() {
|
||||
this.lineEndingIfNeeded();
|
||||
},
|
||||
mathFlowFenceMeta() {
|
||||
this.buffer();
|
||||
},
|
||||
mathText() {
|
||||
this.buffer();
|
||||
},
|
||||
},
|
||||
exit: {
|
||||
mathFlow() {
|
||||
const value = this.encode(this.resume().replace(/(?:\r?\n|\r)$/, ''));
|
||||
this.tag('<div data-mx-maths="');
|
||||
this.tag(value);
|
||||
this.tag('"><code>');
|
||||
this.raw(value);
|
||||
this.tag('</code></div>');
|
||||
this.setData('mathFlowOpen');
|
||||
this.setData('slurpOneLineEnding');
|
||||
},
|
||||
mathFlowFence() {
|
||||
// After the first fence.
|
||||
if (!this.getData('mathFlowOpen')) {
|
||||
this.setData('mathFlowOpen', true);
|
||||
this.setData('slurpOneLineEnding', true);
|
||||
this.buffer();
|
||||
}
|
||||
},
|
||||
mathFlowFenceMeta() {
|
||||
this.resume();
|
||||
},
|
||||
mathFlowValue(token) {
|
||||
this.raw(this.sliceSerialize(token));
|
||||
},
|
||||
mathText() {
|
||||
const value = this.encode(this.resume());
|
||||
this.tag('<span data-mx-maths="');
|
||||
this.tag(value);
|
||||
this.tag('"><code>');
|
||||
this.raw(value);
|
||||
this.tag('</code></span>');
|
||||
},
|
||||
mathTextData(token) {
|
||||
this.raw(this.sliceSerialize(token));
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export {
|
||||
inlineExtension,
|
||||
spoilerExtension, spoilerExtensionHtml,
|
||||
mathExtensionHtml,
|
||||
};
|
||||
|
|
|
@ -162,3 +162,40 @@ export function genRoomVia(room) {
|
|||
}
|
||||
return via.concat(mostPop3.slice(0, 2));
|
||||
}
|
||||
|
||||
export function isCrossVerified(deviceId) {
|
||||
try {
|
||||
const mx = initMatrix.matrixClient;
|
||||
const crossSignInfo = mx.getStoredCrossSigningForUser(mx.getUserId());
|
||||
const deviceInfo = mx.getStoredDevice(mx.getUserId(), deviceId);
|
||||
const deviceTrust = crossSignInfo.checkDeviceTrust(crossSignInfo, deviceInfo, false, true);
|
||||
return deviceTrust.isCrossSigningVerified();
|
||||
} catch {
|
||||
// device does not support encryption
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function hasCrossSigningAccountData() {
|
||||
const mx = initMatrix.matrixClient;
|
||||
const masterKeyData = mx.getAccountData('m.cross_signing.master');
|
||||
return !!masterKeyData;
|
||||
}
|
||||
|
||||
export function getDefaultSSKey() {
|
||||
const mx = initMatrix.matrixClient;
|
||||
try {
|
||||
return mx.getAccountData('m.secret_storage.default_key').getContent().key;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function getSSKeyInfo(key) {
|
||||
const mx = initMatrix.matrixClient;
|
||||
try {
|
||||
return mx.getAccountData(`m.secret_storage.key.${key}`).getContent();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,7 +15,8 @@ const urlSchemes = ['https', 'http', 'ftp', 'mailto', 'magnet'];
|
|||
|
||||
const permittedTagToAttributes = {
|
||||
font: ['style', 'data-mx-bg-color', 'data-mx-color', 'color'],
|
||||
span: ['style', 'data-mx-bg-color', 'data-mx-color', 'data-mx-spoiler', 'data-mx-pill', 'data-mx-ping'],
|
||||
span: ['style', 'data-mx-bg-color', 'data-mx-color', 'data-mx-spoiler', 'data-mx-maths', 'data-mx-pill', 'data-mx-ping'],
|
||||
div: ['data-mx-maths'],
|
||||
a: ['name', 'target', 'href', 'rel'],
|
||||
img: ['width', 'height', 'alt', 'title', 'src', 'data-mx-emoticon'],
|
||||
o: ['start'],
|
||||
|
|
|
@ -1,17 +1,41 @@
|
|||
/* eslint-disable import/prefer-default-export */
|
||||
import React, { lazy, Suspense } from 'react';
|
||||
|
||||
import linkifyHtml from 'linkifyjs/html';
|
||||
import parse from 'html-react-parser';
|
||||
import twemoji from 'twemoji';
|
||||
import { sanitizeText } from './sanitize';
|
||||
|
||||
const Math = lazy(() => import('../app/atoms/math/Math'));
|
||||
|
||||
const mathOptions = {
|
||||
replace: (node) => {
|
||||
const maths = node.attribs?.['data-mx-maths'];
|
||||
if (maths) {
|
||||
return (
|
||||
<Suspense fallback={<code>{maths}</code>}>
|
||||
<Math
|
||||
content={maths}
|
||||
throwOnError={false}
|
||||
errorColor="var(--tc-danger-normal)"
|
||||
displayMode={node.name === 'div'}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string} text - text to twemojify
|
||||
* @param {object|undefined} opts - options for tweomoji.parse
|
||||
* @param {boolean} [linkify=false] - convert links to html tags (default: false)
|
||||
* @param {boolean} [sanitize=true] - sanitize html text (default: true)
|
||||
* @param {boolean} [maths=false] - render maths (default: false)
|
||||
* @returns React component
|
||||
*/
|
||||
export function twemojify(text, opts, linkify = false, sanitize = true) {
|
||||
export function twemojify(text, opts, linkify = false, sanitize = true, maths = false) {
|
||||
if (typeof text !== 'string') return text;
|
||||
let content = text;
|
||||
|
||||
|
@ -25,5 +49,5 @@ export function twemojify(text, opts, linkify = false, sanitize = true) {
|
|||
rel: 'noreferrer noopener',
|
||||
});
|
||||
}
|
||||
return parse(content);
|
||||
return parse(content, maths ? mathOptions : null);
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||
const FaviconsWebpackPlugin = require('favicons-webpack-plugin');
|
||||
const CopyPlugin = require("copy-webpack-plugin");
|
||||
const webpack = require('webpack');
|
||||
|
||||
module.exports = {
|
||||
entry: {
|
||||
|
@ -17,6 +18,7 @@ module.exports = {
|
|||
'util': require.resolve('util/'),
|
||||
'assert': require.resolve('assert/'),
|
||||
'url': require.resolve('url/'),
|
||||
'buffer': require.resolve('buffer'),
|
||||
}
|
||||
},
|
||||
node: {
|
||||
|
@ -73,5 +75,8 @@ module.exports = {
|
|||
{ from: 'config.json' },
|
||||
],
|
||||
}),
|
||||
new webpack.ProvidePlugin({
|
||||
Buffer: ['buffer', 'Buffer'],
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
|
Loading…
Add table
Reference in a new issue