Compare commits
76 Commits
v1.8.2
...
416fd02069
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
416fd02069 | ||
|
|
581963cfb4 | ||
|
|
3b14543e66 | ||
|
|
5dd2194eba | ||
|
|
5b7880f817 | ||
|
|
bafe1c5781 | ||
|
|
d760be58c3 | ||
|
|
3da9b70632 | ||
|
|
b7c5902f67 | ||
|
|
9a22b25564 | ||
|
|
44c3dec9dc | ||
|
|
87f3afd8fd | ||
|
|
53f1129242 | ||
|
|
74216f75e2 | ||
|
|
ed8eca0c1d | ||
|
|
dc8e6e53c7 | ||
|
|
989ab5a432 | ||
|
|
ec26c03d58 | ||
|
|
3b1b3387e7 | ||
|
|
62c03d1334 | ||
|
|
51e12184d7 | ||
|
|
8c01eb9c00 | ||
|
|
bf264d5add | ||
|
|
8b3bd38bad | ||
|
|
ba0de8800a | ||
|
|
cd5ae4cb7f | ||
|
|
6575542281 | ||
|
|
bf2559da80 | ||
|
|
fedc207de2 | ||
|
|
0d58478a73 | ||
|
|
abf24d1942 | ||
|
|
afe3f2f3f3 | ||
|
|
1d1cb567da | ||
|
|
699bbee544 | ||
|
|
9c54915e73 | ||
|
|
ed4390b99d | ||
|
|
039d9bae68 | ||
|
|
93ab48ac9a | ||
|
|
c95e312acb | ||
|
|
9279bc7060 | ||
|
|
fe61576dcd | ||
|
|
3f6e3074f2 | ||
|
|
53a8e2aa57 | ||
|
|
44ab6f181c | ||
|
|
3a3a830706 | ||
|
|
1a6e3e73c5 | ||
|
|
7d508e5a7d | ||
|
|
370c224d3a | ||
|
|
7bce501069 | ||
|
|
9d15445eba | ||
|
|
16ee13f1f7 | ||
|
|
8d25eb0acd | ||
|
|
9cb13a91cd | ||
|
|
30f8930773 | ||
|
|
05eaa8d3e0 | ||
|
|
a32ffdf6d4 | ||
|
|
0f97de1b09 | ||
|
|
e8d6ccec9a | ||
|
|
005434f79b | ||
|
|
fe997d8b01 | ||
|
|
7291932a0b | ||
|
|
49ade03a9a | ||
|
|
dd6dbd25da | ||
|
|
50bf90fada | ||
|
|
abb81b6390 | ||
|
|
36da3d3cba | ||
|
|
ae71a99aa4 | ||
|
|
a7034d6351 | ||
|
|
6010b4c252 | ||
|
|
8330f4fba9 | ||
|
|
dc6e153b92 | ||
|
|
a2655ee6a5 | ||
|
|
13248962af | ||
|
|
b698982186 | ||
|
|
5a299b21c5 | ||
|
|
bb90f11ec8 |
33
.github/ISSUE_TEMPLATE/bug_report.md
vendored
33
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -1,33 +0,0 @@
|
|||||||
---
|
|
||||||
name: Bug report
|
|
||||||
about: Create a report to help us improve
|
|
||||||
title: ''
|
|
||||||
labels: ''
|
|
||||||
assignees: ''
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### Describe the bug
|
|
||||||
A clear and concise description of what the bug is.
|
|
||||||
|
|
||||||
#### To Reproduce
|
|
||||||
Steps to reproduce the behavior:
|
|
||||||
1. Go to '...'
|
|
||||||
2. Click on '....'
|
|
||||||
3. Scroll down to '....'
|
|
||||||
4. See error
|
|
||||||
|
|
||||||
#### Expected behavior
|
|
||||||
A clear and concise description of what you expected to happen.
|
|
||||||
|
|
||||||
#### Screenshots
|
|
||||||
If applicable, add screenshots to help explain your problem.
|
|
||||||
|
|
||||||
#### Desktop (please complete the following information):
|
|
||||||
- OS: [e.g. Windows, MacOS]
|
|
||||||
- Browser: [e.g. chrome, firefox]
|
|
||||||
- Version: [e.g. 3.22]
|
|
||||||
- Matrix homeserver: [e.g. matrix.org]
|
|
||||||
|
|
||||||
#### Additional context
|
|
||||||
Add any other context about the problem here.
|
|
||||||
57
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
57
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
name: 🐞 Bug Report
|
||||||
|
description: Report a bug
|
||||||
|
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
## First of all
|
||||||
|
1. Please search for [existing issues](https://github.com/ajbura/cinny/issues?q=is%3Aissue) about this problem first.
|
||||||
|
2. Make sure Cinny is up to date.
|
||||||
|
3. Make sure it's an issue with Cinny and not something else you are using.
|
||||||
|
4. Remember to be friendly.
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: Describe the bug
|
||||||
|
description: A clear description of what the bug is. Include screenshots if applicable.
|
||||||
|
placeholder: Bug description
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: reproduction
|
||||||
|
attributes:
|
||||||
|
label: Reproduction
|
||||||
|
description: Steps to reproduce the behavior.
|
||||||
|
placeholder: |
|
||||||
|
1. Go to ...
|
||||||
|
2. Click on ...
|
||||||
|
3. See error
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: expected-behavior
|
||||||
|
attributes:
|
||||||
|
label: Expected behavior
|
||||||
|
description: A clear description of what you expected to happen.
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: info
|
||||||
|
attributes:
|
||||||
|
label: Platform and versions
|
||||||
|
description: "Provide OS, browser and Cinny version with your Homeserver."
|
||||||
|
placeholder: |
|
||||||
|
1. OS: [e.g. Windows 10, MacOS]
|
||||||
|
2. Browser: [e.g. chrome 99.5, firefox 97.2]
|
||||||
|
3. Cinny version: [e.g. 1.8.1 (app.cinny.in)]
|
||||||
|
4. Matrix homeserver: [e.g. matrix.org]
|
||||||
|
render: shell
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: context
|
||||||
|
attributes:
|
||||||
|
label: Additional context
|
||||||
|
description: Add any other context about the problem here.
|
||||||
4
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
4
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
contact_links:
|
||||||
|
- name: 💬 Matrix Chat
|
||||||
|
url: https://matrix.to/#/#cinny:matrix.org
|
||||||
|
about: Ask questions and talk to other Cinny users and the maintainers
|
||||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -1,20 +0,0 @@
|
|||||||
---
|
|
||||||
name: Feature request
|
|
||||||
about: Suggest an idea for this project
|
|
||||||
title: ''
|
|
||||||
labels: ''
|
|
||||||
assignees: ''
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### Is your feature request related to a problem? Please describe.
|
|
||||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
|
||||||
|
|
||||||
#### Describe the solution you'd like
|
|
||||||
A clear and concise description of what you want to happen.
|
|
||||||
|
|
||||||
#### Describe alternatives you've considered
|
|
||||||
A clear and concise description of any alternative solutions or features you've considered.
|
|
||||||
|
|
||||||
#### Additional context
|
|
||||||
Add any other context or screenshots about the feature request here.
|
|
||||||
33
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
33
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
name: 💡 Feature Request
|
||||||
|
description: Suggest an idea
|
||||||
|
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: Describe the problem
|
||||||
|
description: A clear description of the problem this feature would solve
|
||||||
|
placeholder: "I'm always frustrated when..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: solution
|
||||||
|
attributes:
|
||||||
|
label: "Describe the solution you'd like"
|
||||||
|
description: A clear description of what change you would like
|
||||||
|
placeholder: "I would like to..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
attributes:
|
||||||
|
label: Alternatives considered
|
||||||
|
description: "Any alternative solutions you've considered"
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: context
|
||||||
|
attributes:
|
||||||
|
label: Additional context
|
||||||
|
description: Add any other context about the problem here.
|
||||||
6
.github/PULL_REQUEST_TEMPLATE.md
vendored
6
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,15 +1,13 @@
|
|||||||
<!-- Please read https://github.com/ajbura/cinny/CONTRIBUTING.md before submitting your pull request -->
|
<!-- Please read https://github.com/ajbura/cinny/CONTRIBUTING.md before submitting your pull request -->
|
||||||
|
|
||||||
### Description
|
### Description
|
||||||
|
<!-- Please include a summary of the change. Please also include relevant motivation and context. List any dependencies that are required for this change. -->
|
||||||
|
|
||||||
Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change.
|
|
||||||
|
|
||||||
Fixes # (issue)
|
Fixes #
|
||||||
|
|
||||||
#### Type of change
|
#### Type of change
|
||||||
|
|
||||||
Please delete options that are not relevant.
|
|
||||||
|
|
||||||
- [ ] Bug fix (non-breaking change which fixes an issue)
|
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||||
- [ ] New feature (non-breaking change which adds functionality)
|
- [ ] New feature (non-breaking change which adds functionality)
|
||||||
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||||
|
|||||||
26
.github/dependabot.yml
vendored
26
.github/dependabot.yml
vendored
@@ -1,28 +1,30 @@
|
|||||||
# Docs: <https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically/customizing-dependency-updates>
|
# Docs: <https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically/customizing-dependency-updates>
|
||||||
|
|
||||||
version: 2
|
version: 2
|
||||||
|
|
||||||
updates:
|
updates:
|
||||||
- package-ecosystem: github-actions
|
- package-ecosystem: npm
|
||||||
directory: /
|
directory: /
|
||||||
schedule:
|
schedule:
|
||||||
interval: weekly
|
interval: weekly
|
||||||
day: "tuesday"
|
day: "tuesday"
|
||||||
time: "01:00"
|
time: "01:00"
|
||||||
timezone: "Asia/Kolkata"
|
timezone: "Asia/Kolkata"
|
||||||
|
open-pull-requests-limit: 15
|
||||||
|
|
||||||
|
- package-ecosystem: github-actions
|
||||||
|
directory: /
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
day: "tuesday"
|
||||||
|
time: "01:00"
|
||||||
|
timezone: "Asia/Kolkata"
|
||||||
|
open-pull-requests-limit: 5
|
||||||
|
|
||||||
- package-ecosystem: docker
|
- package-ecosystem: docker
|
||||||
directory: /
|
directory: /
|
||||||
schedule:
|
schedule:
|
||||||
interval: weekly
|
|
||||||
day: "tuesday"
|
|
||||||
time: "01:00"
|
|
||||||
timezone: "Asia/Kolkata"
|
|
||||||
|
|
||||||
- package-ecosystem: npm
|
|
||||||
directory: /
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
interval: weekly
|
||||||
day: "tuesday"
|
day: "tuesday"
|
||||||
time: "01:00"
|
time: "01:00"
|
||||||
timezone: "Asia/Kolkata"
|
timezone: "Asia/Kolkata"
|
||||||
|
open-pull-requests-limit: 5
|
||||||
|
|||||||
9
.github/workflows/build-pull-request.yml
vendored
9
.github/workflows/build-pull-request.yml
vendored
@@ -10,8 +10,8 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
PR_NUMBER: ${{github.event.number}}
|
PR_NUMBER: ${{github.event.number}}
|
||||||
steps:
|
steps:
|
||||||
- name: Check out the repo
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v3.0.0
|
uses: actions/checkout@v3.0.2
|
||||||
- name: Build app
|
- name: Build app
|
||||||
run: npm ci && npm run build
|
run: npm ci && npm run build
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
@@ -32,8 +32,3 @@ jobs:
|
|||||||
name: pr.json
|
name: pr.json
|
||||||
path: pr.json
|
path: pr.json
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
- name: Build Docker image
|
|
||||||
uses: docker/build-push-action@v2.10.0
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
push: false
|
|
||||||
|
|||||||
6
.github/workflows/deploy-pull-request.yml
vendored
6
.github/workflows/deploy-pull-request.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
|||||||
# There's a 'download artifact' action but it hasn't been updated for the
|
# There's a 'download artifact' action but it hasn't been updated for the
|
||||||
# workflow_run action (https://github.com/actions/download-artifact/issues/60)
|
# workflow_run action (https://github.com/actions/download-artifact/issues/60)
|
||||||
# so instead we get this mess:
|
# so instead we get this mess:
|
||||||
- name: 'Download artifact'
|
- name: Download artifact
|
||||||
uses: actions/github-script@v6.0.0
|
uses: actions/github-script@v6.0.0
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
@@ -46,7 +46,7 @@ jobs:
|
|||||||
fs.writeFileSync('${{github.workspace}}/pr.json.zip', Buffer.from(download.data));
|
fs.writeFileSync('${{github.workspace}}/pr.json.zip', Buffer.from(download.data));
|
||||||
- name: Extract Artifacts
|
- name: Extract Artifacts
|
||||||
run: unzip -d dist previewbuild.zip && rm previewbuild.zip && unzip pr.json.zip && rm pr.json.zip
|
run: unzip -d dist previewbuild.zip && rm previewbuild.zip && unzip pr.json.zip && rm pr.json.zip
|
||||||
- name: 'Read PR Info'
|
- name: Read PR Info
|
||||||
id: readctx
|
id: readctx
|
||||||
uses: actions/github-script@v6.0.0
|
uses: actions/github-script@v6.0.0
|
||||||
with:
|
with:
|
||||||
@@ -68,7 +68,7 @@ jobs:
|
|||||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE3_ID }}
|
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE3_ID }}
|
||||||
timeout-minutes: 1
|
timeout-minutes: 1
|
||||||
- name: Edit PR Description
|
- name: Edit PR Description
|
||||||
uses: velas/pr-description@v1.0.1
|
uses: Beakyn/gha-comment-pull-request@v1.0.2
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
with:
|
with:
|
||||||
|
|||||||
21
.github/workflows/docker-pr.yml
vendored
Normal file
21
.github/workflows/docker-pr.yml
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
name: 'Docker check'
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'Dockerfile'
|
||||||
|
- '.github/workflows/docker-pr.yml'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
docker-build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
PR_NUMBER: ${{github.event.number}}
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v3.0.2
|
||||||
|
- name: Build Docker image
|
||||||
|
uses: docker/build-push-action@v2.10.0
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: false
|
||||||
@@ -6,13 +6,15 @@ on:
|
|||||||
- dev
|
- dev
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy-to-netlify:
|
||||||
name: 'Deploy'
|
name: 'Deploy'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3.0.0
|
- name: Checkout repository
|
||||||
- uses: jsmrcaga/action-netlify-deploy@v1.7.2
|
uses: actions/checkout@v3.0.2
|
||||||
|
- name: Build and deploy to Netlify
|
||||||
|
uses: jsmrcaga/action-netlify-deploy@v1.7.2
|
||||||
with:
|
with:
|
||||||
install_command: "npm ci"
|
install_command: "npm ci"
|
||||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||||
@@ -9,8 +9,8 @@ jobs:
|
|||||||
name: 'Deploy to Netlify'
|
name: 'Deploy to Netlify'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out the repo
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v3.0.0
|
uses: actions/checkout@v3.0.2
|
||||||
- name: Build and deploy to Netlify
|
- name: Build and deploy to Netlify
|
||||||
uses: jsmrcaga/action-netlify-deploy@v1.7.2
|
uses: jsmrcaga/action-netlify-deploy@v1.7.2
|
||||||
with:
|
with:
|
||||||
@@ -26,7 +26,7 @@ jobs:
|
|||||||
- name: Create tar.gz
|
- name: Create tar.gz
|
||||||
run: tar -czvf cinny-${{ steps.vars.outputs.tag }}.tar.gz dist
|
run: tar -czvf cinny-${{ steps.vars.outputs.tag }}.tar.gz dist
|
||||||
- name: Upload tagged release
|
- name: Upload tagged release
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v0.1.14
|
||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
cinny-${{ steps.vars.outputs.tag }}.tar.gz
|
cinny-${{ steps.vars.outputs.tag }}.tar.gz
|
||||||
@@ -35,8 +35,8 @@ jobs:
|
|||||||
name: Push Docker image to Docker Hub
|
name: Push Docker image to Docker Hub
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out the repo
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v3.0.0
|
uses: actions/checkout@v3.0.2
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v1.14.1
|
uses: docker/login-action@v1.14.1
|
||||||
with:
|
with:
|
||||||
@@ -44,7 +44,7 @@ jobs:
|
|||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
- name: Extract metadata (tags, labels) for Docker
|
- name: Extract metadata (tags, labels) for Docker
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v3.6.2
|
uses: docker/metadata-action@v3.7.0
|
||||||
with:
|
with:
|
||||||
images: ajbura/cinny
|
images: ajbura/cinny
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
## Builder
|
## Builder
|
||||||
FROM node:17.7.1-alpine3.15 as builder
|
FROM node:17.9.0-alpine3.15 as builder
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
|
|||||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2021 Ajay Bura (ajbura) and other contributors
|
Copyright (c) 2021 Ajay Bura (ajbura) and contributors
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
1724
package-lock.json
generated
1724
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
32
package.json
32
package.json
@@ -15,8 +15,8 @@
|
|||||||
"author": "Ajay Bura",
|
"author": "Ajay Bura",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/inter": "^4.5.5",
|
"@fontsource/inter": "^4.5.7",
|
||||||
"@fontsource/roboto": "^4.5.3",
|
"@fontsource/roboto": "^4.5.5",
|
||||||
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz",
|
"@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",
|
"@tippyjs/react": "^4.2.6",
|
||||||
"babel-polyfill": "^6.26.0",
|
"babel-polyfill": "^6.26.0",
|
||||||
@@ -26,32 +26,34 @@
|
|||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"flux": "^4.0.3",
|
"flux": "^4.0.3",
|
||||||
"formik": "^2.2.9",
|
"formik": "^2.2.9",
|
||||||
"html-react-parser": "^1.4.8",
|
"html-react-parser": "^1.4.12",
|
||||||
|
"katex": "^0.15.3",
|
||||||
"linkifyjs": "^2.1.9",
|
"linkifyjs": "^2.1.9",
|
||||||
"matrix-js-sdk": "^15.6.0",
|
"matrix-js-sdk": "^17.0.0",
|
||||||
"micromark": "^3.0.10",
|
"micromark": "^3.0.10",
|
||||||
"micromark-extension-gfm": "^2.0.1",
|
"micromark-extension-gfm": "^2.0.1",
|
||||||
|
"micromark-extension-math": "^2.0.2",
|
||||||
"micromark-util-chunked": "^1.0.0",
|
"micromark-util-chunked": "^1.0.0",
|
||||||
"micromark-util-resolve-all": "^1.0.0",
|
"micromark-util-resolve-all": "^1.0.0",
|
||||||
"micromark-util-symbol": "^1.0.1",
|
"micromark-util-symbol": "^1.0.1",
|
||||||
"prop-types": "^15.8.1",
|
"prop-types": "^15.8.1",
|
||||||
"react": "^17.0.2",
|
"react": "^17.0.2",
|
||||||
"react-autosize-textarea": "^7.1.0",
|
"react-autosize-textarea": "^7.1.0",
|
||||||
"react-dnd": "^15.1.1",
|
"react-dnd": "^15.1.2",
|
||||||
"react-dnd-html5-backend": "^15.1.2",
|
"react-dnd-html5-backend": "^15.1.3",
|
||||||
"react-dom": "^17.0.2",
|
"react-dom": "^17.0.2",
|
||||||
"react-google-recaptcha": "^2.1.0",
|
"react-google-recaptcha": "^2.1.0",
|
||||||
"react-modal": "^3.14.4",
|
"react-modal": "^3.14.4",
|
||||||
"sanitize-html": "^2.7.0",
|
"sanitize-html": "^2.7.0",
|
||||||
"tippy.js": "^6.3.7",
|
"tippy.js": "^6.3.7",
|
||||||
"twemoji": "^14.0.1"
|
"twemoji": "^14.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.17.7",
|
"@babel/core": "^7.17.9",
|
||||||
"@babel/preset-env": "^7.16.11",
|
"@babel/preset-env": "^7.16.11",
|
||||||
"@babel/preset-react": "^7.16.7",
|
"@babel/preset-react": "^7.16.7",
|
||||||
"assert": "^2.0.0",
|
"assert": "^2.0.0",
|
||||||
"babel-loader": "^8.2.2",
|
"babel-loader": "^8.2.5",
|
||||||
"browserify-fs": "^1.0.0",
|
"browserify-fs": "^1.0.0",
|
||||||
"buffer": "^6.0.3",
|
"buffer": "^6.0.3",
|
||||||
"clean-webpack-plugin": "^4.0.0",
|
"clean-webpack-plugin": "^4.0.0",
|
||||||
@@ -59,27 +61,27 @@
|
|||||||
"crypto-browserify": "^3.12.0",
|
"crypto-browserify": "^3.12.0",
|
||||||
"css-loader": "^6.7.1",
|
"css-loader": "^6.7.1",
|
||||||
"css-minimizer-webpack-plugin": "^3.4.1",
|
"css-minimizer-webpack-plugin": "^3.4.1",
|
||||||
"eslint": "^8.11.0",
|
"eslint": "^8.14.0",
|
||||||
"eslint-config-airbnb": "^19.0.4",
|
"eslint-config-airbnb": "^19.0.4",
|
||||||
"eslint-plugin-import": "^2.22.1",
|
"eslint-plugin-import": "^2.26.0",
|
||||||
"eslint-plugin-jsx-a11y": "^6.4.1",
|
"eslint-plugin-jsx-a11y": "^6.4.1",
|
||||||
"eslint-plugin-react": "^7.29.4",
|
"eslint-plugin-react": "^7.29.4",
|
||||||
"eslint-plugin-react-hooks": "^4.2.0",
|
"eslint-plugin-react-hooks": "^4.4.0",
|
||||||
"favicons": "^6.2.2",
|
"favicons": "^6.2.2",
|
||||||
"favicons-webpack-plugin": "^5.0.2",
|
"favicons-webpack-plugin": "^5.0.2",
|
||||||
"html-loader": "^3.1.0",
|
"html-loader": "^3.1.0",
|
||||||
"html-webpack-plugin": "^5.3.1",
|
"html-webpack-plugin": "^5.3.1",
|
||||||
"mini-css-extract-plugin": "^2.6.0",
|
"mini-css-extract-plugin": "^2.6.0",
|
||||||
"path-browserify": "^1.0.1",
|
"path-browserify": "^1.0.1",
|
||||||
"sass": "^1.49.9",
|
"sass": "^1.50.1",
|
||||||
"sass-loader": "^12.6.0",
|
"sass-loader": "^12.6.0",
|
||||||
"stream-browserify": "^3.0.0",
|
"stream-browserify": "^3.0.0",
|
||||||
"style-loader": "^3.3.1",
|
"style-loader": "^3.3.1",
|
||||||
"url": "^0.11.0",
|
"url": "^0.11.0",
|
||||||
"util": "^0.12.4",
|
"util": "^0.12.4",
|
||||||
"webpack": "^5.70.0",
|
"webpack": "^5.72.0",
|
||||||
"webpack-cli": "^4.9.2",
|
"webpack-cli": "^4.9.2",
|
||||||
"webpack-dev-server": "^4.7.4",
|
"webpack-dev-server": "^4.8.1",
|
||||||
"webpack-merge": "^5.7.3"
|
"webpack-merge": "^5.7.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
15
public/res/ic/outlined/message-unread.svg
Normal file
15
public/res/ic/outlined/message-unread.svg
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
|
||||||
|
<g>
|
||||||
|
<rect x="7" y="12" fill="#010101" width="10" height="2"/>
|
||||||
|
<g>
|
||||||
|
<circle fill="#010101" cx="19" cy="6" r="4"/>
|
||||||
|
</g>
|
||||||
|
<path fill="#010101" d="M13.3,8H7v2h7.5C14,9.4,13.6,8.7,13.3,8z"/>
|
||||||
|
<path fill="#010101" d="M19,12v5.6l-2.4-1.3L16.1,16h-0.5H5V6h8c0-0.7,0.1-1.4,0.3-2H4.8C3.8,4,3,4.9,3,6v10c0,1.1,0.8,2,1.8,2
|
||||||
|
h10.8l5.4,3v-9.3C20.4,11.9,19.7,12,19,12z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 811 B |
12
public/res/ic/outlined/message.svg
Normal file
12
public/res/ic/outlined/message.svg
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
|
||||||
|
<g>
|
||||||
|
<path fill="#010101" d="M19,6v11.6l-2.4-1.3L16.1,16h-0.5H5V6H19 M19.2,4H4.8C3.8,4,3,4.9,3,6v10c0,1.1,0.8,2,1.8,2h10.8l5.4,3V6
|
||||||
|
C21,4.9,20.2,4,19.2,4L19.2,4z"/>
|
||||||
|
<rect x="7" y="8" fill="#010101" width="10" height="2"/>
|
||||||
|
<rect x="7" y="12" fill="#010101" width="10" height="2"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 732 B |
@@ -4,13 +4,14 @@
|
|||||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
|
viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
|
||||||
<g>
|
<g>
|
||||||
<polygon fill="#010101" points="11,6 11,12 15.2,16.2 16.7,14.8 13,11.2 13,6 "/>
|
<polygon points="11,7 11,13 14.5,16.5 15.9,15.1 13,12.2 13,7 "/>
|
||||||
<path fill="#010101" d="M12,2C6.5,2,2,6.5,2,12H0.2L3,14.8L5.8,12H4c0-4.4,3.6-8,8-8s8,3.6,8,8s-3.6,8-8,8c-1.9,0-3.7-0.7-5-1.8
|
<path d="M12,2C8.7,2,5.8,3.6,4,6V4H2.5v5h5V7.5H5.4C6.9,5.4,9.3,4,12,4c4.4,0,8,3.6,8,8s-3.6,8-8,8s-8-3.6-8-8H2
|
||||||
l-1.2,1.6C7.4,21.2,9.6,22,12,22c5.5,0,10-4.5,10-10S17.5,2,12,2z"/>
|
c0,5.5,4.5,10,10,10s10-4.5,10-10S17.5,2,12,2z"/>
|
||||||
</g>
|
</g>
|
||||||
<g>
|
<g>
|
||||||
<polygon fill="#010101" points="49,44 49,50 53.2,54.2 54.7,52.8 51,49.2 51,44 "/>
|
<polygon points="49,44 49,50 53.2,54.2 54.7,52.8 51,49.2 51,44 "/>
|
||||||
<path fill="#010101" d="M50,40c-5.5,0-10,4.5-10,10h-1.8l2.8,2.8l2.8-2.8H42c0-4.4,3.6-8,8-8s8,3.6,8,8s-3.6,8-8,8
|
<polygon points="45.5,47 40.5,47 40.5,42 42,42 42,45.5 45.5,45.5 "/>
|
||||||
c-1.9,0-3.7-0.7-5-1.8l-1.2,1.6c1.7,1.4,3.9,2.2,6.3,2.2c5.5,0,10-4.5,10-10S55.5,40,50,40z"/>
|
<path d="M50,40c-4.1,0-7.6,2.5-9.2,6h2.2c1.4-2.4,4-4,6.9-4c4.4,0,8,3.6,8,8s-3.6,8-8,8s-8-3.6-8-8h-2c0,5.5,4.5,10,10,10
|
||||||
|
s10-4.5,10-10S55.5,40,50,40z"/>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 985 B |
BIN
public/sound/invite.ogg
Executable file
BIN
public/sound/invite.ogg
Executable file
Binary file not shown.
BIN
public/sound/notification.ogg
Executable file
BIN
public/sound/notification.ogg
Executable file
Binary file not shown.
@@ -11,11 +11,12 @@ const IconButton = React.forwardRef(({
|
|||||||
variant, size, type,
|
variant, size, type,
|
||||||
tooltip, tooltipPlacement, src,
|
tooltip, tooltipPlacement, src,
|
||||||
onClick, tabIndex, disabled, isImage,
|
onClick, tabIndex, disabled, isImage,
|
||||||
|
className,
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
const btn = (
|
const btn = (
|
||||||
<button
|
<button
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={`ic-btn ic-btn-${variant}`}
|
className={`ic-btn ic-btn-${variant} ${className}`}
|
||||||
onMouseUp={(e) => blurOnBubbling(e, `.ic-btn-${variant}`)}
|
onMouseUp={(e) => blurOnBubbling(e, `.ic-btn-${variant}`)}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
// eslint-disable-next-line react/button-has-type
|
// eslint-disable-next-line react/button-has-type
|
||||||
@@ -47,6 +48,7 @@ IconButton.defaultProps = {
|
|||||||
tabIndex: 0,
|
tabIndex: 0,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
isImage: false,
|
isImage: false,
|
||||||
|
className: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
IconButton.propTypes = {
|
IconButton.propTypes = {
|
||||||
@@ -60,6 +62,7 @@ IconButton.propTypes = {
|
|||||||
tabIndex: PropTypes.number,
|
tabIndex: PropTypes.number,
|
||||||
disabled: PropTypes.bool,
|
disabled: PropTypes.bool,
|
||||||
isImage: PropTypes.bool,
|
isImage: PropTypes.bool,
|
||||||
|
className: PropTypes.string,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default IconButton;
|
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;
|
||||||
@@ -74,7 +74,7 @@ Tabs.defaultProps = {
|
|||||||
|
|
||||||
Tabs.propTypes = {
|
Tabs.propTypes = {
|
||||||
items: PropTypes.arrayOf(
|
items: PropTypes.arrayOf(
|
||||||
PropTypes.exact({
|
PropTypes.shape({
|
||||||
iconSrc: PropTypes.string,
|
iconSrc: PropTypes.string,
|
||||||
text: PropTypes.string,
|
text: PropTypes.string,
|
||||||
disabled: PropTypes.bool,
|
disabled: PropTypes.bool,
|
||||||
@@ -84,4 +84,4 @@ Tabs.propTypes = {
|
|||||||
onSelect: PropTypes.func.isRequired,
|
onSelect: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export { Tabs as default };
|
export default Tabs;
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,10 +12,11 @@ import RawModal from '../../atoms/modal/RawModal';
|
|||||||
function Dialog({
|
function Dialog({
|
||||||
className, isOpen, title, onAfterOpen, onAfterClose,
|
className, isOpen, title, onAfterOpen, onAfterClose,
|
||||||
contentOptions, onRequestClose, closeFromOutside, children,
|
contentOptions, onRequestClose, closeFromOutside, children,
|
||||||
|
invisibleScroll,
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<RawModal
|
<RawModal
|
||||||
className={`${className === null ? '' : `${className} `}dialog-model`}
|
className={`${className === null ? '' : `${className} `}dialog-modal`}
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onAfterOpen={onAfterOpen}
|
onAfterOpen={onAfterOpen}
|
||||||
onAfterClose={onAfterClose}
|
onAfterClose={onAfterClose}
|
||||||
@@ -36,7 +37,7 @@ function Dialog({
|
|||||||
{contentOptions}
|
{contentOptions}
|
||||||
</Header>
|
</Header>
|
||||||
<div className="dialog__content__wrapper">
|
<div className="dialog__content__wrapper">
|
||||||
<ScrollView autoHide>
|
<ScrollView autoHide={!invisibleScroll} invisible={invisibleScroll}>
|
||||||
<div className="dialog__content-container">
|
<div className="dialog__content-container">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
@@ -55,6 +56,7 @@ Dialog.defaultProps = {
|
|||||||
onAfterClose: null,
|
onAfterClose: null,
|
||||||
onRequestClose: null,
|
onRequestClose: null,
|
||||||
closeFromOutside: true,
|
closeFromOutside: true,
|
||||||
|
invisibleScroll: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
Dialog.propTypes = {
|
Dialog.propTypes = {
|
||||||
@@ -67,6 +69,7 @@ Dialog.propTypes = {
|
|||||||
onRequestClose: PropTypes.func,
|
onRequestClose: PropTypes.func,
|
||||||
closeFromOutside: PropTypes.bool,
|
closeFromOutside: PropTypes.bool,
|
||||||
children: PropTypes.node.isRequired,
|
children: PropTypes.node.isRequired,
|
||||||
|
invisibleScroll: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Dialog;
|
export default Dialog;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.dialog-model {
|
.dialog-modal {
|
||||||
--modal-height: 656px;
|
--modal-height: 656px;
|
||||||
max-height: min(100%, var(--modal-height));
|
max-height: min(100%, var(--modal-height));
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -21,8 +21,3 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog__content-container {
|
|
||||||
padding-top: var(--sp-extra-tight);
|
|
||||||
padding-bottom: var(--sp-extra-loose);
|
|
||||||
}
|
|
||||||
49
src/app/molecules/dialog/ReusableDialog.jsx
Normal file
49
src/app/molecules/dialog/ReusableDialog.jsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
import cons from '../../../client/state/cons';
|
||||||
|
|
||||||
|
import navigation from '../../../client/state/navigation';
|
||||||
|
import IconButton from '../../atoms/button/IconButton';
|
||||||
|
import Dialog from './Dialog';
|
||||||
|
|
||||||
|
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||||
|
|
||||||
|
function ReusableDialog() {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleOpen = (title, render, afterClose) => {
|
||||||
|
setIsOpen(true);
|
||||||
|
setData({ title, render, afterClose });
|
||||||
|
};
|
||||||
|
navigation.on(cons.events.navigation.REUSABLE_DIALOG_OPENED, handleOpen);
|
||||||
|
return () => {
|
||||||
|
navigation.removeListener(cons.events.navigation.REUSABLE_DIALOG_OPENED, handleOpen);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAfterClose = () => {
|
||||||
|
data.afterClose?.();
|
||||||
|
setData(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRequestClose = () => {
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
isOpen={isOpen}
|
||||||
|
title={data?.title || ''}
|
||||||
|
onAfterClose={handleAfterClose}
|
||||||
|
onRequestClose={handleRequestClose}
|
||||||
|
contentOptions={<IconButton src={CrossIC} onClick={handleRequestClose} tooltip="Close" />}
|
||||||
|
invisibleScroll
|
||||||
|
>
|
||||||
|
{data?.render(handleRequestClose) || <div />}
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ReusableDialog;
|
||||||
@@ -36,6 +36,8 @@ import TickMarkIC from '../../../../public/res/ic/outlined/tick-mark.svg';
|
|||||||
import CmdIC from '../../../../public/res/ic/outlined/cmd.svg';
|
import CmdIC from '../../../../public/res/ic/outlined/cmd.svg';
|
||||||
import BinIC from '../../../../public/res/ic/outlined/bin.svg';
|
import BinIC from '../../../../public/res/ic/outlined/bin.svg';
|
||||||
|
|
||||||
|
import { confirmDialog } from '../confirm-dialog/ConfirmDialog';
|
||||||
|
|
||||||
function PlaceholderMessage() {
|
function PlaceholderMessage() {
|
||||||
return (
|
return (
|
||||||
<div className="ph-msg">
|
<div className="ph-msg">
|
||||||
@@ -186,9 +188,17 @@ const MessageBody = React.memo(({
|
|||||||
// if body is not string it is a React element.
|
// if body is not string it is a React element.
|
||||||
if (typeof body !== 'string') return <div className="message__body">{body}</div>;
|
if (typeof body !== 'string') return <div className="message__body">{body}</div>;
|
||||||
|
|
||||||
let content = isCustomHTML
|
let content = null;
|
||||||
? twemojify(sanitizeCustomHtml(body), undefined, true, false)
|
if (isCustomHTML) {
|
||||||
: twemojify(body, undefined, true);
|
try {
|
||||||
|
content = twemojify(sanitizeCustomHtml(body), undefined, true, false, true);
|
||||||
|
} catch {
|
||||||
|
console.error('Malformed custom html: ', body);
|
||||||
|
content = twemojify(body, undefined);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
content = twemojify(body, undefined, true);
|
||||||
|
}
|
||||||
|
|
||||||
// Determine if this message should render with large emojis
|
// Determine if this message should render with large emojis
|
||||||
// Criteria:
|
// Criteria:
|
||||||
@@ -538,10 +548,15 @@ const MessageOptions = React.memo(({
|
|||||||
<MenuItem
|
<MenuItem
|
||||||
variant="danger"
|
variant="danger"
|
||||||
iconSrc={BinIC}
|
iconSrc={BinIC}
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
if (window.confirm('Are you sure you want to delete this event')) {
|
const isConfirmed = await confirmDialog(
|
||||||
redactEvent(roomId, mEvent.getId());
|
'Delete message',
|
||||||
}
|
'Are you sure that you want to delete this message?',
|
||||||
|
'Delete',
|
||||||
|
'danger',
|
||||||
|
);
|
||||||
|
if (!isConfirmed) return;
|
||||||
|
redactEvent(roomId, mEvent.getId());
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
@use '../../atoms/scroll/scrollbar';
|
@use '../../atoms/scroll/scrollbar';
|
||||||
@use '../../partials/text';
|
@use '../../partials/text';
|
||||||
@use '../../partials/dir';
|
@use '../../partials/dir';
|
||||||
|
@use '../../partials/screen';
|
||||||
|
|
||||||
.message,
|
.message,
|
||||||
.ph-msg {
|
.ph-msg {
|
||||||
@@ -95,7 +96,7 @@
|
|||||||
.message__reactions {
|
.message__reactions {
|
||||||
max-width: calc(100% - 88px);
|
max-width: calc(100% - 88px);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@media (max-width: 1124px) {
|
@include screen.smallerThan(tabletBreakpoint) {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,8 @@ function PopupWindow({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<RawModal
|
<RawModal
|
||||||
className={`${className === null ? '' : `${className} `}pw-model`}
|
className={`${className === null ? '' : `${className} `}pw-modal`}
|
||||||
|
overlayClassName="pw-modal__overlay"
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onAfterClose={onAfterClose}
|
onAfterClose={onAfterClose}
|
||||||
onRequestClose={onRequestClose}
|
onRequestClose={onRequestClose}
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
@use '../../partials/dir';
|
@use '../../partials/dir';
|
||||||
|
@use '../../partials/screen';
|
||||||
|
|
||||||
.pw-model {
|
.pw-modal {
|
||||||
--modal-height: 656px;
|
--modal-height: 774px;
|
||||||
max-height: var(--modal-height) !important;
|
max-height: var(--modal-height) !important;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
||||||
|
@include screen.smallerThan(mobileBreakpoint) {
|
||||||
|
--modal-height: 100%;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
&__overlay {
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.pw {
|
.pw {
|
||||||
@@ -72,4 +81,4 @@
|
|||||||
@include dir.side(margin, 0, var(--sp-tight));
|
@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 Toggle from '../../atoms/button/Toggle';
|
||||||
import SettingTile from '../setting-tile/SettingTile';
|
import SettingTile from '../setting-tile/SettingTile';
|
||||||
|
|
||||||
|
import { confirmDialog } from '../confirm-dialog/ConfirmDialog';
|
||||||
|
|
||||||
function RoomEncryption({ roomId }) {
|
function RoomEncryption({ roomId }) {
|
||||||
const mx = initMatrix.matrixClient;
|
const mx = initMatrix.matrixClient;
|
||||||
const room = mx.getRoom(roomId);
|
const room = mx.getRoom(roomId);
|
||||||
@@ -15,17 +17,20 @@ function RoomEncryption({ roomId }) {
|
|||||||
const [isEncrypted, setIsEncrypted] = useState(encryptionEvents.length > 0);
|
const [isEncrypted, setIsEncrypted] = useState(encryptionEvents.length > 0);
|
||||||
const canEnableEncryption = room.currentState.maySendStateEvent('m.room.encryption', mx.getUserId());
|
const canEnableEncryption = room.currentState.maySendStateEvent('m.room.encryption', mx.getUserId());
|
||||||
|
|
||||||
const handleEncryptionEnable = () => {
|
const handleEncryptionEnable = async () => {
|
||||||
const joinRule = room.getJoinRule();
|
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 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';
|
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)) {
|
const isConfirmed1 = (joinRule === 'public')
|
||||||
setIsEncrypted(true);
|
? await confirmDialog('Enable encryption', confirmMsg1, 'Continue', 'caution')
|
||||||
mx.sendStateEvent(roomId, 'm.room.encryption', {
|
: true;
|
||||||
algorithm: 'm.megolm.v1.aes-sha2',
|
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',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -17,19 +17,19 @@ const visibility = {
|
|||||||
|
|
||||||
const items = [{
|
const items = [{
|
||||||
iconSrc: null,
|
iconSrc: null,
|
||||||
text: 'World readable (anyone can read)',
|
text: 'Anyone (including guests)',
|
||||||
type: visibility.WORLD_READABLE,
|
type: visibility.WORLD_READABLE,
|
||||||
}, {
|
}, {
|
||||||
iconSrc: null,
|
iconSrc: null,
|
||||||
text: 'Member shared (since the point in time of selecting this option)',
|
text: 'Members (all messages)',
|
||||||
type: visibility.SHARED,
|
type: visibility.SHARED,
|
||||||
}, {
|
}, {
|
||||||
iconSrc: null,
|
iconSrc: null,
|
||||||
text: 'Member invited (since they were invited)',
|
text: 'Members (messages after invite)',
|
||||||
type: visibility.INVITED,
|
type: visibility.INVITED,
|
||||||
}, {
|
}, {
|
||||||
iconSrc: null,
|
iconSrc: null,
|
||||||
text: 'Member joined (since they joined)',
|
text: 'Members (messages after join)',
|
||||||
type: visibility.JOINED,
|
type: visibility.JOINED,
|
||||||
}];
|
}];
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ function RoomHistoryVisibility({ roomId }) {
|
|||||||
</MenuItem>
|
</MenuItem>
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
<Text variant="b3">Changes to who can read history will only apply to future messages in this room. The visibility of existing history will be unchanged.</Text>
|
<Text variant="b3">Changes to history visibility will only apply to future messages. The visibility of existing history will have no effect.</Text>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import colorMXID from '../../../util/colorMXID';
|
|||||||
import { openProfileViewer } from '../../../client/action/navigation';
|
import { openProfileViewer } from '../../../client/action/navigation';
|
||||||
import { getUsernameOfRoomMember, getPowerLabel } from '../../../util/matrixUtil';
|
import { getUsernameOfRoomMember, getPowerLabel } from '../../../util/matrixUtil';
|
||||||
import AsyncSearch from '../../../util/AsyncSearch';
|
import AsyncSearch from '../../../util/AsyncSearch';
|
||||||
|
import { memberByAtoZ, memberByPowerLevel } from '../../../util/sort';
|
||||||
|
|
||||||
import Text from '../../atoms/text/Text';
|
import Text from '../../atoms/text/Text';
|
||||||
import Button from '../../atoms/button/Button';
|
import Button from '../../atoms/button/Button';
|
||||||
@@ -19,26 +20,6 @@ import PeopleSelector from '../people-selector/PeopleSelector';
|
|||||||
|
|
||||||
const PER_PAGE_MEMBER = 50;
|
const PER_PAGE_MEMBER = 50;
|
||||||
|
|
||||||
function AtoZ(m1, m2) {
|
|
||||||
const aName = m1.name;
|
|
||||||
const bName = m2.name;
|
|
||||||
|
|
||||||
if (aName.toLowerCase() < bName.toLowerCase()) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
if (aName.toLowerCase() > bName.toLowerCase()) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
function sortByPowerLevel(m1, m2) {
|
|
||||||
const pl1 = m1.powerLevel;
|
|
||||||
const pl2 = m2.powerLevel;
|
|
||||||
|
|
||||||
if (pl1 > pl2) return -1;
|
|
||||||
if (pl1 < pl2) return 1;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
function normalizeMembers(members) {
|
function normalizeMembers(members) {
|
||||||
const mx = initMatrix.matrixClient;
|
const mx = initMatrix.matrixClient;
|
||||||
return members.map((member) => ({
|
return members.map((member) => ({
|
||||||
@@ -65,7 +46,7 @@ function useMemberOfMembership(roomId, membership) {
|
|||||||
if (event && event?.getRoomId() !== roomId) return;
|
if (event && event?.getRoomId() !== roomId) return;
|
||||||
const memberOfMembership = normalizeMembers(
|
const memberOfMembership = normalizeMembers(
|
||||||
room.getMembersWithMembership(membership)
|
room.getMembersWithMembership(membership)
|
||||||
.sort(AtoZ).sort(sortByPowerLevel),
|
.sort(memberByAtoZ).sort(memberByPowerLevel),
|
||||||
);
|
);
|
||||||
setMembers(memberOfMembership);
|
setMembers(memberOfMembership);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -35,7 +35,12 @@ const items = [{
|
|||||||
function setRoomNotifType(roomId, newType) {
|
function setRoomNotifType(roomId, newType) {
|
||||||
const mx = initMatrix.matrixClient;
|
const mx = initMatrix.matrixClient;
|
||||||
const { notifications } = initMatrix;
|
const { notifications } = initMatrix;
|
||||||
const roomPushRule = mx.getRoomPushRule('global', roomId);
|
let roomPushRule;
|
||||||
|
try {
|
||||||
|
roomPushRule = mx.getRoomPushRule('global', roomId);
|
||||||
|
} catch {
|
||||||
|
roomPushRule = undefined;
|
||||||
|
}
|
||||||
const promises = [];
|
const promises = [];
|
||||||
|
|
||||||
if (newType === cons.notifs.MUTE) {
|
if (newType === cons.notifs.MUTE) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { twemojify } from '../../../util/twemojify';
|
|||||||
import initMatrix from '../../../client/initMatrix';
|
import initMatrix from '../../../client/initMatrix';
|
||||||
import { openInviteUser } from '../../../client/action/navigation';
|
import { openInviteUser } from '../../../client/action/navigation';
|
||||||
import * as roomActions from '../../../client/action/room';
|
import * as roomActions from '../../../client/action/room';
|
||||||
|
import { markAsRead } from '../../../client/action/notifications';
|
||||||
|
|
||||||
import { MenuHeader, MenuItem } from '../../atoms/context-menu/ContextMenu';
|
import { MenuHeader, MenuItem } from '../../atoms/context-menu/ContextMenu';
|
||||||
import RoomNotification from '../room-notification/RoomNotification';
|
import RoomNotification from '../room-notification/RoomNotification';
|
||||||
@@ -14,27 +15,32 @@ import TickMarkIC from '../../../../public/res/ic/outlined/tick-mark.svg';
|
|||||||
import AddUserIC from '../../../../public/res/ic/outlined/add-user.svg';
|
import AddUserIC from '../../../../public/res/ic/outlined/add-user.svg';
|
||||||
import LeaveArrowIC from '../../../../public/res/ic/outlined/leave-arrow.svg';
|
import LeaveArrowIC from '../../../../public/res/ic/outlined/leave-arrow.svg';
|
||||||
|
|
||||||
|
import { confirmDialog } from '../confirm-dialog/ConfirmDialog';
|
||||||
|
|
||||||
function RoomOptions({ roomId, afterOptionSelect }) {
|
function RoomOptions({ roomId, afterOptionSelect }) {
|
||||||
const mx = initMatrix.matrixClient;
|
const mx = initMatrix.matrixClient;
|
||||||
const room = mx.getRoom(roomId);
|
const room = mx.getRoom(roomId);
|
||||||
const canInvite = room?.canInvite(mx.getUserId());
|
const canInvite = room?.canInvite(mx.getUserId());
|
||||||
|
|
||||||
const handleMarkAsRead = () => {
|
const handleMarkAsRead = () => {
|
||||||
|
markAsRead(roomId);
|
||||||
afterOptionSelect();
|
afterOptionSelect();
|
||||||
if (!room) return;
|
|
||||||
const events = room.getLiveTimeline().getEvents();
|
|
||||||
mx.sendReadReceipt(events[events.length - 1]);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInviteClick = () => {
|
const handleInviteClick = () => {
|
||||||
openInviteUser(roomId);
|
openInviteUser(roomId);
|
||||||
afterOptionSelect();
|
afterOptionSelect();
|
||||||
};
|
};
|
||||||
const handleLeaveClick = () => {
|
const handleLeaveClick = async () => {
|
||||||
if (confirm('Are you really want to leave this room?')) {
|
afterOptionSelect();
|
||||||
roomActions.leave(roomId);
|
const isConfirmed = await confirmDialog(
|
||||||
afterOptionSelect();
|
'Leave room',
|
||||||
}
|
`Are you sure that you want to leave "${room.name}" room?`,
|
||||||
|
'Leave',
|
||||||
|
'danger',
|
||||||
|
);
|
||||||
|
if (!isConfirmed) return;
|
||||||
|
roomActions.leave(roomId);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ function RoomPermissions({ roomId }) {
|
|||||||
const pLEvent = room.currentState.getStateEvents('m.room.power_levels')[0];
|
const pLEvent = room.currentState.getStateEvents('m.room.power_levels')[0];
|
||||||
const permissions = pLEvent.getContent();
|
const permissions = pLEvent.getContent();
|
||||||
const canChangePermission = room.currentState.maySendStateEvent('m.room.power_levels', mx.getUserId());
|
const canChangePermission = room.currentState.maySendStateEvent('m.room.power_levels', mx.getUserId());
|
||||||
|
const myPowerLevel = room.getMember(mx.getUserId())?.powerLevel ?? 100;
|
||||||
|
|
||||||
const handlePowerSelector = (e, permKey, parentKey, powerLevel) => {
|
const handlePowerSelector = (e, permKey, parentKey, powerLevel) => {
|
||||||
const handlePowerLevelChange = (newPowerLevel) => {
|
const handlePowerLevelChange = (newPowerLevel) => {
|
||||||
@@ -208,7 +209,7 @@ function RoomPermissions({ roomId }) {
|
|||||||
(closeMenu) => (
|
(closeMenu) => (
|
||||||
<PowerLevelSelector
|
<PowerLevelSelector
|
||||||
value={powerLevel}
|
value={powerLevel}
|
||||||
max={100}
|
max={myPowerLevel}
|
||||||
onSelect={(pl) => {
|
onSelect={(pl) => {
|
||||||
closeMenu();
|
closeMenu();
|
||||||
handlePowerLevelChange(pl);
|
handlePowerLevelChange(pl);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import PencilIC from '../../../../public/res/ic/outlined/pencil.svg';
|
|||||||
|
|
||||||
import { useStore } from '../../hooks/useStore';
|
import { useStore } from '../../hooks/useStore';
|
||||||
import { useForceUpdate } from '../../hooks/useForceUpdate';
|
import { useForceUpdate } from '../../hooks/useForceUpdate';
|
||||||
|
import { confirmDialog } from '../confirm-dialog/ConfirmDialog';
|
||||||
|
|
||||||
function RoomProfile({ roomId }) {
|
function RoomProfile({ roomId }) {
|
||||||
const isMountStore = useStore();
|
const isMountStore = useStore();
|
||||||
@@ -117,7 +118,13 @@ function RoomProfile({ roomId }) {
|
|||||||
|
|
||||||
const handleAvatarUpload = async (url) => {
|
const handleAvatarUpload = async (url) => {
|
||||||
if (url === null) {
|
if (url === null) {
|
||||||
if (confirm('Are you sure you want to remove 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 }, '');
|
await mx.sendStateEvent(roomId, 'm.room.avatar', { url }, '');
|
||||||
}
|
}
|
||||||
} else 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 './RoomTile.scss';
|
||||||
|
|
||||||
import { twemojify } from '../../../util/twemojify';
|
import { twemojify } from '../../../util/twemojify';
|
||||||
import { sanitizeText } from '../../../util/sanitize';
|
|
||||||
|
|
||||||
import colorMXID from '../../../util/colorMXID';
|
import colorMXID from '../../../util/colorMXID';
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ function SettingTile({ title, options, content }) {
|
|||||||
<div className="setting-tile">
|
<div className="setting-tile">
|
||||||
<div className="setting-tile__content">
|
<div className="setting-tile__content">
|
||||||
<div className="setting-tile__title">
|
<div className="setting-tile__title">
|
||||||
<Text variant="b1">{title}</Text>
|
{
|
||||||
|
typeof title === 'string'
|
||||||
|
? <Text variant="b1">{title}</Text>
|
||||||
|
: title
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
{content}
|
{content}
|
||||||
</div>
|
</div>
|
||||||
@@ -24,7 +28,7 @@ SettingTile.defaultProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
SettingTile.propTypes = {
|
SettingTile.propTypes = {
|
||||||
title: PropTypes.string.isRequired,
|
title: PropTypes.node.isRequired,
|
||||||
options: PropTypes.node,
|
options: PropTypes.node,
|
||||||
content: PropTypes.node,
|
content: PropTypes.node,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ import Tooltip from '../../atoms/tooltip/Tooltip';
|
|||||||
import { blurOnBubbling } from '../../atoms/button/script';
|
import { blurOnBubbling } from '../../atoms/button/script';
|
||||||
|
|
||||||
const SidebarAvatar = React.forwardRef(({
|
const SidebarAvatar = React.forwardRef(({
|
||||||
tooltip, active, onClick, onContextMenu,
|
className, tooltip, active, onClick,
|
||||||
avatar, notificationBadge,
|
onContextMenu, avatar, notificationBadge,
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
let activeClass = '';
|
const classes = ['sidebar-avatar'];
|
||||||
if (active) activeClass = ' sidebar-avatar--active';
|
if (active) classes.push('sidebar-avatar--active');
|
||||||
|
if (className) classes.push(className);
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={<Text variant="b1">{twemojify(tooltip)}</Text>}
|
content={<Text variant="b1">{twemojify(tooltip)}</Text>}
|
||||||
@@ -21,7 +22,7 @@ const SidebarAvatar = React.forwardRef(({
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={`sidebar-avatar${activeClass}`}
|
className={classes.join(' ')}
|
||||||
type="button"
|
type="button"
|
||||||
onMouseUp={(e) => blurOnBubbling(e, '.sidebar-avatar')}
|
onMouseUp={(e) => blurOnBubbling(e, '.sidebar-avatar')}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
@@ -34,6 +35,7 @@ const SidebarAvatar = React.forwardRef(({
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
SidebarAvatar.defaultProps = {
|
SidebarAvatar.defaultProps = {
|
||||||
|
className: null,
|
||||||
active: false,
|
active: false,
|
||||||
onClick: null,
|
onClick: null,
|
||||||
onContextMenu: null,
|
onContextMenu: null,
|
||||||
@@ -41,6 +43,7 @@ SidebarAvatar.defaultProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
SidebarAvatar.propTypes = {
|
SidebarAvatar.propTypes = {
|
||||||
|
className: PropTypes.string,
|
||||||
tooltip: PropTypes.string.isRequired,
|
tooltip: PropTypes.string.isRequired,
|
||||||
active: PropTypes.bool,
|
active: PropTypes.bool,
|
||||||
onClick: PropTypes.func,
|
onClick: PropTypes.func,
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ function SpaceAddExistingContent({ roomId }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSearch = (ev) => {
|
const handleSearch = (ev) => {
|
||||||
const term = ev.target.value.toLocaleLowerCase().replaceAll(' ', '');
|
const term = ev.target.value.toLocaleLowerCase().replace(/\s/g, '');
|
||||||
if (term === '') {
|
if (term === '') {
|
||||||
setSearchIds(null);
|
setSearchIds(null);
|
||||||
return;
|
return;
|
||||||
@@ -100,7 +100,7 @@ function SpaceAddExistingContent({ roomId }) {
|
|||||||
if (!name) return false;
|
if (!name) return false;
|
||||||
name = name.normalize('NFKC')
|
name = name.normalize('NFKC')
|
||||||
.toLocaleLowerCase()
|
.toLocaleLowerCase()
|
||||||
.replaceAll(' ', '');
|
.replace(/\s/g, '');
|
||||||
return name.includes(term);
|
return name.includes(term);
|
||||||
});
|
});
|
||||||
setSearchIds(searchedIds);
|
setSearchIds(searchedIds);
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import LeaveArrowIC from '../../../../public/res/ic/outlined/leave-arrow.svg';
|
|||||||
import PinIC from '../../../../public/res/ic/outlined/pin.svg';
|
import PinIC from '../../../../public/res/ic/outlined/pin.svg';
|
||||||
import PinFilledIC from '../../../../public/res/ic/filled/pin.svg';
|
import PinFilledIC from '../../../../public/res/ic/filled/pin.svg';
|
||||||
|
|
||||||
|
import { confirmDialog } from '../confirm-dialog/ConfirmDialog';
|
||||||
|
|
||||||
function SpaceOptions({ roomId, afterOptionSelect }) {
|
function SpaceOptions({ roomId, afterOptionSelect }) {
|
||||||
const mx = initMatrix.matrixClient;
|
const mx = initMatrix.matrixClient;
|
||||||
const room = mx.getRoom(roomId);
|
const room = mx.getRoom(roomId);
|
||||||
@@ -54,11 +56,16 @@ function SpaceOptions({ roomId, afterOptionSelect }) {
|
|||||||
afterOptionSelect();
|
afterOptionSelect();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLeaveClick = () => {
|
const handleLeaveClick = async () => {
|
||||||
if (confirm('Are you really want to leave this space?')) {
|
afterOptionSelect();
|
||||||
leave(roomId);
|
const isConfirmed = await confirmDialog(
|
||||||
afterOptionSelect();
|
'Leave space',
|
||||||
}
|
`Are you sure that you want to leave "${room.name}" space?`,
|
||||||
|
'Leave',
|
||||||
|
'danger',
|
||||||
|
);
|
||||||
|
if (!isConfirmed) return;
|
||||||
|
leave(roomId);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ function CreateRoomContent({ isSpace, parentId, onRequestClose }) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
content={(
|
content={(
|
||||||
<Text variant="b3">Override the default (100) power level.</Text>
|
<Text variant="b3">Selecting Admin sets 100 power level whereas Founder sets 101.</Text>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<Input name="topic" minHeight={174} resizable label="Topic (optional)" />
|
<Input name="topic" minHeight={174} resizable label="Topic (optional)" />
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
@use '../../partials/dir';
|
@use '../../partials/dir';
|
||||||
|
|
||||||
.create-room {
|
.create-room {
|
||||||
|
margin: var(--sp-normal);
|
||||||
@include dir.side(margin, var(--sp-normal), var(--sp-extra-tight));
|
@include dir.side(margin, var(--sp-normal), var(--sp-extra-tight));
|
||||||
|
|
||||||
&__form > * {
|
&__form > * {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import Text from '../../atoms/text/Text';
|
|||||||
function DragDrop({ isOpen }) {
|
function DragDrop({ isOpen }) {
|
||||||
return (
|
return (
|
||||||
<RawModal
|
<RawModal
|
||||||
className="drag-drop__model"
|
className="drag-drop__modal"
|
||||||
overlayClassName="drag-drop__overlay"
|
overlayClassName="drag-drop__overlay"
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.drag-drop__model {
|
.drag-drop__modal {
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import initMatrix from '../../../client/initMatrix';
|
|||||||
import cons from '../../../client/state/cons';
|
import cons from '../../../client/state/cons';
|
||||||
import navigation from '../../../client/state/navigation';
|
import navigation from '../../../client/state/navigation';
|
||||||
import AsyncSearch from '../../../util/AsyncSearch';
|
import AsyncSearch from '../../../util/AsyncSearch';
|
||||||
|
import { addRecentEmoji, getRecentEmojis } from './recent';
|
||||||
|
|
||||||
import Text from '../../atoms/text/Text';
|
import Text from '../../atoms/text/Text';
|
||||||
import RawIcon from '../../atoms/system-icons/RawIcon';
|
import RawIcon from '../../atoms/system-icons/RawIcon';
|
||||||
@@ -20,6 +21,7 @@ import Input from '../../atoms/input/Input';
|
|||||||
import ScrollView from '../../atoms/scroll/ScrollView';
|
import ScrollView from '../../atoms/scroll/ScrollView';
|
||||||
|
|
||||||
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
|
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
|
||||||
|
import RecentClockIC from '../../../../public/res/ic/outlined/recent-clock.svg';
|
||||||
import EmojiIC from '../../../../public/res/ic/outlined/emoji.svg';
|
import EmojiIC from '../../../../public/res/ic/outlined/emoji.svg';
|
||||||
import DogIC from '../../../../public/res/ic/outlined/dog.svg';
|
import DogIC from '../../../../public/res/ic/outlined/dog.svg';
|
||||||
import CupIC from '../../../../public/res/ic/outlined/cup.svg';
|
import CupIC from '../../../../public/res/ic/outlined/cup.svg';
|
||||||
@@ -29,10 +31,11 @@ import BulbIC from '../../../../public/res/ic/outlined/bulb.svg';
|
|||||||
import PeaceIC from '../../../../public/res/ic/outlined/peace.svg';
|
import PeaceIC from '../../../../public/res/ic/outlined/peace.svg';
|
||||||
import FlagIC from '../../../../public/res/ic/outlined/flag.svg';
|
import FlagIC from '../../../../public/res/ic/outlined/flag.svg';
|
||||||
|
|
||||||
|
const ROW_EMOJIS_COUNT = 7;
|
||||||
|
|
||||||
const EmojiGroup = React.memo(({ name, groupEmojis }) => {
|
const EmojiGroup = React.memo(({ name, groupEmojis }) => {
|
||||||
function getEmojiBoard() {
|
function getEmojiBoard() {
|
||||||
const emojiBoard = [];
|
const emojiBoard = [];
|
||||||
const ROW_EMOJIS_COUNT = 7;
|
|
||||||
const totalEmojis = groupEmojis.length;
|
const totalEmojis = groupEmojis.length;
|
||||||
|
|
||||||
for (let r = 0; r < totalEmojis; r += ROW_EMOJIS_COUNT) {
|
for (let r = 0; r < totalEmojis; r += ROW_EMOJIS_COUNT) {
|
||||||
@@ -147,8 +150,9 @@ function EmojiBoard({ onSelect, searchRef }) {
|
|||||||
function selectEmoji(e) {
|
function selectEmoji(e) {
|
||||||
if (isTargetNotEmoji(e.target)) return;
|
if (isTargetNotEmoji(e.target)) return;
|
||||||
|
|
||||||
const emoji = e.target;
|
const emoji = getEmojiDataFromTarget(e.target);
|
||||||
onSelect(getEmojiDataFromTarget(emoji));
|
onSelect(emoji);
|
||||||
|
if (emoji.hexcode) addRecentEmoji(emoji.unicode);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setEmojiInfo(emoji) {
|
function setEmojiInfo(emoji) {
|
||||||
@@ -188,6 +192,9 @@ function EmojiBoard({ onSelect, searchRef }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [availableEmojis, setAvailableEmojis] = useState([]);
|
const [availableEmojis, setAvailableEmojis] = useState([]);
|
||||||
|
const [recentEmojis, setRecentEmojis] = useState([]);
|
||||||
|
|
||||||
|
const recentOffset = recentEmojis.length > 0 ? 1 : 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updateAvailableEmoji = (selectedRoomId) => {
|
const updateAvailableEmoji = (selectedRoomId) => {
|
||||||
@@ -215,6 +222,9 @@ function EmojiBoard({ onSelect, searchRef }) {
|
|||||||
const onOpen = () => {
|
const onOpen = () => {
|
||||||
searchRef.current.value = '';
|
searchRef.current.value = '';
|
||||||
handleSearchChange();
|
handleSearchChange();
|
||||||
|
|
||||||
|
// only update when board is getting opened to prevent shifting UI
|
||||||
|
setRecentEmojis(getRecentEmojis(3 * ROW_EMOJIS_COUNT));
|
||||||
};
|
};
|
||||||
|
|
||||||
navigation.on(cons.events.navigation.ROOM_SELECTED, updateAvailableEmoji);
|
navigation.on(cons.events.navigation.ROOM_SELECTED, updateAvailableEmoji);
|
||||||
@@ -230,7 +240,7 @@ function EmojiBoard({ onSelect, searchRef }) {
|
|||||||
const $emojiContent = scrollEmojisRef.current.firstElementChild;
|
const $emojiContent = scrollEmojisRef.current.firstElementChild;
|
||||||
const groupCount = $emojiContent.childElementCount;
|
const groupCount = $emojiContent.childElementCount;
|
||||||
if (groupCount > emojiGroups.length) {
|
if (groupCount > emojiGroups.length) {
|
||||||
tabIndex += groupCount - emojiGroups.length - availableEmojis.length;
|
tabIndex += groupCount - emojiGroups.length - availableEmojis.length - recentOffset;
|
||||||
}
|
}
|
||||||
$emojiContent.children[tabIndex].scrollIntoView();
|
$emojiContent.children[tabIndex].scrollIntoView();
|
||||||
}
|
}
|
||||||
@@ -246,6 +256,7 @@ function EmojiBoard({ onSelect, searchRef }) {
|
|||||||
<ScrollView ref={scrollEmojisRef} autoHide>
|
<ScrollView ref={scrollEmojisRef} autoHide>
|
||||||
<div onMouseMove={hoverEmoji} onClick={selectEmoji}>
|
<div onMouseMove={hoverEmoji} onClick={selectEmoji}>
|
||||||
<SearchedEmoji />
|
<SearchedEmoji />
|
||||||
|
{recentEmojis.length > 0 && <EmojiGroup name="Recently used" groupEmojis={recentEmojis} />}
|
||||||
{
|
{
|
||||||
availableEmojis.map((pack) => (
|
availableEmojis.map((pack) => (
|
||||||
<EmojiGroup
|
<EmojiGroup
|
||||||
@@ -271,13 +282,21 @@ function EmojiBoard({ onSelect, searchRef }) {
|
|||||||
</div>
|
</div>
|
||||||
<ScrollView invisible>
|
<ScrollView invisible>
|
||||||
<div className="emoji-board__nav">
|
<div className="emoji-board__nav">
|
||||||
|
{recentEmojis.length > 0 && (
|
||||||
|
<IconButton
|
||||||
|
onClick={() => openGroup(0)}
|
||||||
|
src={RecentClockIC}
|
||||||
|
tooltip="Recent"
|
||||||
|
tooltipPlacement="right"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="emoji-board__nav-custom">
|
<div className="emoji-board__nav-custom">
|
||||||
{
|
{
|
||||||
availableEmojis.map((pack) => {
|
availableEmojis.map((pack) => {
|
||||||
const src = initMatrix.matrixClient.mxcUrlToHttp(pack.avatar ?? pack.images[0].mxc);
|
const src = initMatrix.matrixClient.mxcUrlToHttp(pack.avatar ?? pack.images[0].mxc);
|
||||||
return (
|
return (
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={() => openGroup(pack.packIndex)}
|
onClick={() => openGroup(recentOffset + pack.packIndex)}
|
||||||
src={src}
|
src={src}
|
||||||
key={pack.packIndex}
|
key={pack.packIndex}
|
||||||
tooltip={pack.displayName}
|
tooltip={pack.displayName}
|
||||||
@@ -301,7 +320,7 @@ function EmojiBoard({ onSelect, searchRef }) {
|
|||||||
[7, FlagIC, 'Flags'],
|
[7, FlagIC, 'Flags'],
|
||||||
].map(([indx, ico, name]) => (
|
].map(([indx, ico, name]) => (
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={() => openGroup(availableEmojis.length + indx)}
|
onClick={() => openGroup(recentOffset + availableEmojis.length + indx)}
|
||||||
key={indx}
|
key={indx}
|
||||||
src={ico}
|
src={ico}
|
||||||
tooltip={name}
|
tooltip={name}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
--emoji-board-height: 390px;
|
--emoji-board-height: 390px;
|
||||||
--emoji-board-width: 286px;
|
--emoji-board-width: 286px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 90vh;
|
||||||
|
|
||||||
&__content {
|
&__content {
|
||||||
@extend .cp-fx__item-one;
|
@extend .cp-fx__item-one;
|
||||||
@@ -91,6 +93,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.emoji-row {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
.emoji-group {
|
.emoji-group {
|
||||||
--emoji-padding: 6px;
|
--emoji-padding: 6px;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
36
src/app/organisms/emoji-board/recent.js
Normal file
36
src/app/organisms/emoji-board/recent.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import initMatrix from '../../../client/initMatrix';
|
||||||
|
import { emojis } from './emoji';
|
||||||
|
|
||||||
|
const eventType = 'io.element.recent_emoji';
|
||||||
|
|
||||||
|
function getRecentEmojisRaw() {
|
||||||
|
return initMatrix.matrixClient.getAccountData(eventType).getContent().recent_emoji ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRecentEmojis(limit) {
|
||||||
|
const res = [];
|
||||||
|
getRecentEmojisRaw()
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.find(([unicode]) => {
|
||||||
|
const emoji = emojis.find((e) => e.unicode === unicode);
|
||||||
|
if (emoji) return res.push(emoji) >= limit;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addRecentEmoji(unicode) {
|
||||||
|
const recent = getRecentEmojisRaw();
|
||||||
|
const i = recent.findIndex(([u]) => u === unicode);
|
||||||
|
let entry;
|
||||||
|
if (i < 0) {
|
||||||
|
entry = [unicode, 1];
|
||||||
|
} else {
|
||||||
|
[entry] = recent.splice(i, 1);
|
||||||
|
entry[1] += 1;
|
||||||
|
}
|
||||||
|
recent.unshift(entry);
|
||||||
|
initMatrix.matrixClient.setAccountData(eventType, {
|
||||||
|
recent_emoji: recent.slice(0, 100),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -54,17 +54,19 @@ function InviteList({ isOpen, onRequestClose }) {
|
|||||||
}, [procInvite]);
|
}, [procInvite]);
|
||||||
|
|
||||||
function renderRoomTile(roomId) {
|
function renderRoomTile(roomId) {
|
||||||
const myRoom = initMatrix.matrixClient.getRoom(roomId);
|
const mx = initMatrix.matrixClient;
|
||||||
|
const myRoom = mx.getRoom(roomId);
|
||||||
const roomName = myRoom.name;
|
const roomName = myRoom.name;
|
||||||
let roomAlias = myRoom.getCanonicalAlias();
|
let roomAlias = myRoom.getCanonicalAlias();
|
||||||
if (roomAlias === null) roomAlias = myRoom.roomId;
|
if (roomAlias === null) roomAlias = myRoom.roomId;
|
||||||
|
const inviterName = myRoom.getMember(mx.getUserId())?.events?.member?.getSender?.() ?? '';
|
||||||
return (
|
return (
|
||||||
<RoomTile
|
<RoomTile
|
||||||
key={myRoom.roomId}
|
key={myRoom.roomId}
|
||||||
name={roomName}
|
name={roomName}
|
||||||
avatarSrc={initMatrix.matrixClient.getRoom(roomId).getAvatarUrl(initMatrix.matrixClient.baseUrl, 42, 42, 'crop')}
|
avatarSrc={initMatrix.matrixClient.getRoom(roomId).getAvatarUrl(initMatrix.matrixClient.baseUrl, 42, 42, 'crop')}
|
||||||
id={roomAlias}
|
id={roomAlias}
|
||||||
inviterName={myRoom.getJoinedMembers()[0].userId}
|
inviterName={inviterName}
|
||||||
options={
|
options={
|
||||||
procInvite.has(myRoom.roomId)
|
procInvite.has(myRoom.roomId)
|
||||||
? (<Spinner size="small" />)
|
? (<Spinner size="small" />)
|
||||||
|
|||||||
@@ -1,18 +1,38 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
import initMatrix from '../../../client/initMatrix';
|
||||||
import cons from '../../../client/state/cons';
|
import cons from '../../../client/state/cons';
|
||||||
import navigation from '../../../client/state/navigation';
|
import navigation from '../../../client/state/navigation';
|
||||||
import Postie from '../../../util/Postie';
|
import Postie from '../../../util/Postie';
|
||||||
|
import { roomIdByActivity } from '../../../util/sort';
|
||||||
|
|
||||||
import RoomsCategory from './RoomsCategory';
|
import RoomsCategory from './RoomsCategory';
|
||||||
|
|
||||||
import { AtoZ } from './common';
|
|
||||||
|
|
||||||
const drawerPostie = new Postie();
|
const drawerPostie = new Postie();
|
||||||
function Directs() {
|
function Directs() {
|
||||||
|
const mx = initMatrix.matrixClient;
|
||||||
const { roomList, notifications } = initMatrix;
|
const { roomList, notifications } = initMatrix;
|
||||||
const directIds = [...roomList.directs].sort(AtoZ);
|
const [directIds, setDirectIds] = useState([]);
|
||||||
|
|
||||||
|
useEffect(() => setDirectIds([...roomList.directs].sort(roomIdByActivity)), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleTimeline = (event, room, toStartOfTimeline, removed, data) => {
|
||||||
|
if (!roomList.directs.has(room.roomId)) return;
|
||||||
|
if (!data.liveEvent) return;
|
||||||
|
if (directIds[0] === room.roomId) return;
|
||||||
|
const newDirectIds = [room.roomId];
|
||||||
|
directIds.forEach((id) => {
|
||||||
|
if (id === room.roomId) return;
|
||||||
|
newDirectIds.push(id);
|
||||||
|
});
|
||||||
|
setDirectIds(newDirectIds);
|
||||||
|
};
|
||||||
|
mx.on('Room.timeline', handleTimeline);
|
||||||
|
return () => {
|
||||||
|
mx.removeListener('Room.timeline', handleTimeline);
|
||||||
|
};
|
||||||
|
}, [directIds]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const selectorChanged = (selectedRoomId, prevSelectedRoomId) => {
|
const selectorChanged = (selectedRoomId, prevSelectedRoomId) => {
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import initMatrix from '../../../client/initMatrix';
|
|||||||
import cons from '../../../client/state/cons';
|
import cons from '../../../client/state/cons';
|
||||||
import navigation from '../../../client/state/navigation';
|
import navigation from '../../../client/state/navigation';
|
||||||
import Postie from '../../../util/Postie';
|
import Postie from '../../../util/Postie';
|
||||||
|
import { roomIdByActivity, roomIdByAtoZ } from '../../../util/sort';
|
||||||
|
|
||||||
import RoomsCategory from './RoomsCategory';
|
import RoomsCategory from './RoomsCategory';
|
||||||
|
|
||||||
import { useCategorizedSpaces } from '../../hooks/useCategorizedSpaces';
|
import { useCategorizedSpaces } from '../../hooks/useCategorizedSpaces';
|
||||||
import { AtoZ, RoomToDM } from './common';
|
|
||||||
|
|
||||||
const drawerPostie = new Postie();
|
const drawerPostie = new Postie();
|
||||||
function Home({ spaceId }) {
|
function Home({ spaceId }) {
|
||||||
@@ -34,10 +34,6 @@ function Home({ spaceId }) {
|
|||||||
roomIds = roomList.getOrphanRooms();
|
roomIds = roomList.getOrphanRooms();
|
||||||
}
|
}
|
||||||
|
|
||||||
spaceIds.sort(AtoZ);
|
|
||||||
roomIds.sort(AtoZ);
|
|
||||||
directIds.sort(AtoZ);
|
|
||||||
|
|
||||||
if (isCategorized) {
|
if (isCategorized) {
|
||||||
categories = roomList.getCategorizedSpaces(spaceIds);
|
categories = roomList.getCategorizedSpaces(spaceIds);
|
||||||
categories.delete(spaceId);
|
categories.delete(spaceId);
|
||||||
@@ -73,26 +69,36 @@ function Home({ spaceId }) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{ !isCategorized && spaceIds.length !== 0 && (
|
{ !isCategorized && spaceIds.length !== 0 && (
|
||||||
<RoomsCategory name="Spaces" roomIds={spaceIds} drawerPostie={drawerPostie} />
|
<RoomsCategory name="Spaces" roomIds={spaceIds.sort(roomIdByAtoZ)} drawerPostie={drawerPostie} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{ roomIds.length !== 0 && (
|
{ roomIds.length !== 0 && (
|
||||||
<RoomsCategory name="Rooms" roomIds={roomIds} drawerPostie={drawerPostie} />
|
<RoomsCategory name="Rooms" roomIds={roomIds.sort(roomIdByAtoZ)} drawerPostie={drawerPostie} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{ directIds.length !== 0 && (
|
{ directIds.length !== 0 && (
|
||||||
<RoomsCategory name="People" roomIds={directIds} drawerPostie={drawerPostie} />
|
<RoomsCategory name="People" roomIds={directIds.sort(roomIdByActivity)} drawerPostie={drawerPostie} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{ isCategorized && [...categories].map(([catId, childIds]) => (
|
{ isCategorized && [...categories].map(([catId, childIds]) => {
|
||||||
<RoomsCategory
|
const rms = [];
|
||||||
key={catId}
|
const dms = [];
|
||||||
spaceId={catId}
|
childIds.forEach((id) => {
|
||||||
name={mx.getRoom(catId).name}
|
if (directs.has(id)) dms.push(id);
|
||||||
roomIds={[...childIds].sort(AtoZ).sort(RoomToDM)}
|
else rms.push(id);
|
||||||
drawerPostie={drawerPostie}
|
});
|
||||||
/>
|
rms.sort(roomIdByAtoZ);
|
||||||
))}
|
dms.sort(roomIdByActivity);
|
||||||
|
return (
|
||||||
|
<RoomsCategory
|
||||||
|
key={catId}
|
||||||
|
spaceId={catId}
|
||||||
|
name={mx.getRoom(catId).name}
|
||||||
|
roomIds={rms.concat(dms)}
|
||||||
|
drawerPostie={drawerPostie}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from '../../../client/action/navigation';
|
} from '../../../client/action/navigation';
|
||||||
import { moveSpaceShortcut } from '../../../client/action/accountData';
|
import { moveSpaceShortcut } from '../../../client/action/accountData';
|
||||||
import { abbreviateNumber, getEventCords } from '../../../util/common';
|
import { abbreviateNumber, getEventCords } from '../../../util/common';
|
||||||
|
import { isCrossVerified } from '../../../util/matrixUtil';
|
||||||
|
|
||||||
import Avatar from '../../atoms/avatar/Avatar';
|
import Avatar from '../../atoms/avatar/Avatar';
|
||||||
import NotificationBadge from '../../atoms/badge/NotificationBadge';
|
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 AddPinIC from '../../../../public/res/ic/outlined/add-pin.svg';
|
||||||
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
|
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
|
||||||
import InviteIC from '../../../../public/res/ic/outlined/invite.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 { useSelectedTab } from '../../hooks/useSelectedTab';
|
||||||
|
import { useDeviceList } from '../../hooks/useDeviceList';
|
||||||
|
|
||||||
|
import { tabText as settingTabText } from '../settings/Settings';
|
||||||
|
|
||||||
function useNotificationUpdate() {
|
function useNotificationUpdate() {
|
||||||
const { notifications } = initMatrix;
|
const { notifications } = initMatrix;
|
||||||
@@ -72,7 +77,7 @@ function ProfileAvatarMenu() {
|
|||||||
return (
|
return (
|
||||||
<SidebarAvatar
|
<SidebarAvatar
|
||||||
onClick={openSettings}
|
onClick={openSettings}
|
||||||
tooltip={profile.displayName}
|
tooltip="Settings"
|
||||||
avatar={(
|
avatar={(
|
||||||
<Avatar
|
<Avatar
|
||||||
text={profile.displayName}
|
text={profile.displayName}
|
||||||
@@ -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() {
|
function FeaturedTab() {
|
||||||
const { roomList, accountData, notifications } = initMatrix;
|
const { roomList, accountData, notifications } = initMatrix;
|
||||||
const [selectedTab] = useSelectedTab();
|
const [selectedTab] = useSelectedTab();
|
||||||
@@ -358,6 +379,7 @@ function SideBar() {
|
|||||||
notificationBadge={<NotificationBadge alert content={totalInvites} />}
|
notificationBadge={<NotificationBadge alert content={totalInvites} />}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<CrossSigninAlert />
|
||||||
<ProfileAvatarMenu />
|
<ProfileAvatarMenu />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -57,4 +57,21 @@
|
|||||||
width: 24px;
|
width: 24px;
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background-color: var(--bg-surface-border);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import initMatrix from '../../../client/initMatrix';
|
|
||||||
|
|
||||||
function AtoZ(aId, bId) {
|
|
||||||
let aName = initMatrix.matrixClient.getRoom(aId).name;
|
|
||||||
let bName = initMatrix.matrixClient.getRoom(bId).name;
|
|
||||||
|
|
||||||
// remove "#" from the room name
|
|
||||||
// To ignore it in sorting
|
|
||||||
aName = aName.replaceAll('#', '');
|
|
||||||
bName = bName.replaceAll('#', '');
|
|
||||||
|
|
||||||
if (aName.toLowerCase() < bName.toLowerCase()) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
if (aName.toLowerCase() > bName.toLowerCase()) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const RoomToDM = (aId, bId) => {
|
|
||||||
const { directs } = initMatrix.roomList;
|
|
||||||
const aIsDm = directs.has(aId);
|
|
||||||
const bIsDm = directs.has(bId);
|
|
||||||
if (aIsDm && !bIsDm) return 1;
|
|
||||||
if (!aIsDm && bIsDm) return -1;
|
|
||||||
return 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
export { AtoZ, RoomToDM };
|
|
||||||
@@ -1,37 +1,53 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
import { twemojify } from '../../../util/twemojify';
|
||||||
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
import initMatrix from '../../../client/initMatrix';
|
||||||
import colorMXID from '../../../util/colorMXID';
|
import colorMXID from '../../../util/colorMXID';
|
||||||
|
|
||||||
|
import Text from '../../atoms/text/Text';
|
||||||
|
import IconButton from '../../atoms/button/IconButton';
|
||||||
import Button from '../../atoms/button/Button';
|
import Button from '../../atoms/button/Button';
|
||||||
import ImageUpload from '../../molecules/image-upload/ImageUpload';
|
import ImageUpload from '../../molecules/image-upload/ImageUpload';
|
||||||
import Input from '../../atoms/input/Input';
|
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';
|
import './ProfileEditor.scss';
|
||||||
|
|
||||||
// TODO Fix bug that prevents 'Save' button from enabling up until second changed.
|
// TODO Fix bug that prevents 'Save' button from enabling up until second changed.
|
||||||
function ProfileEditor({
|
function ProfileEditor({ userId }) {
|
||||||
userId,
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
}) {
|
|
||||||
const mx = initMatrix.matrixClient;
|
const mx = initMatrix.matrixClient;
|
||||||
|
const user = mx.getUser(mx.getUserId());
|
||||||
|
|
||||||
const displayNameRef = useRef(null);
|
const displayNameRef = useRef(null);
|
||||||
const bgColor = colorMXID(userId);
|
const [avatarSrc, setAvatarSrc] = useState(user.avatarUrl ? mx.mxcUrlToHttp(user.avatarUrl, 80, 80, 'crop') : null);
|
||||||
const [avatarSrc, setAvatarSrc] = useState(null);
|
const [username, setUsername] = useState(user.displayName);
|
||||||
const [disabled, setDisabled] = useState(true);
|
const [disabled, setDisabled] = useState(true);
|
||||||
|
|
||||||
let username = mx.getUser(mx.getUserId()).displayName;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
mx.getProfileInfo(mx.getUserId()).then((info) => {
|
mx.getProfileInfo(mx.getUserId()).then((info) => {
|
||||||
|
if (!isMounted) return;
|
||||||
setAvatarSrc(info.avatar_url ? mx.mxcUrlToHttp(info.avatar_url, 80, 80, 'crop') : null);
|
setAvatarSrc(info.avatar_url ? mx.mxcUrlToHttp(info.avatar_url, 80, 80, 'crop') : null);
|
||||||
|
setUsername(info.displayname);
|
||||||
});
|
});
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
}, [userId]);
|
}, [userId]);
|
||||||
|
|
||||||
// Sets avatar URL and updates the avatar component in profile editor to reflect new upload
|
const handleAvatarUpload = async (url) => {
|
||||||
function handleAvatarUpload(url) {
|
|
||||||
if (url === null) {
|
if (url === null) {
|
||||||
if (confirm('Are you sure 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('');
|
mx.setAvatarUrl('');
|
||||||
setAvatarSrc(null);
|
setAvatarSrc(null);
|
||||||
}
|
}
|
||||||
@@ -39,48 +55,72 @@ function ProfileEditor({
|
|||||||
}
|
}
|
||||||
mx.setAvatarUrl(url);
|
mx.setAvatarUrl(url);
|
||||||
setAvatarSrc(mx.mxcUrlToHttp(url, 80, 80, 'crop'));
|
setAvatarSrc(mx.mxcUrlToHttp(url, 80, 80, 'crop'));
|
||||||
}
|
};
|
||||||
|
|
||||||
function saveDisplayName() {
|
const saveDisplayName = () => {
|
||||||
const newDisplayName = displayNameRef.current.value;
|
const newDisplayName = displayNameRef.current.value;
|
||||||
if (newDisplayName !== null && newDisplayName !== username) {
|
if (newDisplayName !== null && newDisplayName !== username) {
|
||||||
mx.setDisplayName(newDisplayName);
|
mx.setDisplayName(newDisplayName);
|
||||||
username = newDisplayName;
|
setUsername(newDisplayName);
|
||||||
setDisabled(true);
|
setDisabled(true);
|
||||||
|
setIsEditing(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
function onDisplayNameInputChange() {
|
const onDisplayNameInputChange = () => {
|
||||||
setDisabled(username === displayNameRef.current.value || displayNameRef.current.value == null);
|
setDisabled(username === displayNameRef.current.value || displayNameRef.current.value == null);
|
||||||
}
|
};
|
||||||
function cancelDisplayNameChanges() {
|
const cancelDisplayNameChanges = () => {
|
||||||
displayNameRef.current.value = username;
|
displayNameRef.current.value = username;
|
||||||
onDisplayNameInputChange();
|
onDisplayNameInputChange();
|
||||||
}
|
setIsEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
const renderForm = () => (
|
||||||
<form
|
<form
|
||||||
className="profile-editor"
|
className="profile-editor__form"
|
||||||
|
style={{ marginBottom: avatarSrc ? '24px' : '0' }}
|
||||||
onSubmit={(e) => { e.preventDefault(); saveDisplayName(); }}
|
onSubmit={(e) => { e.preventDefault(); saveDisplayName(); }}
|
||||||
>
|
>
|
||||||
|
<Input
|
||||||
|
label={`Display name of ${mx.getUserId()}`}
|
||||||
|
onChange={onDisplayNameInputChange}
|
||||||
|
value={mx.getUser(mx.getUserId()).displayName}
|
||||||
|
forwardRef={displayNameRef}
|
||||||
|
/>
|
||||||
|
<Button variant="primary" type="submit" disabled={disabled}>Save</Button>
|
||||||
|
<Button onClick={cancelDisplayNameChanges}>Cancel</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderInfo = () => (
|
||||||
|
<div className="profile-editor__info" style={{ marginBottom: avatarSrc ? '24px' : '0' }}>
|
||||||
|
<div>
|
||||||
|
<Text variant="h2" primary weight="medium">{twemojify(username)}</Text>
|
||||||
|
<IconButton
|
||||||
|
src={PencilIC}
|
||||||
|
size="extra-small"
|
||||||
|
tooltip="Edit"
|
||||||
|
onClick={() => setIsEditing(true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Text variant="b2">{mx.getUserId()}</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="profile-editor">
|
||||||
<ImageUpload
|
<ImageUpload
|
||||||
text={username}
|
text={username}
|
||||||
bgColor={bgColor}
|
bgColor={colorMXID(userId)}
|
||||||
imageSrc={avatarSrc}
|
imageSrc={avatarSrc}
|
||||||
onUpload={handleAvatarUpload}
|
onUpload={handleAvatarUpload}
|
||||||
onRequestRemove={() => handleAvatarUpload(null)}
|
onRequestRemove={() => handleAvatarUpload(null)}
|
||||||
/>
|
/>
|
||||||
<div className="profile-editor__input-wrapper">
|
{
|
||||||
<Input
|
isEditing ? renderForm() : renderInfo()
|
||||||
label={`Display name of ${mx.getUserId()}`}
|
}
|
||||||
onChange={onDisplayNameInputChange}
|
</div>
|
||||||
value={mx.getUser(mx.getUserId()).displayName}
|
|
||||||
forwardRef={displayNameRef}
|
|
||||||
/>
|
|
||||||
<Button variant="primary" type="submit" disabled={disabled}>Save</Button>
|
|
||||||
<Button onClick={cancelDisplayNameChanges}>Cancel</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +1,41 @@
|
|||||||
@use '../../partials/dir';
|
@use '../../partials/dir';
|
||||||
|
@use '../../partials/flex';
|
||||||
|
|
||||||
.profile-editor {
|
.profile-editor {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-editor__input-wrapper {
|
.profile-editor__info,
|
||||||
flex: 1;
|
.profile-editor__form {
|
||||||
min-width: 0;
|
@extend .cp-fx__item-one;
|
||||||
margin-top: 10px;
|
@include dir.side(margin, var(--sp-loose), 0);
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
}
|
||||||
|
|
||||||
|
.profile-editor__info {
|
||||||
|
flex-direction: column;
|
||||||
|
& > div:first-child {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.ic-btn {
|
||||||
|
margin: 0 var(--sp-extra-tight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-editor__form {
|
||||||
|
margin-top: 10px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
|
||||||
& > .input-container {
|
& > .input-container {
|
||||||
flex: 1;
|
@extend .cp-fx__item-one;
|
||||||
}
|
}
|
||||||
& > button {
|
& > button {
|
||||||
height: 46px;
|
height: 46px;
|
||||||
margin-top: var(--sp-normal);
|
margin-top: var(--sp-normal);
|
||||||
}
|
|
||||||
|
|
||||||
& > * {
|
|
||||||
@include dir.side(margin, var(--sp-normal), 0);
|
@include dir.side(margin, var(--sp-normal), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -32,6 +32,7 @@ import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.s
|
|||||||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||||
|
|
||||||
import { useForceUpdate } from '../../hooks/useForceUpdate';
|
import { useForceUpdate } from '../../hooks/useForceUpdate';
|
||||||
|
import { confirmDialog } from '../../molecules/confirm-dialog/ConfirmDialog';
|
||||||
|
|
||||||
function ModerationTools({
|
function ModerationTools({
|
||||||
roomId, userId,
|
roomId, userId,
|
||||||
@@ -362,7 +363,7 @@ function ProfileViewer() {
|
|||||||
&& (powerLevel < myPowerLevel || userId === mx.getUserId())
|
&& (powerLevel < myPowerLevel || userId === mx.getUserId())
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleChangePowerLevel = (newPowerLevel) => {
|
const handleChangePowerLevel = async (newPowerLevel) => {
|
||||||
if (newPowerLevel === powerLevel) return;
|
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 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?';
|
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 isSharedPower = newPowerLevel === myPowerLevel;
|
||||||
const isDemotingMyself = userId === mx.getUserId();
|
const isDemotingMyself = userId === mx.getUserId();
|
||||||
if (isSharedPower || isDemotingMyself) {
|
if (isSharedPower || isDemotingMyself) {
|
||||||
if (confirm(isSharedPower ? SHARED_POWER_MSG : DEMOTING_MYSELF_MSG)) {
|
const isConfirmed = await confirmDialog(
|
||||||
roomActions.setPowerLevel(roomId, userId, newPowerLevel);
|
'Change power level',
|
||||||
}
|
isSharedPower ? SHARED_POWER_MSG : DEMOTING_MYSELF_MSG,
|
||||||
|
'Change',
|
||||||
|
'caution',
|
||||||
|
);
|
||||||
|
if (!isConfirmed) return;
|
||||||
|
roomActions.setPowerLevel(roomId, userId, newPowerLevel);
|
||||||
} else {
|
} else {
|
||||||
roomActions.setPowerLevel(roomId, userId, newPowerLevel);
|
roomActions.setPowerLevel(roomId, userId, newPowerLevel);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import './PublicRooms.scss';
|
|||||||
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
import initMatrix from '../../../client/initMatrix';
|
||||||
import cons from '../../../client/state/cons';
|
import cons from '../../../client/state/cons';
|
||||||
import { selectRoom } from '../../../client/action/navigation';
|
import { selectRoom, selectTab } from '../../../client/action/navigation';
|
||||||
import * as roomActions from '../../../client/action/room';
|
import * as roomActions from '../../../client/action/room';
|
||||||
|
|
||||||
import Text from '../../atoms/text/Text';
|
import Text from '../../atoms/text/Text';
|
||||||
@@ -179,7 +179,9 @@ function PublicRooms({ isOpen, searchTerm, onRequestClose }) {
|
|||||||
}, [joiningRooms]);
|
}, [joiningRooms]);
|
||||||
|
|
||||||
function handleViewRoom(roomId) {
|
function handleViewRoom(roomId) {
|
||||||
selectRoom(roomId);
|
const room = initMatrix.matrixClient.getRoom(roomId);
|
||||||
|
if (room.isSpaceRoom()) selectTab(roomId);
|
||||||
|
else selectRoom(roomId);
|
||||||
onRequestClose();
|
onRequestClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,7 +195,7 @@ function PublicRooms({ isOpen, searchTerm, onRequestClose }) {
|
|||||||
return rooms.map((room) => {
|
return rooms.map((room) => {
|
||||||
const alias = typeof room.canonical_alias === 'string' ? room.canonical_alias : room.room_id;
|
const alias = typeof room.canonical_alias === 'string' ? room.canonical_alias : room.room_id;
|
||||||
const name = typeof room.name === 'string' ? room.name : alias;
|
const name = typeof room.name === 'string' ? room.name : alias;
|
||||||
const isJoined = initMatrix.roomList.rooms.has(room.room_id);
|
const isJoined = initMatrix.matrixClient.getRoom(room.room_id) !== null;
|
||||||
return (
|
return (
|
||||||
<RoomTile
|
<RoomTile
|
||||||
key={room.room_id}
|
key={room.room_id}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import Search from '../search/Search';
|
|||||||
import ViewSource from '../view-source/ViewSource';
|
import ViewSource from '../view-source/ViewSource';
|
||||||
import CreateRoom from '../create-room/CreateRoom';
|
import CreateRoom from '../create-room/CreateRoom';
|
||||||
|
|
||||||
|
import ReusableDialog from '../../molecules/dialog/ReusableDialog';
|
||||||
|
|
||||||
function Dialogs() {
|
function Dialogs() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -18,6 +20,8 @@ function Dialogs() {
|
|||||||
<CreateRoom />
|
<CreateRoom />
|
||||||
<SpaceAddExisting />
|
<SpaceAddExisting />
|
||||||
<Search />
|
<Search />
|
||||||
|
|
||||||
|
<ReusableDialog />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ function Windows() {
|
|||||||
const [inviteUser, changeInviteUser] = useState({
|
const [inviteUser, changeInviteUser] = useState({
|
||||||
isOpen: false, roomId: undefined, term: undefined,
|
isOpen: false, roomId: undefined, term: undefined,
|
||||||
});
|
});
|
||||||
const [settings, changeSettings] = useState(false);
|
|
||||||
|
|
||||||
function openInviteList() {
|
function openInviteList() {
|
||||||
changeInviteList(true);
|
changeInviteList(true);
|
||||||
@@ -36,20 +35,15 @@ function Windows() {
|
|||||||
searchTerm,
|
searchTerm,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function openSettings() {
|
|
||||||
changeSettings(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
navigation.on(cons.events.navigation.INVITE_LIST_OPENED, openInviteList);
|
navigation.on(cons.events.navigation.INVITE_LIST_OPENED, openInviteList);
|
||||||
navigation.on(cons.events.navigation.PUBLIC_ROOMS_OPENED, openPublicRooms);
|
navigation.on(cons.events.navigation.PUBLIC_ROOMS_OPENED, openPublicRooms);
|
||||||
navigation.on(cons.events.navigation.INVITE_USER_OPENED, openInviteUser);
|
navigation.on(cons.events.navigation.INVITE_USER_OPENED, openInviteUser);
|
||||||
navigation.on(cons.events.navigation.SETTINGS_OPENED, openSettings);
|
|
||||||
return () => {
|
return () => {
|
||||||
navigation.removeListener(cons.events.navigation.INVITE_LIST_OPENED, openInviteList);
|
navigation.removeListener(cons.events.navigation.INVITE_LIST_OPENED, openInviteList);
|
||||||
navigation.removeListener(cons.events.navigation.PUBLIC_ROOMS_OPENED, openPublicRooms);
|
navigation.removeListener(cons.events.navigation.PUBLIC_ROOMS_OPENED, openPublicRooms);
|
||||||
navigation.removeListener(cons.events.navigation.INVITE_USER_OPENED, openInviteUser);
|
navigation.removeListener(cons.events.navigation.INVITE_USER_OPENED, openInviteUser);
|
||||||
navigation.removeListener(cons.events.navigation.SETTINGS_OPENED, openSettings);
|
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -70,10 +64,7 @@ function Windows() {
|
|||||||
searchTerm={inviteUser.searchTerm}
|
searchTerm={inviteUser.searchTerm}
|
||||||
onRequestClose={() => changeInviteUser({ isOpen: false, roomId: undefined })}
|
onRequestClose={() => changeInviteUser({ isOpen: false, roomId: undefined })}
|
||||||
/>
|
/>
|
||||||
<Settings
|
<Settings />
|
||||||
isOpen={settings}
|
|
||||||
onRequestClose={() => changeSettings(false)}
|
|
||||||
/>
|
|
||||||
<SpaceSettings />
|
<SpaceSettings />
|
||||||
<SpaceManage />
|
<SpaceManage />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -64,9 +64,11 @@ function ReadReceipts() {
|
|||||||
onRequestClose={() => setIsOpen(false)}
|
onRequestClose={() => setIsOpen(false)}
|
||||||
contentOptions={<IconButton src={CrossIC} onClick={() => setIsOpen(false)} tooltip="Close" />}
|
contentOptions={<IconButton src={CrossIC} onClick={() => setIsOpen(false)} tooltip="Close" />}
|
||||||
>
|
>
|
||||||
{
|
<div style={{ marginTop: 'var(--sp-tight)', marginBottom: 'var(--sp-extra-loose)' }}>
|
||||||
readers.map(renderPeople)
|
{
|
||||||
}
|
readers.map(renderPeople)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { getPowerLabel, getUsernameOfRoomMember } from '../../../util/matrixUtil
|
|||||||
import colorMXID from '../../../util/colorMXID';
|
import colorMXID from '../../../util/colorMXID';
|
||||||
import { openInviteUser, openProfileViewer } from '../../../client/action/navigation';
|
import { openInviteUser, openProfileViewer } from '../../../client/action/navigation';
|
||||||
import AsyncSearch from '../../../util/AsyncSearch';
|
import AsyncSearch from '../../../util/AsyncSearch';
|
||||||
|
import { memberByAtoZ, memberByPowerLevel } from '../../../util/sort';
|
||||||
|
|
||||||
import Text from '../../atoms/text/Text';
|
import Text from '../../atoms/text/Text';
|
||||||
import Header, { TitleWrapper } from '../../atoms/header/Header';
|
import Header, { TitleWrapper } from '../../atoms/header/Header';
|
||||||
@@ -24,26 +25,6 @@ import AddUserIC from '../../../../public/res/ic/outlined/add-user.svg';
|
|||||||
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
|
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
|
||||||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||||
|
|
||||||
function AtoZ(m1, m2) {
|
|
||||||
const aName = m1.name;
|
|
||||||
const bName = m2.name;
|
|
||||||
|
|
||||||
if (aName.toLowerCase() < bName.toLowerCase()) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
if (aName.toLowerCase() > bName.toLowerCase()) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
function sortByPowerLevel(m1, m2) {
|
|
||||||
const pl1 = m1.powerLevel;
|
|
||||||
const pl2 = m2.powerLevel;
|
|
||||||
|
|
||||||
if (pl1 > pl2) return -1;
|
|
||||||
if (pl1 < pl2) return 1;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
function simplyfiMembers(members) {
|
function simplyfiMembers(members) {
|
||||||
const mx = initMatrix.matrixClient;
|
const mx = initMatrix.matrixClient;
|
||||||
return members.map((member) => ({
|
return members.map((member) => ({
|
||||||
@@ -111,7 +92,7 @@ function PeopleDrawer({ roomId }) {
|
|||||||
setMemberList(
|
setMemberList(
|
||||||
simplyfiMembers(
|
simplyfiMembers(
|
||||||
getMembersWithMembership(membership)
|
getMembersWithMembership(membership)
|
||||||
.sort(AtoZ).sort(sortByPowerLevel),
|
.sort(memberByAtoZ).sort(memberByPowerLevel),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import './Room.scss';
|
|||||||
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
import initMatrix from '../../../client/initMatrix';
|
||||||
import cons from '../../../client/state/cons';
|
import cons from '../../../client/state/cons';
|
||||||
import navigation from '../../../client/state/navigation';
|
|
||||||
import settings from '../../../client/state/settings';
|
import settings from '../../../client/state/settings';
|
||||||
import RoomTimeline from '../../../client/state/RoomTimeline';
|
import RoomTimeline from '../../../client/state/RoomTimeline';
|
||||||
|
import navigation from '../../../client/state/navigation';
|
||||||
|
import { openNavigation } from '../../../client/action/navigation';
|
||||||
|
|
||||||
import Welcome from '../welcome/Welcome';
|
import Welcome from '../welcome/Welcome';
|
||||||
import RoomView from './RoomView';
|
import RoomView from './RoomView';
|
||||||
@@ -26,7 +27,7 @@ function Room() {
|
|||||||
roomInfo.roomTimeline?.removeInternalListeners();
|
roomInfo.roomTimeline?.removeInternalListeners();
|
||||||
if (mx.getRoom(rId)) {
|
if (mx.getRoom(rId)) {
|
||||||
setRoomInfo({
|
setRoomInfo({
|
||||||
roomTimeline: new RoomTimeline(rId, initMatrix.notifications),
|
roomTimeline: new RoomTimeline(rId),
|
||||||
eventId: eId ?? null,
|
eventId: eId ?? null,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -53,7 +54,10 @@ function Room() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const { roomTimeline, eventId } = roomInfo;
|
const { roomTimeline, eventId } = roomInfo;
|
||||||
if (roomTimeline === null) return <Welcome />;
|
if (roomTimeline === null) {
|
||||||
|
setTimeout(() => openNavigation());
|
||||||
|
return <Welcome />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="room">
|
<div className="room">
|
||||||
@@ -61,7 +65,7 @@ function Room() {
|
|||||||
<RoomSettings roomId={roomTimeline.roomId} />
|
<RoomSettings roomId={roomTimeline.roomId} />
|
||||||
<RoomView roomTimeline={roomTimeline} eventId={eventId} />
|
<RoomView roomTimeline={roomTimeline} eventId={eventId} />
|
||||||
</div>
|
</div>
|
||||||
{ isDrawer && <PeopleDrawer roomId={roomTimeline.roomId} />}
|
{isDrawer && <PeopleDrawer roomId={roomTimeline.roomId} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
@use '../../partials/flex';
|
@use '../../partials/flex';
|
||||||
|
@use '../../partials/screen';
|
||||||
|
|
||||||
.room {
|
.room {
|
||||||
@extend .cp-fx__row;
|
@extend .cp-fx__row;
|
||||||
@@ -9,4 +10,10 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
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 ChevronTopIC from '../../../../public/res/ic/outlined/chevron-top.svg';
|
||||||
|
|
||||||
import { useForceUpdate } from '../../hooks/useForceUpdate';
|
import { useForceUpdate } from '../../hooks/useForceUpdate';
|
||||||
|
import { confirmDialog } from '../../molecules/confirm-dialog/ConfirmDialog';
|
||||||
|
|
||||||
const tabText = {
|
const tabText = {
|
||||||
GENERAL: 'General',
|
GENERAL: 'General',
|
||||||
@@ -85,10 +86,15 @@ function GeneralSettings({ roomId }) {
|
|||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
variant="danger"
|
variant="danger"
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
if (confirm('Are you really want to leave this room?')) {
|
const isConfirmed = await confirmDialog(
|
||||||
roomActions.leave(roomId);
|
'Leave room',
|
||||||
}
|
`Are you sure that you want to leave "${room.name}" room?`,
|
||||||
|
'Leave',
|
||||||
|
'danger',
|
||||||
|
);
|
||||||
|
if (!isConfirmed) return;
|
||||||
|
roomActions.leave(roomId);
|
||||||
}}
|
}}
|
||||||
iconSrc={LeaveArrowIC}
|
iconSrc={LeaveArrowIC}
|
||||||
>
|
>
|
||||||
@@ -123,7 +129,7 @@ function SecuritySettings({ roomId }) {
|
|||||||
<RoomEncryption roomId={roomId} />
|
<RoomEncryption roomId={roomId} />
|
||||||
</div>
|
</div>
|
||||||
<div className="room-settings__card">
|
<div className="room-settings__card">
|
||||||
<MenuHeader>Message history visibility (Who can read history)</MenuHeader>
|
<MenuHeader>Message history visibility</MenuHeader>
|
||||||
<RoomHistoryVisibility roomId={roomId} />
|
<RoomHistoryVisibility roomId={roomId} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
@use '../../partials/flex';
|
@use '../../partials/flex';
|
||||||
|
@use '../../partials/screen';
|
||||||
|
@use '../../partials/dir';
|
||||||
|
|
||||||
.room-view {
|
.room-view {
|
||||||
@extend .cp-fx__column;
|
@extend .cp-fx__column;
|
||||||
@@ -18,6 +20,12 @@
|
|||||||
box-shadow: var(--bs-popup);
|
box-shadow: var(--bs-popup);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
& .header {
|
||||||
|
@include screen.smallerThan(mobileBreakpoint) {
|
||||||
|
padding: 0 var(--sp-tight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&__content-wrapper {
|
&__content-wrapper {
|
||||||
@extend .cp-fx__item-one;
|
@extend .cp-fx__item-one;
|
||||||
@extend .cp-fx__column;
|
@extend .cp-fx__column;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import AsyncSearch from '../../../util/AsyncSearch';
|
|||||||
import Text from '../../atoms/text/Text';
|
import Text from '../../atoms/text/Text';
|
||||||
import ScrollView from '../../atoms/scroll/ScrollView';
|
import ScrollView from '../../atoms/scroll/ScrollView';
|
||||||
import FollowingMembers from '../../molecules/following-members/FollowingMembers';
|
import FollowingMembers from '../../molecules/following-members/FollowingMembers';
|
||||||
|
import { addRecentEmoji } from '../emoji-board/recent';
|
||||||
|
|
||||||
const commands = [{
|
const commands = [{
|
||||||
name: 'markdown',
|
name: 'markdown',
|
||||||
@@ -43,7 +44,9 @@ const commands = [{
|
|||||||
}, {
|
}, {
|
||||||
name: 'leave',
|
name: 'leave',
|
||||||
description: 'Leave current room',
|
description: 'Leave current room',
|
||||||
exe: (roomId) => roomActions.leave(roomId),
|
exe: (roomId) => {
|
||||||
|
roomActions.leave(roomId);
|
||||||
|
},
|
||||||
}, {
|
}, {
|
||||||
name: 'invite',
|
name: 'invite',
|
||||||
isOptions: true,
|
isOptions: true,
|
||||||
@@ -237,6 +240,7 @@ function RoomViewCmdBar({ roomId, roomTimeline, viewEvent }) {
|
|||||||
viewEvent.emit('cmd_fired');
|
viewEvent.emit('cmd_fired');
|
||||||
}
|
}
|
||||||
if (myCmd.prefix === ':') {
|
if (myCmd.prefix === ':') {
|
||||||
|
if (!myCmd.result.mxc) addRecentEmoji(myCmd.result.unicode);
|
||||||
viewEvent.emit('cmd_fired', {
|
viewEvent.emit('cmd_fired', {
|
||||||
replace: myCmd.result.mxc ? `:${myCmd.result.shortcode}: ` : myCmd.result.unicode,
|
replace: myCmd.result.mxc ? `:${myCmd.result.shortcode}: ` : myCmd.result.unicode,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
&__info {
|
&__info {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 40px;
|
width: 40px;
|
||||||
@include dir.side(margin, 10px, 14px);
|
@include dir.side(margin, 14px, 10px);
|
||||||
|
|
||||||
& > * {
|
& > * {
|
||||||
margin: auto;
|
margin: auto;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import cons from '../../../client/state/cons';
|
|||||||
import navigation from '../../../client/state/navigation';
|
import navigation from '../../../client/state/navigation';
|
||||||
import { openProfileViewer } from '../../../client/action/navigation';
|
import { openProfileViewer } from '../../../client/action/navigation';
|
||||||
import { diffMinutes, isInSameDay, Throttle } from '../../../util/common';
|
import { diffMinutes, isInSameDay, Throttle } from '../../../util/common';
|
||||||
|
import { markAsRead } from '../../../client/action/notifications';
|
||||||
|
|
||||||
import Divider from '../../atoms/divider/Divider';
|
import Divider from '../../atoms/divider/Divider';
|
||||||
import ScrollView from '../../atoms/scroll/ScrollView';
|
import ScrollView from '../../atoms/scroll/ScrollView';
|
||||||
@@ -62,7 +63,7 @@ function genRoomIntro(mEvent, roomTimeline) {
|
|||||||
avatarSrc={avatarSrc}
|
avatarSrc={avatarSrc}
|
||||||
name={roomTimeline.room.name}
|
name={roomTimeline.room.name}
|
||||||
heading={`Welcome to ${roomTimeline.room.name}`}
|
heading={`Welcome to ${roomTimeline.room.name}`}
|
||||||
desc={`This is the beginning of ${roomTimeline.room.name} room.${typeof roomTopic !== 'undefined' ? (` Topic: ${roomTopic}`) : ''}`}
|
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}
|
time={mEvent ? `Created at ${dateFormat(mEvent.getDate(), 'dd mmmm yyyy, hh:MM TT')}` : null}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -253,7 +254,7 @@ function useHandleScroll(
|
|||||||
);
|
);
|
||||||
roomTimeline.emit(cons.events.roomTimeline.AT_BOTTOM, isAtBottom);
|
roomTimeline.emit(cons.events.roomTimeline.AT_BOTTOM, isAtBottom);
|
||||||
if (isAtBottom && readUptoEvtStore.getItem()) {
|
if (isAtBottom && readUptoEvtStore.getItem()) {
|
||||||
requestAnimationFrame(() => roomTimeline.markAllAsRead());
|
requestAnimationFrame(() => markAsRead(roomTimeline.roomId));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
autoPaginate();
|
autoPaginate();
|
||||||
@@ -263,7 +264,7 @@ function useHandleScroll(
|
|||||||
const timelineScroll = timelineScrollRef.current;
|
const timelineScroll = timelineScrollRef.current;
|
||||||
const limit = eventLimitRef.current;
|
const limit = eventLimitRef.current;
|
||||||
if (readUptoEvtStore.getItem()) {
|
if (readUptoEvtStore.getItem()) {
|
||||||
requestAnimationFrame(() => roomTimeline.markAllAsRead());
|
requestAnimationFrame(() => markAsRead(roomTimeline.roomId));
|
||||||
}
|
}
|
||||||
if (roomTimeline.isServingLiveTimeline()) {
|
if (roomTimeline.isServingLiveTimeline()) {
|
||||||
limit.setFrom(roomTimeline.timeline.length - limit.maxEvents);
|
limit.setFrom(roomTimeline.timeline.length - limit.maxEvents);
|
||||||
@@ -286,7 +287,7 @@ function useEventArrive(roomTimeline, readUptoEvtStore, timelineScrollRef, event
|
|||||||
const limit = eventLimitRef.current;
|
const limit = eventLimitRef.current;
|
||||||
const trySendReadReceipt = (event) => {
|
const trySendReadReceipt = (event) => {
|
||||||
if (myUserId === event.getSender()) {
|
if (myUserId === event.getSender()) {
|
||||||
requestAnimationFrame(() => roomTimeline.markAllAsRead());
|
requestAnimationFrame(() => markAsRead(roomTimeline.roomId));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const readUpToEvent = readUptoEvtStore.getItem();
|
const readUpToEvent = readUptoEvtStore.getItem();
|
||||||
@@ -295,7 +296,7 @@ function useEventArrive(roomTimeline, readUptoEvtStore, timelineScrollRef, event
|
|||||||
|
|
||||||
if (isUnread === false) {
|
if (isUnread === false) {
|
||||||
if (document.visibilityState === 'visible' && timelineScroll.bottom < 16) {
|
if (document.visibilityState === 'visible' && timelineScroll.bottom < 16) {
|
||||||
requestAnimationFrame(() => roomTimeline.markAllAsRead());
|
requestAnimationFrame(() => markAsRead(roomTimeline.roomId));
|
||||||
} else {
|
} else {
|
||||||
readUptoEvtStore.setItem(roomTimeline.findEventByIdInTimelineSet(readUpToId));
|
readUptoEvtStore.setItem(roomTimeline.findEventByIdInTimelineSet(readUpToId));
|
||||||
}
|
}
|
||||||
@@ -305,7 +306,7 @@ function useEventArrive(roomTimeline, readUptoEvtStore, timelineScrollRef, event
|
|||||||
const { timeline } = roomTimeline;
|
const { timeline } = roomTimeline;
|
||||||
const unreadMsgIsLast = timeline[timeline.length - 2].getId() === readUpToId;
|
const unreadMsgIsLast = timeline[timeline.length - 2].getId() === readUpToId;
|
||||||
if (unreadMsgIsLast) {
|
if (unreadMsgIsLast) {
|
||||||
requestAnimationFrame(() => roomTimeline.markAllAsRead());
|
requestAnimationFrame(() => markAsRead(roomTimeline.roomId));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -399,7 +400,7 @@ function RoomViewContent({ eventId, roomTimeline }) {
|
|||||||
if (timelineScroll.bottom < 16 && !roomTimeline.canPaginateForward()) {
|
if (timelineScroll.bottom < 16 && !roomTimeline.canPaginateForward()) {
|
||||||
const readUpToId = roomTimeline.getReadUpToEventId();
|
const readUpToId = roomTimeline.getReadUpToEventId();
|
||||||
if (readUptoEvtStore.getItem()?.getId() === readUpToId || readUpToId === null) {
|
if (readUptoEvtStore.getItem()?.getId() === readUpToId || readUpToId === null) {
|
||||||
requestAnimationFrame(() => roomTimeline.markAllAsRead());
|
requestAnimationFrame(() => markAsRead(roomTimeline.roomId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
jumpToItemIndex = -1;
|
jumpToItemIndex = -1;
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ import './RoomViewFloating.scss';
|
|||||||
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
import initMatrix from '../../../client/initMatrix';
|
||||||
import cons from '../../../client/state/cons';
|
import cons from '../../../client/state/cons';
|
||||||
|
import { markAsRead } from '../../../client/action/notifications';
|
||||||
|
|
||||||
import Text from '../../atoms/text/Text';
|
import Text from '../../atoms/text/Text';
|
||||||
import Button from '../../atoms/button/Button';
|
import Button from '../../atoms/button/Button';
|
||||||
import IconButton from '../../atoms/button/IconButton';
|
|
||||||
|
|
||||||
import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.svg';
|
import MessageIC from '../../../../public/res/ic/outlined/message.svg';
|
||||||
|
import MessageUnreadIC from '../../../../public/res/ic/outlined/message-unread.svg';
|
||||||
import TickMarkIC from '../../../../public/res/ic/outlined/tick-mark.svg';
|
import TickMarkIC from '../../../../public/res/ic/outlined/tick-mark.svg';
|
||||||
|
|
||||||
import { getUsersActionJsx } from './common';
|
import { getUsersActionJsx } from './common';
|
||||||
@@ -23,7 +24,7 @@ function useJumpToEvent(roomTimeline) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const cancelJumpToEvent = () => {
|
const cancelJumpToEvent = () => {
|
||||||
roomTimeline.markAllAsRead();
|
markAsRead(roomTimeline.roomId);
|
||||||
setEventId(null);
|
setEventId(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -36,11 +37,12 @@ function useJumpToEvent(roomTimeline) {
|
|||||||
setEventId(readEventId);
|
setEventId(readEventId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { notifications } = initMatrix;
|
||||||
const handleMarkAsRead = () => setEventId(null);
|
const handleMarkAsRead = () => setEventId(null);
|
||||||
roomTimeline.on(cons.events.roomTimeline.MARKED_AS_READ, handleMarkAsRead);
|
notifications.on(cons.events.notifications.FULL_READ, handleMarkAsRead);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
roomTimeline.removeListener(cons.events.roomTimeline.MARKED_AS_READ, handleMarkAsRead);
|
notifications.removeListener(cons.events.notifications.FULL_READ, handleMarkAsRead);
|
||||||
setEventId(null);
|
setEventId(null);
|
||||||
};
|
};
|
||||||
}, [roomTimeline]);
|
}, [roomTimeline]);
|
||||||
@@ -96,28 +98,21 @@ function RoomViewFloating({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={`room-view__unread ${isJumpToEvent ? 'room-view__unread--open' : ''}`}>
|
<div className={`room-view__unread ${isJumpToEvent ? 'room-view__unread--open' : ''}`}>
|
||||||
<Button onClick={jumpToEvent} variant="primary">
|
<Button iconSrc={MessageUnreadIC} onClick={jumpToEvent} variant="primary">
|
||||||
<Text variant="b2">Jump to unread</Text>
|
<Text variant="b3" weight="medium">Jump to unread messages</Text>
|
||||||
|
</Button>
|
||||||
|
<Button iconSrc={TickMarkIC} onClick={cancelJumpToEvent} variant="primary">
|
||||||
|
<Text variant="b3" weight="bold">Mark as read</Text>
|
||||||
</Button>
|
</Button>
|
||||||
<IconButton
|
|
||||||
onClick={cancelJumpToEvent}
|
|
||||||
variant="primary"
|
|
||||||
size="extra-small"
|
|
||||||
src={TickMarkIC}
|
|
||||||
tooltipPlacement="bottom"
|
|
||||||
tooltip="Mark as read"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className={`room-view__typing${typingMembers.size > 0 ? ' room-view__typing--open' : ''}`}>
|
<div className={`room-view__typing${typingMembers.size > 0 ? ' room-view__typing--open' : ''}`}>
|
||||||
<div className="bouncing-loader"><div /></div>
|
<div className="bouncing-loader"><div /></div>
|
||||||
<Text variant="b2">{getUsersActionJsx(roomId, [...typingMembers], 'typing...')}</Text>
|
<Text variant="b2">{getUsersActionJsx(roomId, [...typingMembers], 'typing...')}</Text>
|
||||||
</div>
|
</div>
|
||||||
<div className={`room-view__STB${isAtBottom ? '' : ' room-view__STB--open'}`}>
|
<div className={`room-view__STB${isAtBottom ? '' : ' room-view__STB--open'}`}>
|
||||||
<IconButton
|
<Button iconSrc={MessageIC} onClick={handleScrollToBottom}>
|
||||||
onClick={handleScrollToBottom}
|
<Text variant="b3" weight="medium">Jump to latest</Text>
|
||||||
src={ChevronBottomIC}
|
</Button>
|
||||||
tooltip="Scroll to Bottom"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -72,51 +72,54 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__STB,
|
||||||
|
&__unread {
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: var(--bg-surface-low);
|
||||||
|
border-radius: var(--bo-radius);
|
||||||
|
|
||||||
|
& button {
|
||||||
|
justify-content: flex-start;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
padding: 6px var(--sp-tight);
|
||||||
|
& .ic-raw {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&__STB {
|
&__STB {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@include dir.prop(right, var(--sp-normal), unset);
|
@include dir.prop(left, 50%, unset);
|
||||||
@include dir.prop(left, unset, var(--sp-normal));
|
@include dir.prop(right, unset, 50%);
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
border-radius: var(--bo-radius);
|
|
||||||
box-shadow: var(--bs-surface-border);
|
box-shadow: var(--bs-surface-border);
|
||||||
background-color: var(--bg-surface-low);
|
|
||||||
transition: transform 200ms ease-in-out;
|
transition: transform 200ms ease-in-out;
|
||||||
transform: translateY(100%) scale(0);
|
transform: translate(-50%, 100%);
|
||||||
|
|
||||||
&--open {
|
&--open {
|
||||||
transform: translateY(-28px) scale(1);
|
transform: translate(-50%, -28px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__unread {
|
&__unread {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: var(--sp-extra-tight);
|
top: var(--sp-extra-tight);
|
||||||
@include dir.prop(right, var(--sp-extra-tight), unset);
|
@include dir.prop(left, var(--sp-normal), unset);
|
||||||
@include dir.prop(left, unset, var(--sp-extra-tight));
|
@include dir.prop(right, unset, var(--sp-normal));
|
||||||
z-index: 999;
|
z-index: 999;
|
||||||
|
|
||||||
display: none;
|
display: none;
|
||||||
background-color: var(--bg-surface);
|
width: calc(100% - var(--sp-extra-loose));
|
||||||
border-radius: var(--bo-radius);
|
box-shadow: 0 0 2px 0 rgba(0, 0, 0, 20%);
|
||||||
box-shadow: var(--bs-primary-border);
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
&--open {
|
&--open {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
& button:first-child {
|
||||||
& .ic-btn {
|
|
||||||
padding: 6px var(--sp-extra-tight);
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
& .btn-primary {
|
|
||||||
@extend .cp-fx__item-one;
|
@extend .cp-fx__item-one;
|
||||||
@include dir.side(margin, 0, 1px);
|
|
||||||
border-radius: 0;
|
|
||||||
padding: 0 var(--sp-tight);
|
|
||||||
&:focus {
|
|
||||||
background-color: var(--bg-primary-hover);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8,7 +8,7 @@ import { blurOnBubbling } from '../../atoms/button/script';
|
|||||||
import initMatrix from '../../../client/initMatrix';
|
import initMatrix from '../../../client/initMatrix';
|
||||||
import cons from '../../../client/state/cons';
|
import cons from '../../../client/state/cons';
|
||||||
import navigation from '../../../client/state/navigation';
|
import navigation from '../../../client/state/navigation';
|
||||||
import { toggleRoomSettings, openReusableContextMenu } from '../../../client/action/navigation';
|
import { toggleRoomSettings, openReusableContextMenu, openNavigation } from '../../../client/action/navigation';
|
||||||
import { togglePeopleDrawer } from '../../../client/action/settings';
|
import { togglePeopleDrawer } from '../../../client/action/settings';
|
||||||
import colorMXID from '../../../util/colorMXID';
|
import colorMXID from '../../../util/colorMXID';
|
||||||
import { getEventCords } from '../../../util/common';
|
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 SearchIC from '../../../../public/res/ic/outlined/search.svg';
|
||||||
import UserIC from '../../../../public/res/ic/outlined/user.svg';
|
import UserIC from '../../../../public/res/ic/outlined/user.svg';
|
||||||
import VerticalMenuIC from '../../../../public/res/ic/outlined/vertical-menu.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';
|
import { useForceUpdate } from '../../hooks/useForceUpdate';
|
||||||
|
|
||||||
@@ -73,6 +74,12 @@ function RoomViewHeader({ roomId }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Header>
|
<Header>
|
||||||
|
<IconButton
|
||||||
|
src={BackArrowIC}
|
||||||
|
className="room-header__back-btn"
|
||||||
|
tooltip="Return to navigation"
|
||||||
|
onClick={() => openNavigation()}
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
ref={roomHeaderBtnRef}
|
ref={roomHeaderBtnRef}
|
||||||
className="room-header__btn"
|
className="room-header__btn"
|
||||||
@@ -87,7 +94,8 @@ function RoomViewHeader({ roomId }) {
|
|||||||
<RawIcon src={ChevronBottomIC} />
|
<RawIcon src={ChevronBottomIC} />
|
||||||
</button>
|
</button>
|
||||||
<IconButton onClick={() => toggleRoomSettings(tabText.SEARCH)} tooltip="Search" src={SearchIC} />
|
<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
|
<IconButton
|
||||||
onClick={openRoomOptions}
|
onClick={openRoomOptions}
|
||||||
tooltip="Options"
|
tooltip="Options"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
@use '../../partials/flex';
|
@use '../../partials/flex';
|
||||||
@use '../../partials/dir';
|
@use '../../partials/dir';
|
||||||
|
@use '../../partials/screen';
|
||||||
|
|
||||||
.room-header__btn {
|
.room-header__btn {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -24,4 +25,23 @@
|
|||||||
box-shadow: var(--bs-surface-outline);
|
box-shadow: var(--bs-surface-outline);
|
||||||
outline: none;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import navigation from '../../../client/state/navigation';
|
|||||||
import AsyncSearch from '../../../util/AsyncSearch';
|
import AsyncSearch from '../../../util/AsyncSearch';
|
||||||
import { selectRoom, selectTab } from '../../../client/action/navigation';
|
import { selectRoom, selectTab } from '../../../client/action/navigation';
|
||||||
import { joinRuleToIconSrc } from '../../../util/matrixUtil';
|
import { joinRuleToIconSrc } from '../../../util/matrixUtil';
|
||||||
|
import { roomIdByActivity } from '../../../util/sort';
|
||||||
|
|
||||||
import Text from '../../atoms/text/Text';
|
import Text from '../../atoms/text/Text';
|
||||||
import RawIcon from '../../atoms/system-icons/RawIcon';
|
import RawIcon from '../../atoms/system-icons/RawIcon';
|
||||||
@@ -47,27 +48,24 @@ function useVisiblityToggle(setResult) {
|
|||||||
return [isOpen, requestClose];
|
return [isOpen, requestClose];
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapRoomIds(roomIds, type) {
|
function mapRoomIds(roomIds) {
|
||||||
const mx = initMatrix.matrixClient;
|
const mx = initMatrix.matrixClient;
|
||||||
const { directs, roomIdToParents } = initMatrix.roomList;
|
const { directs, roomIdToParents } = initMatrix.roomList;
|
||||||
|
|
||||||
return roomIds.map((roomId) => {
|
return roomIds.map((roomId) => {
|
||||||
let roomType = type;
|
|
||||||
|
|
||||||
if (!roomType) {
|
|
||||||
roomType = directs.has(roomId) ? 'direct' : 'room';
|
|
||||||
}
|
|
||||||
|
|
||||||
const room = mx.getRoom(roomId);
|
const room = mx.getRoom(roomId);
|
||||||
const parentSet = roomIdToParents.get(roomId);
|
const parentSet = roomIdToParents.get(roomId);
|
||||||
const parentNames = parentSet
|
const parentNames = parentSet ? [] : undefined;
|
||||||
? [...parentSet].map((parentId) => mx.getRoom(parentId).name)
|
parentSet?.forEach((parentId) => parentNames.push(mx.getRoom(parentId).name));
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const parents = parentNames ? parentNames.join(', ') : null;
|
const parents = parentNames ? parentNames.join(', ') : null;
|
||||||
|
|
||||||
|
let type = 'room';
|
||||||
|
if (room.isSpaceRoom()) type = 'space';
|
||||||
|
else if (directs.has(roomId)) type = 'direct';
|
||||||
|
|
||||||
return ({
|
return ({
|
||||||
type: roomType,
|
type,
|
||||||
name: room.name,
|
name: room.name,
|
||||||
parents,
|
parents,
|
||||||
roomId,
|
roomId,
|
||||||
@@ -93,34 +91,32 @@ function Search() {
|
|||||||
const generateResults = (term) => {
|
const generateResults = (term) => {
|
||||||
const prefix = term.match(/^[#@*]/)?.[0];
|
const prefix = term.match(/^[#@*]/)?.[0];
|
||||||
|
|
||||||
if (term.length === 1) {
|
if (term.length > 1) {
|
||||||
const { roomList } = initMatrix;
|
|
||||||
const spaces = mapRoomIds([...roomList.spaces], 'space').reverse();
|
|
||||||
const rooms = mapRoomIds([...roomList.rooms], 'room').reverse();
|
|
||||||
const directs = mapRoomIds([...roomList.directs], 'direct').reverse();
|
|
||||||
|
|
||||||
if (prefix === '*') {
|
|
||||||
asyncSearch.setup(spaces, { keys: 'name', isContain: true, limit: 20 });
|
|
||||||
handleSearchResults(spaces, '*');
|
|
||||||
} else if (prefix === '#') {
|
|
||||||
asyncSearch.setup(rooms, { keys: 'name', isContain: true, limit: 20 });
|
|
||||||
handleSearchResults(rooms, '#');
|
|
||||||
} else if (prefix === '@') {
|
|
||||||
asyncSearch.setup(directs, { keys: 'name', isContain: true, limit: 20 });
|
|
||||||
handleSearchResults(directs, '@');
|
|
||||||
} else {
|
|
||||||
const dataList = spaces.concat(rooms, directs);
|
|
||||||
asyncSearch.setup(dataList, { keys: 'name', isContain: true, limit: 20 });
|
|
||||||
asyncSearch.search(term);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
asyncSearch.search(prefix ? term.slice(1) : term);
|
asyncSearch.search(prefix ? term.slice(1) : term);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { spaces, rooms, directs } = initMatrix.roomList;
|
||||||
|
let ids = null;
|
||||||
|
|
||||||
|
if (prefix) {
|
||||||
|
if (prefix === '#') ids = [...rooms];
|
||||||
|
else if (prefix === '@') ids = [...directs];
|
||||||
|
else ids = [...spaces];
|
||||||
|
} else {
|
||||||
|
ids = [...rooms].concat([...directs], [...spaces]);
|
||||||
|
}
|
||||||
|
|
||||||
|
ids.sort(roomIdByActivity);
|
||||||
|
const mappedIds = mapRoomIds(ids);
|
||||||
|
asyncSearch.setup(mappedIds, { keys: 'name', isContain: true, limit: 20 });
|
||||||
|
if (prefix) handleSearchResults(mappedIds, prefix);
|
||||||
|
else asyncSearch.search(term);
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadRecentRooms = () => {
|
const loadRecentRooms = () => {
|
||||||
const { recentRooms } = navigation;
|
const { recentRooms } = navigation;
|
||||||
handleSearchResults(mapRoomIds(recentRooms).reverse(), '');
|
handleSearchResults(mapRoomIds(recentRooms).reverse());
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAfterOpen = () => {
|
const handleAfterOpen = () => {
|
||||||
@@ -154,6 +150,7 @@ function Search() {
|
|||||||
else {
|
else {
|
||||||
searchRef.current.value = '';
|
searchRef.current.value = '';
|
||||||
searchRef.current.focus();
|
searchRef.current.focus();
|
||||||
|
loadRecentRooms();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -202,7 +199,7 @@ function Search() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<RawModal
|
<RawModal
|
||||||
className="search-dialog__model dialog-model"
|
className="search-dialog__modal dialog-modal"
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onAfterOpen={handleAfterOpen}
|
onAfterOpen={handleAfterOpen}
|
||||||
onAfterClose={handleAfterClose}
|
onAfterClose={handleAfterClose}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
@use '../../partials/dir';
|
@use '../../partials/dir';
|
||||||
|
|
||||||
.search-dialog__model {
|
.search-dialog__modal {
|
||||||
--modal-height: 380px;
|
--modal-height: 380px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background-color: var(--bg-surface);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
229
src/app/organisms/settings/DeviceManage.jsx
Normal file
229
src/app/organisms/settings/DeviceManage.jsx
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import './DeviceManage.scss';
|
||||||
|
import dateFormat from 'dateformat';
|
||||||
|
|
||||||
|
import initMatrix from '../../../client/initMatrix';
|
||||||
|
import { isCrossVerified } from '../../../util/matrixUtil';
|
||||||
|
import { openReusableDialog } 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';
|
||||||
|
|
||||||
|
const promptDeviceName = async (deviceName) => new Promise((resolve) => {
|
||||||
|
let isCompleted = false;
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setProcessing([]);
|
||||||
|
}, [deviceList]);
|
||||||
|
|
||||||
|
const addToProcessing = (device) => {
|
||||||
|
const old = [...processing];
|
||||||
|
old.push(device.device_id);
|
||||||
|
setProcessing(old);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFromProcessing = () => {
|
||||||
|
setProcessing([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (deviceList === null) {
|
||||||
|
return (
|
||||||
|
<div className="device-manage">
|
||||||
|
<div className="device-manage__loading">
|
||||||
|
<Spinner size="small" />
|
||||||
|
<Text>Loading devices...</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRename = async (device) => {
|
||||||
|
const newName = await promptDeviceName(device.display_name);
|
||||||
|
if (newName === null || newName.trim() === '') return;
|
||||||
|
if (newName.trim() === device.display_name) return;
|
||||||
|
addToProcessing(device);
|
||||||
|
try {
|
||||||
|
await mx.setDeviceDetails(device.device_id, {
|
||||||
|
display_name: newName,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
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 renderDevice = (device, isVerified) => {
|
||||||
|
const deviceId = device.device_id;
|
||||||
|
const displayName = device.display_name;
|
||||||
|
const lastIP = device.last_seen_ip;
|
||||||
|
const lastTS = device.last_seen_ts;
|
||||||
|
return (
|
||||||
|
<SettingTile
|
||||||
|
key={deviceId}
|
||||||
|
title={(
|
||||||
|
<Text style={{ color: isVerified ? '' : 'var(--tc-danger-high)' }}>
|
||||||
|
{displayName}
|
||||||
|
<Text variant="b3" span>{` — ${deviceId}${mx.deviceId === deviceId ? ' (current)' : ''}`}</Text>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
options={
|
||||||
|
processing.includes(deviceId)
|
||||||
|
? <Spinner size="small" />
|
||||||
|
: (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const unverified = [];
|
||||||
|
const verified = [];
|
||||||
|
const noEncryption = [];
|
||||||
|
deviceList.sort((a, b) => b.last_seen_ts - a.last_seen_ts).forEach((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 sessions</Text>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
{noEncryption.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<MenuHeader>Sessions without encryption support</MenuHeader>
|
||||||
|
{noEncryption.map((device) => renderDevice(device, true))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<MenuHeader>Verified sessions</MenuHeader>
|
||||||
|
{
|
||||||
|
verified.length > 0
|
||||||
|
? verified.map((device, index) => {
|
||||||
|
if (truncated && index >= TRUNCATED_COUNT) return null;
|
||||||
|
return renderDevice(device, true);
|
||||||
|
})
|
||||||
|
: <Text className="device-manage__info">No verified session</Text>
|
||||||
|
}
|
||||||
|
{ verified.length > TRUNCATED_COUNT && (
|
||||||
|
<Button className="device-manage__info" onClick={() => setTruncated(!truncated)}>
|
||||||
|
{truncated ? `View ${verified.length - 4} more` : 'View less'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{ deviceList.length > 0 && (
|
||||||
|
<Text className="device-manage__info" variant="b3">Session names are visible to everyone, so do not put any private info here.</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DeviceManage;
|
||||||
29
src/app/organisms/settings/DeviceManage.scss
Normal file
29
src/app/organisms/settings/DeviceManage.scss
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
@use '../../partials/flex';
|
||||||
|
|
||||||
|
.device-manage {
|
||||||
|
&__loading {
|
||||||
|
@extend .cp-fx__row--c-c;
|
||||||
|
padding: var(--sp-extra-loose) var(--sp-normal);
|
||||||
|
|
||||||
|
.text {
|
||||||
|
margin: 0 var(--sp-normal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&__info {
|
||||||
|
margin: var(--sp-normal);
|
||||||
|
}
|
||||||
|
& .setting-tile:last-of-type {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__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();
|
||||||
|
mountStore.setItem(true);
|
||||||
|
|
||||||
|
const deleteBackup = async () => {
|
||||||
|
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();
|
||||||
|
mountStore.setItem(true);
|
||||||
|
|
||||||
|
const toggleWithPhrase = () => setWithPhrase(!withPhrase);
|
||||||
|
|
||||||
|
const processInput = async ({ key, phrase }) => {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import './Settings.scss';
|
import './Settings.scss';
|
||||||
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
import initMatrix from '../../../client/initMatrix';
|
||||||
import cons from '../../../client/state/cons';
|
import cons from '../../../client/state/cons';
|
||||||
import settings from '../../../client/state/settings';
|
import settings from '../../../client/state/settings';
|
||||||
|
import navigation from '../../../client/state/navigation';
|
||||||
import {
|
import {
|
||||||
toggleSystemTheme, toggleMarkdown, toggleMembershipEvents, toggleNickAvatarEvents,
|
toggleSystemTheme, toggleMarkdown, toggleMembershipEvents, toggleNickAvatarEvents,
|
||||||
toggleNotifications,
|
toggleNotifications, toggleNotificationSounds,
|
||||||
} from '../../../client/action/settings';
|
} from '../../../client/action/settings';
|
||||||
import logout from '../../../client/action/logout';
|
import logout from '../../../client/action/logout';
|
||||||
import { usePermission } from '../../hooks/usePermission';
|
import { usePermission } from '../../hooks/usePermission';
|
||||||
@@ -16,16 +16,20 @@ import Text from '../../atoms/text/Text';
|
|||||||
import IconButton from '../../atoms/button/IconButton';
|
import IconButton from '../../atoms/button/IconButton';
|
||||||
import Button from '../../atoms/button/Button';
|
import Button from '../../atoms/button/Button';
|
||||||
import Toggle from '../../atoms/button/Toggle';
|
import Toggle from '../../atoms/button/Toggle';
|
||||||
|
import Tabs from '../../atoms/tabs/Tabs';
|
||||||
|
import { MenuHeader } from '../../atoms/context-menu/ContextMenu';
|
||||||
import SegmentedControls from '../../atoms/segmented-controls/SegmentedControls';
|
import SegmentedControls from '../../atoms/segmented-controls/SegmentedControls';
|
||||||
|
|
||||||
import PopupWindow, { PWContentSelector } from '../../molecules/popup-window/PopupWindow';
|
import PopupWindow from '../../molecules/popup-window/PopupWindow';
|
||||||
import SettingTile from '../../molecules/setting-tile/SettingTile';
|
import SettingTile from '../../molecules/setting-tile/SettingTile';
|
||||||
import ImportE2ERoomKeys from '../../molecules/import-export-e2e-room-keys/ImportE2ERoomKeys';
|
import ImportE2ERoomKeys from '../../molecules/import-export-e2e-room-keys/ImportE2ERoomKeys';
|
||||||
import ExportE2ERoomKeys from '../../molecules/import-export-e2e-room-keys/ExportE2ERoomKeys';
|
import ExportE2ERoomKeys from '../../molecules/import-export-e2e-room-keys/ExportE2ERoomKeys';
|
||||||
|
|
||||||
import ProfileEditor from '../profile-editor/ProfileEditor';
|
import ProfileEditor from '../profile-editor/ProfileEditor';
|
||||||
|
import CrossSigning from './CrossSigning';
|
||||||
|
import KeyBackup from './KeyBackup';
|
||||||
|
import DeviceManage from './DeviceManage';
|
||||||
|
|
||||||
import SettingsIC from '../../../../public/res/ic/outlined/settings.svg';
|
|
||||||
import SunIC from '../../../../public/res/ic/outlined/sun.svg';
|
import SunIC from '../../../../public/res/ic/outlined/sun.svg';
|
||||||
import LockIC from '../../../../public/res/ic/outlined/lock.svg';
|
import LockIC from '../../../../public/res/ic/outlined/lock.svg';
|
||||||
import BellIC from '../../../../public/res/ic/outlined/bell.svg';
|
import BellIC from '../../../../public/res/ic/outlined/bell.svg';
|
||||||
@@ -34,86 +38,76 @@ import PowerIC from '../../../../public/res/ic/outlined/power.svg';
|
|||||||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||||
|
|
||||||
import CinnySVG from '../../../../public/res/svg/cinny.svg';
|
import CinnySVG from '../../../../public/res/svg/cinny.svg';
|
||||||
|
import { confirmDialog } from '../../molecules/confirm-dialog/ConfirmDialog';
|
||||||
function GeneralSection() {
|
|
||||||
return (
|
|
||||||
<div className="settings-content">
|
|
||||||
<SettingTile
|
|
||||||
title=""
|
|
||||||
content={(
|
|
||||||
<ProfileEditor userId={initMatrix.matrixClient.getUserId()} />
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AppearanceSection() {
|
function AppearanceSection() {
|
||||||
const [, updateState] = useState({});
|
const [, updateState] = useState({});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="settings-content">
|
<div className="settings-appearance">
|
||||||
<SettingTile
|
<div className="settings-appearance__card">
|
||||||
title="Follow system theme"
|
<MenuHeader>Theme</MenuHeader>
|
||||||
options={(
|
<SettingTile
|
||||||
<Toggle
|
title="Follow system theme"
|
||||||
isActive={settings.useSystemTheme}
|
options={(
|
||||||
onToggle={() => { toggleSystemTheme(); updateState({}); }}
|
<Toggle
|
||||||
/>
|
isActive={settings.useSystemTheme}
|
||||||
)}
|
onToggle={() => { toggleSystemTheme(); updateState({}); }}
|
||||||
content={<Text variant="b3">Use light or dark mode based on the system's settings.</Text>}
|
|
||||||
/>
|
|
||||||
{(() => {
|
|
||||||
if (!settings.useSystemTheme) {
|
|
||||||
return (
|
|
||||||
<SettingTile
|
|
||||||
title="Theme"
|
|
||||||
content={(
|
|
||||||
<SegmentedControls
|
|
||||||
selected={settings.getThemeIndex()}
|
|
||||||
segments={[
|
|
||||||
{ text: 'Light' },
|
|
||||||
{ text: 'Silver' },
|
|
||||||
{ text: 'Dark' },
|
|
||||||
{ text: 'Butter' },
|
|
||||||
]}
|
|
||||||
onSelect={(index) => settings.setTheme(index)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
);
|
)}
|
||||||
}
|
content={<Text variant="b3">Use light or dark mode based on the system settings.</Text>}
|
||||||
})()}
|
/>
|
||||||
<SettingTile
|
{!settings.useSystemTheme && (
|
||||||
title="Markdown formatting"
|
<SettingTile
|
||||||
options={(
|
title="Theme"
|
||||||
<Toggle
|
content={(
|
||||||
isActive={settings.isMarkdown}
|
<SegmentedControls
|
||||||
onToggle={() => { toggleMarkdown(); updateState({}); }}
|
selected={settings.getThemeIndex()}
|
||||||
|
segments={[
|
||||||
|
{ text: 'Light' },
|
||||||
|
{ text: 'Silver' },
|
||||||
|
{ text: 'Dark' },
|
||||||
|
{ text: 'Butter' },
|
||||||
|
]}
|
||||||
|
onSelect={(index) => settings.setTheme(index)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
content={<Text variant="b3">Format messages with markdown syntax before sending.</Text>}
|
</div>
|
||||||
/>
|
<div className="settings-appearance__card">
|
||||||
<SettingTile
|
<MenuHeader>Room messages</MenuHeader>
|
||||||
title="Hide membership events"
|
<SettingTile
|
||||||
options={(
|
title="Markdown formatting"
|
||||||
<Toggle
|
options={(
|
||||||
isActive={settings.hideMembershipEvents}
|
<Toggle
|
||||||
onToggle={() => { toggleMembershipEvents(); updateState({}); }}
|
isActive={settings.isMarkdown}
|
||||||
/>
|
onToggle={() => { toggleMarkdown(); updateState({}); }}
|
||||||
)}
|
/>
|
||||||
content={<Text variant="b3">Hide membership change messages from room timeline. (Join, Leave, Invite, Kick and Ban)</Text>}
|
)}
|
||||||
/>
|
content={<Text variant="b3">Format messages with markdown syntax before sending.</Text>}
|
||||||
<SettingTile
|
/>
|
||||||
title="Hide nick/avatar events"
|
<SettingTile
|
||||||
options={(
|
title="Hide membership events"
|
||||||
<Toggle
|
options={(
|
||||||
isActive={settings.hideNickAvatarEvents}
|
<Toggle
|
||||||
onToggle={() => { toggleNickAvatarEvents(); updateState({}); }}
|
isActive={settings.hideMembershipEvents}
|
||||||
/>
|
onToggle={() => { toggleMembershipEvents(); updateState({}); }}
|
||||||
)}
|
/>
|
||||||
content={<Text variant="b3">Hide nick and avatar change messages from room timeline.</Text>}
|
)}
|
||||||
/>
|
content={<Text variant="b3">Hide membership change messages from room timeline. (Join, Leave, Invite, Kick and Ban)</Text>}
|
||||||
|
/>
|
||||||
|
<SettingTile
|
||||||
|
title="Hide nick/avatar events"
|
||||||
|
options={(
|
||||||
|
<Toggle
|
||||||
|
isActive={settings.hideNickAvatarEvents}
|
||||||
|
onToggle={() => { toggleNickAvatarEvents(); updateState({}); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
content={<Text variant="b3">Hide nick and avatar change messages from room timeline.</Text>}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -125,7 +119,7 @@ function NotificationsSection() {
|
|||||||
|
|
||||||
const renderOptions = () => {
|
const renderOptions = () => {
|
||||||
if (window.Notification === undefined) {
|
if (window.Notification === undefined) {
|
||||||
return <Text className="set-notifications__not-supported">Not supported in this browser.</Text>;
|
return <Text className="settings-notifications__not-supported">Not supported in this browser.</Text>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (permission === 'granted') {
|
if (permission === 'granted') {
|
||||||
@@ -152,11 +146,22 @@ function NotificationsSection() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="set-notifications settings-content">
|
<div className="settings-notifications">
|
||||||
|
<MenuHeader>Notification & Sound</MenuHeader>
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Show desktop notifications"
|
title="Desktop notification"
|
||||||
options={renderOptions()}
|
options={renderOptions()}
|
||||||
content={<Text variant="b3">Show notifications when new messages arrive.</Text>}
|
content={<Text variant="b3">Show desktop notification when new messages arrive.</Text>}
|
||||||
|
/>
|
||||||
|
<SettingTile
|
||||||
|
title="Notification Sound"
|
||||||
|
options={(
|
||||||
|
<Toggle
|
||||||
|
isActive={settings.isNotificationSounds}
|
||||||
|
onToggle={() => { toggleNotificationSounds(); updateState({}); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
content={<Text variant="b3">Play sound when new messages arrive.</Text>}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -164,149 +169,171 @@ function NotificationsSection() {
|
|||||||
|
|
||||||
function SecuritySection() {
|
function SecuritySection() {
|
||||||
return (
|
return (
|
||||||
<div className="set-security settings-content">
|
<div className="settings-security">
|
||||||
<SettingTile
|
<div className="settings-security__card">
|
||||||
title={`Device ID: ${initMatrix.matrixClient.getDeviceId()}`}
|
<MenuHeader>Cross signing and backup</MenuHeader>
|
||||||
/>
|
<CrossSigning />
|
||||||
<SettingTile
|
<KeyBackup />
|
||||||
title={`Device key: ${initMatrix.matrixClient.getDeviceEd25519Key().match(/.{1,4}/g).join(' ')}`}
|
</div>
|
||||||
content={<Text variant="b3">Use this device ID-key combo to verify or manage this session from Element client.</Text>}
|
<DeviceManage />
|
||||||
/>
|
<div className="settings-security__card">
|
||||||
<SettingTile
|
<MenuHeader>Export/Import encryption keys</MenuHeader>
|
||||||
title="Export E2E room keys"
|
<SettingTile
|
||||||
content={(
|
title="Export E2E room keys"
|
||||||
<>
|
content={(
|
||||||
<Text variant="b3">Export end-to-end encryption room keys to decrypt old messages in other session. In order to encrypt keys you need to set a password, which will be used while importing.</Text>
|
<>
|
||||||
<ExportE2ERoomKeys />
|
<Text variant="b3">Export end-to-end encryption room keys to decrypt old messages in other session. In order to encrypt keys you need to set a password, which will be used while importing.</Text>
|
||||||
</>
|
<ExportE2ERoomKeys />
|
||||||
)}
|
</>
|
||||||
/>
|
)}
|
||||||
<SettingTile
|
/>
|
||||||
title="Import E2E room keys"
|
<SettingTile
|
||||||
content={(
|
title="Import E2E room keys"
|
||||||
<>
|
content={(
|
||||||
<Text variant="b3">{'To decrypt older messages, Export E2EE room keys from Element (Settings > Security & Privacy > Encryption > Cryptography) and import them here. Imported keys are encrypted so you\'ll have to enter the password you set in order to decrypt it.'}</Text>
|
<>
|
||||||
<ImportE2ERoomKeys />
|
<Text variant="b3">{'To decrypt older messages, Export E2EE room keys from Element (Settings > Security & Privacy > Encryption > Cryptography) and import them here. Imported keys are encrypted so you\'ll have to enter the password you set in order to decrypt it.'}</Text>
|
||||||
</>
|
<ImportE2ERoomKeys />
|
||||||
)}
|
</>
|
||||||
/>
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AboutSection() {
|
function AboutSection() {
|
||||||
return (
|
return (
|
||||||
<div className="settings-content set__about">
|
<div className="settings-about">
|
||||||
<div className="set-about__branding">
|
<div className="settings-about__card">
|
||||||
<img width="60" height="60" src={CinnySVG} alt="Cinny logo" />
|
<MenuHeader>Application</MenuHeader>
|
||||||
<div>
|
<div className="settings-about__branding">
|
||||||
<Text variant="h2" weight='medium'>
|
<img width="60" height="60" src={CinnySVG} alt="Cinny logo" />
|
||||||
Cinny
|
<div>
|
||||||
<span className="text text-b3" style={{ margin: '0 var(--sp-extra-tight)' }}>{`v${cons.version}`}</span>
|
<Text variant="h2" weight="medium">
|
||||||
</Text>
|
Cinny
|
||||||
<Text>Yet another matrix client</Text>
|
<span className="text text-b3" style={{ margin: '0 var(--sp-extra-tight)' }}>{`v${cons.version}`}</span>
|
||||||
|
</Text>
|
||||||
|
<Text>Yet another matrix client</Text>
|
||||||
|
|
||||||
<div className="set-about__btns">
|
<div className="settings-about__btns">
|
||||||
<Button onClick={() => window.open('https://github.com/ajbura/cinny')}>Source code</Button>
|
<Button onClick={() => window.open('https://github.com/ajbura/cinny')}>Source code</Button>
|
||||||
<Button onClick={() => window.open('https://cinny.in/#sponsor')}>Support</Button>
|
<Button onClick={() => window.open('https://cinny.in/#sponsor')}>Support</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="set-about__credits">
|
<div className="settings-about__card">
|
||||||
<Text variant="s1" weight="medium">Credits</Text>
|
<MenuHeader>Credits</MenuHeader>
|
||||||
<ul>
|
<div className="settings-about__credits">
|
||||||
<li>
|
<ul>
|
||||||
{/* eslint-disable-next-line react/jsx-one-expression-per-line */ }
|
<li>
|
||||||
<Text>The <a href="https://github.com/matrix-org/matrix-js-sdk" rel="noreferrer noopener" target="_blank">matrix-js-sdk</a> is © <a href="https://matrix.org/foundation" rel="noreferrer noopener" target="_blank">The Matrix.org Foundation C.I.C</a> used under the terms of <a href="http://www.apache.org/licenses/LICENSE-2.0" rel="noreferrer noopener" target="_blank">Apache 2.0</a>.</Text>
|
{/* eslint-disable-next-line react/jsx-one-expression-per-line */ }
|
||||||
</li>
|
<Text>The <a href="https://github.com/matrix-org/matrix-js-sdk" rel="noreferrer noopener" target="_blank">matrix-js-sdk</a> is © <a href="https://matrix.org/foundation" rel="noreferrer noopener" target="_blank">The Matrix.org Foundation C.I.C</a> used under the terms of <a href="http://www.apache.org/licenses/LICENSE-2.0" rel="noreferrer noopener" target="_blank">Apache 2.0</a>.</Text>
|
||||||
<li>
|
</li>
|
||||||
{/* eslint-disable-next-line react/jsx-one-expression-per-line */ }
|
<li>
|
||||||
<Text>The <a href="https://twemoji.twitter.com" target="_blank" rel="noreferrer noopener">Twemoji</a> emoji art is © <a href="https://twemoji.twitter.com" target="_blank" rel="noreferrer noopener">Twitter, Inc and other contributors</a> used under the terms of <a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noreferrer noopener">CC-BY 4.0</a>.</Text>
|
{/* eslint-disable-next-line react/jsx-one-expression-per-line */ }
|
||||||
</li>
|
<Text>The <a href="https://twemoji.twitter.com" target="_blank" rel="noreferrer noopener">Twemoji</a> emoji art is © <a href="https://twemoji.twitter.com" target="_blank" rel="noreferrer noopener">Twitter, Inc and other contributors</a> used under the terms of <a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noreferrer noopener">CC-BY 4.0</a>.</Text>
|
||||||
</ul>
|
</li>
|
||||||
|
<li>
|
||||||
|
{/* eslint-disable-next-line react/jsx-one-expression-per-line */ }
|
||||||
|
<Text>The <a href="https://material.io/design/sound/sound-resources.html" target="_blank" rel="noreferrer noopener">Material sound resources</a> are © <a href="https://google.com" target="_blank" rel="noreferrer noopener">Google</a> used under the terms of <a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noreferrer noopener">CC-BY 4.0</a>.</Text>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Settings({ isOpen, onRequestClose }) {
|
export const tabText = {
|
||||||
const settingSections = [{
|
APPEARANCE: 'Appearance',
|
||||||
name: 'General',
|
NOTIFICATIONS: 'Notifications',
|
||||||
iconSrc: SettingsIC,
|
SECURITY: 'Security',
|
||||||
render() {
|
ABOUT: 'About',
|
||||||
return <GeneralSection />;
|
};
|
||||||
},
|
const tabItems = [{
|
||||||
}, {
|
text: tabText.APPEARANCE,
|
||||||
name: 'Appearance',
|
iconSrc: SunIC,
|
||||||
iconSrc: SunIC,
|
disabled: false,
|
||||||
render() {
|
render: () => <AppearanceSection />,
|
||||||
return <AppearanceSection />;
|
}, {
|
||||||
},
|
text: tabText.NOTIFICATIONS,
|
||||||
}, {
|
iconSrc: BellIC,
|
||||||
name: 'Notifications',
|
disabled: false,
|
||||||
iconSrc: BellIC,
|
render: () => <NotificationsSection />,
|
||||||
render() {
|
}, {
|
||||||
return <NotificationsSection />;
|
text: tabText.SECURITY,
|
||||||
},
|
iconSrc: LockIC,
|
||||||
}, {
|
disabled: false,
|
||||||
name: 'Security & Privacy',
|
render: () => <SecuritySection />,
|
||||||
iconSrc: LockIC,
|
}, {
|
||||||
render() {
|
text: tabText.ABOUT,
|
||||||
return <SecuritySection />;
|
iconSrc: InfoIC,
|
||||||
},
|
disabled: false,
|
||||||
}, {
|
render: () => <AboutSection />,
|
||||||
name: 'Help & About',
|
}];
|
||||||
iconSrc: InfoIC,
|
|
||||||
render() {
|
|
||||||
return <AboutSection />;
|
|
||||||
},
|
|
||||||
}];
|
|
||||||
const [selectedSection, setSelectedSection] = useState(settingSections[0]);
|
|
||||||
|
|
||||||
const handleLogout = () => {
|
function useWindowToggle(setSelectedTab) {
|
||||||
if (confirm('Confirm logout')) logout();
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const openSettings = (tab) => {
|
||||||
|
const tabItem = tabItems.find((item) => item.text === tab);
|
||||||
|
if (tabItem) setSelectedTab(tabItem);
|
||||||
|
setIsOpen(true);
|
||||||
|
};
|
||||||
|
navigation.on(cons.events.navigation.SETTINGS_OPENED, openSettings);
|
||||||
|
return () => {
|
||||||
|
navigation.removeListener(cons.events.navigation.SETTINGS_OPENED, openSettings);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const requestClose = () => setIsOpen(false);
|
||||||
|
|
||||||
|
return [isOpen, requestClose];
|
||||||
|
}
|
||||||
|
|
||||||
|
function Settings() {
|
||||||
|
const [selectedTab, setSelectedTab] = useState(tabItems[0]);
|
||||||
|
const [isOpen, requestClose] = useWindowToggle(setSelectedTab);
|
||||||
|
|
||||||
|
const handleTabChange = (tabItem) => setSelectedTab(tabItem);
|
||||||
|
const handleLogout = async () => {
|
||||||
|
if (await confirmDialog('Logout', 'Are you sure that you want to logout your session?', 'Logout', 'danger')) {
|
||||||
|
logout();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PopupWindow
|
<PopupWindow
|
||||||
className="settings-window"
|
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onRequestClose={onRequestClose}
|
className="settings-window"
|
||||||
title="Settings"
|
title={<Text variant="s1" weight="medium" primary>Settings</Text>}
|
||||||
contentTitle={selectedSection.name}
|
contentOptions={(
|
||||||
drawer={(
|
|
||||||
<>
|
<>
|
||||||
{
|
<Button variant="danger" iconSrc={PowerIC} onClick={handleLogout}>
|
||||||
settingSections.map((section) => (
|
|
||||||
<PWContentSelector
|
|
||||||
key={section.name}
|
|
||||||
selected={selectedSection.name === section.name}
|
|
||||||
onClick={() => setSelectedSection(section)}
|
|
||||||
iconSrc={section.iconSrc}
|
|
||||||
>
|
|
||||||
{section.name}
|
|
||||||
</PWContentSelector>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
<PWContentSelector
|
|
||||||
variant="danger"
|
|
||||||
onClick={handleLogout}
|
|
||||||
iconSrc={PowerIC}
|
|
||||||
>
|
|
||||||
Logout
|
Logout
|
||||||
</PWContentSelector>
|
</Button>
|
||||||
|
<IconButton src={CrossIC} onClick={requestClose} tooltip="Close" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
contentOptions={<IconButton src={CrossIC} onClick={onRequestClose} tooltip="Close" />}
|
onRequestClose={requestClose}
|
||||||
>
|
>
|
||||||
{selectedSection.render()}
|
{isOpen && (
|
||||||
|
<div className="settings-window__content">
|
||||||
|
<ProfileEditor userId={initMatrix.matrixClient.getUserId()} />
|
||||||
|
<Tabs
|
||||||
|
items={tabItems}
|
||||||
|
defaultSelected={tabItems.findIndex((tab) => tab.text === selectedTab.text)}
|
||||||
|
onSelect={handleTabChange}
|
||||||
|
/>
|
||||||
|
<div className="settings-window__cards-wrapper">
|
||||||
|
{ selectedTab.render() }
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</PopupWindow>
|
</PopupWindow>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Settings.propTypes = {
|
|
||||||
isOpen: PropTypes.bool.isRequired,
|
|
||||||
onRequestClose: PropTypes.func.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Settings;
|
export default Settings;
|
||||||
|
|||||||
@@ -1,60 +1,90 @@
|
|||||||
@use '../../partials/flex';
|
@use '../../partials/flex';
|
||||||
@use '../../partials/dir';
|
@use '../../partials/dir';
|
||||||
|
@use '../../partials/screen';
|
||||||
|
|
||||||
.settings-window {
|
.settings-window {
|
||||||
& .pw__drawer__content {
|
& .pw {
|
||||||
@extend .cp-fx__column;
|
background-color: var(--bg-surface-low);
|
||||||
min-height: 100%;
|
}
|
||||||
padding-bottom: var(--sp-extra-tight);
|
|
||||||
|
.header .btn-danger {
|
||||||
|
margin: 0 var(--sp-tight);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
& > .pw-content-selector:last-child {
|
& .profile-editor {
|
||||||
margin-top: auto;
|
padding: var(--sp-loose) var(--sp-extra-loose);
|
||||||
|
}
|
||||||
|
|
||||||
|
& .tabs__content {
|
||||||
|
padding: 0 var(--sp-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__cards-wrapper {
|
||||||
|
padding: 0 var(--sp-normal);
|
||||||
|
@include dir.side(padding, var(--sp-normal), var(--sp-extra-tight));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.settings-window__card {
|
||||||
|
margin: var(--sp-normal) 0;
|
||||||
|
background-color: var(--bg-surface);
|
||||||
|
border-radius: var(--bo-radius);
|
||||||
|
box-shadow: var(--bs-surface-border);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
& > .context-menu__header:first-child {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.settings-appearance__card,
|
||||||
|
.settings-notifications,
|
||||||
|
.settings-security__card,
|
||||||
|
.settings-security .device-manage,
|
||||||
|
.settings-about__card {
|
||||||
|
@extend .settings-window__card;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-window__cards-wrapper{
|
||||||
|
& .setting-tile {
|
||||||
|
margin: 0 var(--sp-normal);
|
||||||
|
margin-top: var(--sp-normal);
|
||||||
|
padding-bottom: 16px;
|
||||||
|
border-bottom: 1px solid var(--bg-surface-border);
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
& .pw__content-container {
|
|
||||||
min-height: 100%;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-content {
|
.settings-notifications {
|
||||||
@include dir.side(margin, var(--sp-normal), var(--sp-extra-tight));
|
|
||||||
|
|
||||||
& .setting-tile {
|
|
||||||
margin-top: var(--sp-normal);
|
|
||||||
border-bottom: 1px solid var(--bg-surface-border);
|
|
||||||
padding-bottom: 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.set-notifications {
|
|
||||||
&__not-supported {
|
&__not-supported {
|
||||||
padding: 0 var(--sp-ultra-tight);
|
padding: 0 var(--sp-ultra-tight);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.set-about {
|
.settings-about {
|
||||||
&__branding {
|
&__branding {
|
||||||
margin-top: var(--sp-extra-tight);
|
padding: var(--sp-normal);
|
||||||
margin-bottom: var(--sp-normal);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
||||||
& > div {
|
& > div {
|
||||||
margin: 0 var(--sp-loose);
|
margin: 0 var(--sp-loose);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
&__btns {
|
&__btns {
|
||||||
margin: 0;
|
& button {
|
||||||
margin-top: var(--sp-normal);
|
margin-top: var(--sp-tight);
|
||||||
& button:last-child {
|
@include dir.side(margin, 0, var(--sp-tight));
|
||||||
margin: 0 var(--sp-tight)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__credits {
|
&__credits {
|
||||||
margin-top: var(--sp-loose);
|
padding: 0 var(--sp-normal);
|
||||||
& ul {
|
& ul {
|
||||||
|
color: var(--tc-surface-low);
|
||||||
|
padding: var(--sp-normal);
|
||||||
margin: var(--sp-extra-tight) 0;
|
margin: var(--sp-extra-tight) 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import cons from '../../../client/state/cons';
|
|||||||
import navigation from '../../../client/state/navigation';
|
import navigation from '../../../client/state/navigation';
|
||||||
import { createSpaceShortcut, deleteSpaceShortcut } from '../../../client/action/accountData';
|
import { createSpaceShortcut, deleteSpaceShortcut } from '../../../client/action/accountData';
|
||||||
import { joinRuleToIconSrc } from '../../../util/matrixUtil';
|
import { joinRuleToIconSrc } from '../../../util/matrixUtil';
|
||||||
|
import { roomIdByAtoZ } from '../../../util/sort';
|
||||||
|
|
||||||
import Text from '../../atoms/text/Text';
|
import Text from '../../atoms/text/Text';
|
||||||
import Button from '../../atoms/button/Button';
|
import Button from '../../atoms/button/Button';
|
||||||
@@ -20,7 +21,6 @@ import PinFilledIC from '../../../../public/res/ic/filled/pin.svg';
|
|||||||
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
|
||||||
|
|
||||||
import { useSpaceShortcut } from '../../hooks/useSpaceShortcut';
|
import { useSpaceShortcut } from '../../hooks/useSpaceShortcut';
|
||||||
import { AtoZ } from '../navigation/common';
|
|
||||||
|
|
||||||
function ShortcutSpacesContent() {
|
function ShortcutSpacesContent() {
|
||||||
const mx = initMatrix.matrixClient;
|
const mx = initMatrix.matrixClient;
|
||||||
@@ -29,7 +29,7 @@ function ShortcutSpacesContent() {
|
|||||||
const [spaceShortcut] = useSpaceShortcut();
|
const [spaceShortcut] = useSpaceShortcut();
|
||||||
const spaceWithoutShortcut = [...spaces].filter(
|
const spaceWithoutShortcut = [...spaces].filter(
|
||||||
(spaceId) => !spaceShortcut.includes(spaceId),
|
(spaceId) => !spaceShortcut.includes(spaceId),
|
||||||
).sort(AtoZ);
|
).sort(roomIdByAtoZ);
|
||||||
|
|
||||||
const [process, setProcess] = useState(null);
|
const [process, setProcess] = useState(null);
|
||||||
const [selected, setSelected] = useState([]);
|
const [selected, setSelected] = useState([]);
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import PinFilledIC from '../../../../public/res/ic/filled/pin.svg';
|
|||||||
import CategoryIC from '../../../../public/res/ic/outlined/category.svg';
|
import CategoryIC from '../../../../public/res/ic/outlined/category.svg';
|
||||||
import CategoryFilledIC from '../../../../public/res/ic/filled/category.svg';
|
import CategoryFilledIC from '../../../../public/res/ic/filled/category.svg';
|
||||||
|
|
||||||
|
import { confirmDialog } from '../../molecules/confirm-dialog/ConfirmDialog';
|
||||||
import { useForceUpdate } from '../../hooks/useForceUpdate';
|
import { useForceUpdate } from '../../hooks/useForceUpdate';
|
||||||
|
|
||||||
const tabText = {
|
const tabText = {
|
||||||
@@ -61,6 +62,7 @@ const tabItems = [{
|
|||||||
function GeneralSettings({ roomId }) {
|
function GeneralSettings({ roomId }) {
|
||||||
const isPinned = initMatrix.accountData.spaceShortcut.has(roomId);
|
const isPinned = initMatrix.accountData.spaceShortcut.has(roomId);
|
||||||
const isCategorized = initMatrix.accountData.categorizedSpaces.has(roomId);
|
const isCategorized = initMatrix.accountData.categorizedSpaces.has(roomId);
|
||||||
|
const roomName = initMatrix.matrixClient.getRoom(roomId)?.name;
|
||||||
const [, forceUpdate] = useForceUpdate();
|
const [, forceUpdate] = useForceUpdate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -89,10 +91,14 @@ function GeneralSettings({ roomId }) {
|
|||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
variant="danger"
|
variant="danger"
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
if (confirm('Are you really want to leave this space?')) {
|
const isConfirmed = await confirmDialog(
|
||||||
leave(roomId);
|
'Leave space',
|
||||||
}
|
`Are you sure that you want to leave "${roomName}" space?`,
|
||||||
|
'Leave',
|
||||||
|
'danger',
|
||||||
|
);
|
||||||
|
if (isConfirmed) leave(roomId);
|
||||||
}}
|
}}
|
||||||
iconSrc={LeaveArrowIC}
|
iconSrc={LeaveArrowIC}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -7,14 +7,10 @@
|
|||||||
|
|
||||||
& .room-profile {
|
& .room-profile {
|
||||||
padding: var(--sp-loose) var(--sp-extra-loose);
|
padding: var(--sp-loose) var(--sp-extra-loose);
|
||||||
}
|
}
|
||||||
|
|
||||||
& .tabs {
|
& .tabs__content {
|
||||||
box-shadow: inset 0 -1px 0 var(--bg-surface-border);
|
padding: 0 var(--sp-normal);
|
||||||
|
|
||||||
&__content {
|
|
||||||
padding: 0 var(--sp-normal);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__cards-wrapper {
|
&__cards-wrapper {
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import './Client.scss';
|
import './Client.scss';
|
||||||
|
|
||||||
|
import { initHotkeys } from '../../../client/event/hotkeys';
|
||||||
|
import { initRoomListListener } from '../../../client/event/roomList';
|
||||||
|
|
||||||
import Text from '../../atoms/text/Text';
|
import Text from '../../atoms/text/Text';
|
||||||
import Spinner from '../../atoms/spinner/Spinner';
|
import Spinner from '../../atoms/spinner/Spinner';
|
||||||
import Navigation from '../../organisms/navigation/Navigation';
|
import Navigation from '../../organisms/navigation/Navigation';
|
||||||
@@ -20,6 +23,29 @@ function Client() {
|
|||||||
const [isLoading, changeLoading] = useState(true);
|
const [isLoading, changeLoading] = useState(true);
|
||||||
const [loadingMsg, setLoadingMsg] = useState('Heating up');
|
const [loadingMsg, setLoadingMsg] = useState('Heating up');
|
||||||
const [dragCounter, setDragCounter] = useState(0);
|
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(() => {
|
useEffect(() => {
|
||||||
let counter = 0;
|
let counter = 0;
|
||||||
@@ -38,6 +64,8 @@ function Client() {
|
|||||||
}, 15000);
|
}, 15000);
|
||||||
initMatrix.once('init_loading_finished', () => {
|
initMatrix.once('init_loading_finished', () => {
|
||||||
clearInterval(iId);
|
clearInterval(iId);
|
||||||
|
initHotkeys();
|
||||||
|
initRoomListListener(initMatrix.roomList);
|
||||||
changeLoading(false);
|
changeLoading(false);
|
||||||
});
|
});
|
||||||
initMatrix.init();
|
initMatrix.init();
|
||||||
@@ -123,10 +151,10 @@ function Client() {
|
|||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
>
|
>
|
||||||
<div className="navigation__wrapper">
|
<div className="navigation__wrapper" ref={navWrapperRef}>
|
||||||
<Navigation />
|
<Navigation />
|
||||||
</div>
|
</div>
|
||||||
<div className="room__wrapper">
|
<div className={`room__wrapper ${classNameHidden}`} ref={roomWrapperRef}>
|
||||||
<Room />
|
<Room />
|
||||||
</div>
|
</div>
|
||||||
<Windows />
|
<Windows />
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user