Compare commits

..
Author SHA1 Message Date
Ajay Bura 5fbd91b694 Change jump to unread icon
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2022-03-17 15:16:05 +05:30
Ajay Bura 3bab294076 Add message icons
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2022-03-17 15:15:38 +05:30
Ajay Bura 4ccc1d544e Add esc as hotkey to mark room as read
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2022-03-17 14:48:24 +05:30
Ajay Bura 7fb79f6ea6 Add notification mark as read action
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2022-03-17 13:11:14 +05:30
Ajay Bura 4d908859f0 Fix mark as read not hidding jump to unread btn
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2022-03-17 12:30:42 +05:30
Ajay Bura cc55d30544 Remove unused cod
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2022-03-17 11:51:14 +05:30
Ajay Bura 9c716b1eeb Improve jump to unread button
Signed-off-by: Ajay Bura <ajbura@gmail.com>
2022-03-17 11:50:25 +05:30
133 changed files with 2557 additions and 5170 deletions

No files matched your search

+33
View File
@@ -0,0 +1,33 @@
---
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
View File
@@ -1,57 +0,0 @@
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
View File
@@ -1,4 +0,0 @@
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
View File
@@ -0,0 +1,20 @@
---
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.
@@ -1,33 +0,0 @@
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.
+4 -2
View File
@@ -1,13 +1,15 @@
<!-- Please read https://github.com/ajbura/cinny/CONTRIBUTING.md before submitting your pull request -->
### 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 #
Fixes # (issue)
#### Type of change
Please delete options that are not relevant.
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
+11 -13
View File
@@ -1,30 +1,28 @@
# Docs: <https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically/customizing-dependency-updates>
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
day: "tuesday"
time: "01:00"
timezone: "Asia/Kolkata"
open-pull-requests-limit: 15
updates:
- package-ecosystem: github-actions
directory: /
schedule:
schedule:
interval: weekly
day: "tuesday"
time: "01:00"
timezone: "Asia/Kolkata"
open-pull-requests-limit: 5
- package-ecosystem: docker
directory: /
schedule:
schedule:
interval: weekly
day: "tuesday"
time: "01:00"
timezone: "Asia/Kolkata"
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
day: "tuesday"
time: "01:00"
timezone: "Asia/Kolkata"
open-pull-requests-limit: 5
+8 -3
View File
@@ -10,8 +10,8 @@ jobs:
env:
PR_NUMBER: ${{github.event.number}}
steps:
- name: Checkout repository
uses: actions/checkout@v3.0.2
- name: Check out the repo
uses: actions/checkout@v3.0.0
- name: Build app
run: npm ci && npm run build
- name: Upload artifact
@@ -21,7 +21,7 @@ jobs:
path: dist
retention-days: 1
- name: Get PR info
uses: actions/github-script@v6.1.0
uses: actions/github-script@v6.0.0
with:
script: |
var fs = require('fs');
@@ -32,3 +32,8 @@ jobs:
name: pr.json
path: pr.json
retention-days: 1
- name: Build Docker image
uses: docker/build-push-action@v2.10.0
with:
context: .
push: false
+6 -6
View File
@@ -13,8 +13,8 @@ jobs:
# 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)
# so instead we get this mess:
- name: Download artifact
uses: actions/github-script@v6.1.0
- name: 'Download artifact'
uses: actions/github-script@v6.0.0
with:
script: |
var artifacts = await github.rest.actions.listWorkflowRunArtifacts({
@@ -46,9 +46,9 @@ jobs:
fs.writeFileSync('${{github.workspace}}/pr.json.zip', Buffer.from(download.data));
- name: Extract Artifacts
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
uses: actions/github-script@v6.1.0
uses: actions/github-script@v6.0.0
with:
script: |
var fs = require('fs');
@@ -56,7 +56,7 @@ jobs:
console.log(`::set-output name=prnumber::${pr.number}`);
- name: Deploy to Netlify
id: netlify
uses: nwtgck/actions-netlify@b7c1504e00c6b8a249d1848cc1b522a4865eed99
uses: nwtgck/actions-netlify@v1.2.3
with:
publish-dir: dist
deploy-message: "Deploy from GitHub Actions"
@@ -68,7 +68,7 @@ jobs:
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE3_ID }}
timeout-minutes: 1
- name: Edit PR Description
uses: Beakyn/gha-comment-pull-request@2167a7aee24f9e61ce76a23039f322e49a990409
uses: velas/pr-description@v1.0.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
-21
View File
@@ -1,21 +0,0 @@
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@v3.0.0
with:
context: .
push: false
@@ -6,15 +6,13 @@ on:
- dev
jobs:
deploy-to-netlify:
deploy:
name: 'Deploy'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3.0.2
- name: Build and deploy to Netlify
uses: jsmrcaga/action-netlify-deploy@fb6a5f936a4b06a8f7793e69fc5a022ffe39807a
- uses: actions/checkout@v3.0.0
- uses: jsmrcaga/action-netlify-deploy@v1.7.2
with:
install_command: "npm ci"
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
@@ -9,10 +9,10 @@ jobs:
name: 'Deploy to Netlify'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3.0.2
- name: Check out the repo
uses: actions/checkout@v3.0.0
- name: Build and deploy to Netlify
uses: jsmrcaga/action-netlify-deploy@fb6a5f936a4b06a8f7793e69fc5a022ffe39807a
uses: jsmrcaga/action-netlify-deploy@v1.7.2
with:
install_command: "npm ci"
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
@@ -25,38 +25,30 @@ jobs:
run: echo ::set-output name=tag::${GITHUB_REF#refs/*/}
- name: Create tar.gz
run: tar -czvf cinny-${{ steps.vars.outputs.tag }}.tar.gz dist
- name: Sign tar.gz
uses: actionhippie/gpgsign@4e28208b142cae93e1582401dcda1cf79e4f72c0
with:
private_key: ${{ secrets.GNUPG_KEY }}
passphrase: ${{ secrets.GNUPG_PASSPHRASE }}
detach_sign: true
files: cinny-${{ steps.vars.outputs.tag }}.tar.gz
- name: Upload tagged release
uses: softprops/action-gh-release@1e07f4398721186383de40550babbdf2b84acfc5
uses: softprops/action-gh-release@v1
with:
files: |
cinny-${{ steps.vars.outputs.tag }}.tar.gz
cinny-${{ steps.vars.outputs.tag }}.tar.gz.asc
push_to_dockerhub:
name: Push Docker image to Docker Hub
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3.0.2
- name: Check out the repo
uses: actions/checkout@v3.0.0
- name: Login to Docker Hub
uses: docker/login-action@v2.0.0
uses: docker/login-action@v1.14.1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v4.0.1
uses: docker/metadata-action@v3.6.2
with:
images: ajbura/cinny
- name: Build and push Docker image
uses: docker/build-push-action@v3.0.0
uses: docker/build-push-action@v2.10.0
with:
context: .
push: true
+1 -1
View File
@@ -1,5 +1,5 @@
## Builder
FROM node:17.9.0-alpine3.15 as builder
FROM node:17.7.1-alpine3.15 as builder
WORKDIR /src
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2021 Ajay Bura (ajbura)
Copyright (c) 2021 Ajay Bura (ajbura) and other contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+2 -9
View File
@@ -1,10 +1,5 @@
# Cinny
[![Star](https://img.shields.io/github/stars/ajbura/cinny)](https://github.com/ajbura/cinny/tree/dev)
[![Chat](https://img.shields.io/badge/chat-on%20matrix-orange)](https://matrix.to/#/#cinny:matrix.org)
[![Twitter](https://img.shields.io/twitter/url?url=https://twitter.com/@cinnyapp)](https://twitter.com/@cinnyapp)
[![Support](https://img.shields.io/badge/sponsor-open%20collective-blue.svg)](https://opencollective.com/cinny)
## Table of Contents
- [About](#about)
@@ -16,7 +11,7 @@
Cinny is a [Matrix](https://matrix.org) client focusing primarily on simple, elegant and secure interface.
![preview](https://github.com/cinnyapp/cinny-site/blob/main/assets/preview-light.png)
![preview](https://github.com/ajbura/cinny-site/blob/master/assets/preview-light.png)
## Building and Running
@@ -26,8 +21,6 @@ A tarball of pre-compiled version of the app is provided with each [release](htt
You can serve the application with a webserver of your choosing by simply copying `dist/` directory to the webroot.
### Building from source
> We recommend using a version manager as versions change very quickly. You will likely need to switch
between multiple Node.js versions based on the needs of different projects you're working on. [NVM on windows](https://github.com/coreybutler/nvm-windows#installation--upgrades) on Windows and [nvm](https://github.com/nvm-sh/nvm) on Linux/macOS are pretty good choices. Also recommended nodejs version is 16.15.0 LTS.
Execute the following commands to compile the app from its source code:
@@ -66,7 +59,7 @@ To set default Homeserver on login and register page, place a customized [`confi
## License
Copyright (c) 2021 Ajay Bura (ajbura)
Copyright (c) 2021 Ajay Bura (ajbura) and contributors
Code licensed under the MIT License: <http://opensource.org/licenses/MIT>
+1 -2
View File
@@ -7,6 +7,5 @@
"kde.org",
"matrix.org",
"chat.mozilla.org"
],
"allowCustomHomeservers": true
]
}
+1680 -1464
View File
File diff suppressed because it is too large. Load diff
+21 -23
View File
@@ -1,11 +1,11 @@
{
"name": "cinny",
"version": "2.0.3",
"version": "1.8.1",
"description": "Yet another matrix client",
"main": "index.js",
"engines": {
"npm": ">=6.14.8 <8.5.5",
"node": ">=14.15.0 <17.9.0"
"npm": ">=6.14.11",
"node": ">=14.6.0"
},
"scripts": {
"start": "webpack serve --config ./webpack.dev.js --open",
@@ -15,8 +15,8 @@
"author": "Ajay Bura",
"license": "MIT",
"dependencies": {
"@fontsource/inter": "^4.5.10",
"@fontsource/roboto": "^4.5.7",
"@fontsource/inter": "^4.5.5",
"@fontsource/roboto": "^4.5.3",
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.8.tgz",
"@tippyjs/react": "^4.2.6",
"babel-polyfill": "^6.26.0",
@@ -26,34 +26,32 @@
"file-saver": "^2.0.5",
"flux": "^4.0.3",
"formik": "^2.2.9",
"html-react-parser": "^1.4.12",
"katex": "^0.15.3",
"html-react-parser": "^1.4.8",
"linkifyjs": "^2.1.9",
"matrix-js-sdk": "^17.2.0",
"matrix-js-sdk": "^15.6.0",
"micromark": "^3.0.10",
"micromark-extension-gfm": "^2.0.1",
"micromark-extension-math": "^2.0.2",
"micromark-util-chunked": "^1.0.0",
"micromark-util-resolve-all": "^1.0.0",
"micromark-util-symbol": "^1.0.1",
"prop-types": "^15.8.1",
"react": "^17.0.2",
"react-autosize-textarea": "^7.1.0",
"react-dnd": "^15.1.2",
"react-dnd-html5-backend": "^15.1.3",
"react-dnd": "^15.1.1",
"react-dnd-html5-backend": "^15.1.2",
"react-dom": "^17.0.2",
"react-google-recaptcha": "^2.1.0",
"react-modal": "^3.15.1",
"react-modal": "^3.14.4",
"sanitize-html": "^2.7.0",
"tippy.js": "^6.3.7",
"twemoji": "^14.0.2"
"twemoji": "^14.0.1"
},
"devDependencies": {
"@babel/core": "^7.18.0",
"@babel/preset-env": "^7.18.0",
"@babel/preset-react": "^7.17.12",
"@babel/core": "^7.17.7",
"@babel/preset-env": "^7.16.11",
"@babel/preset-react": "^7.16.7",
"assert": "^2.0.0",
"babel-loader": "^8.2.5",
"babel-loader": "^8.2.2",
"browserify-fs": "^1.0.0",
"buffer": "^6.0.3",
"clean-webpack-plugin": "^4.0.0",
@@ -61,27 +59,27 @@
"crypto-browserify": "^3.12.0",
"css-loader": "^6.7.1",
"css-minimizer-webpack-plugin": "^3.4.1",
"eslint": "^8.15.0",
"eslint": "^8.11.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-jsx-a11y": "^6.4.1",
"eslint-plugin-react": "^7.29.4",
"eslint-plugin-react-hooks": "^4.5.0",
"eslint-plugin-react-hooks": "^4.2.0",
"favicons": "^6.2.2",
"favicons-webpack-plugin": "^5.0.2",
"html-loader": "^3.1.0",
"html-webpack-plugin": "^5.3.1",
"mini-css-extract-plugin": "^2.6.0",
"path-browserify": "^1.0.1",
"sass": "^1.51.0",
"sass": "^1.49.9",
"sass-loader": "^12.6.0",
"stream-browserify": "^3.0.0",
"style-loader": "^3.3.1",
"url": "^0.11.0",
"util": "^0.12.4",
"webpack": "^5.72.1",
"webpack": "^5.70.0",
"webpack-cli": "^4.9.2",
"webpack-dev-server": "^4.9.0",
"webpack-dev-server": "^4.7.4",
"webpack-merge": "^5.7.3"
}
}
-6
View File
@@ -18,11 +18,5 @@
</head>
<body id="appBody">
<div id="root"></div>
<audio id="notificationSound">
<source src="./sound/notification.ogg" type="audio/ogg" />
</audio>
<audio id="inviteSound">
<source src="./sound/invite.ogg" type="audio/ogg" />
</audio>
</body>
</html>
+6 -7
View File
@@ -4,14 +4,13 @@
<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>
<polygon points="11,7 11,13 14.5,16.5 15.9,15.1 13,12.2 13,7 "/>
<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
c0,5.5,4.5,10,10,10s10-4.5,10-10S17.5,2,12,2z"/>
<polygon fill="#010101" points="11,6 11,12 15.2,16.2 16.7,14.8 13,11.2 13,6 "/>
<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
l-1.2,1.6C7.4,21.2,9.6,22,12,22c5.5,0,10-4.5,10-10S17.5,2,12,2z"/>
</g>
<g>
<polygon points="49,44 49,50 53.2,54.2 54.7,52.8 51,49.2 51,44 "/>
<polygon points="45.5,47 40.5,47 40.5,42 42,42 42,45.5 45.5,45.5 "/>
<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"/>
<polygon fill="#010101" 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
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"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 985 B

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.
Binary file not shown.
+1 -4
View File
@@ -11,12 +11,11 @@ const IconButton = React.forwardRef(({
variant, size, type,
tooltip, tooltipPlacement, src,
onClick, tabIndex, disabled, isImage,
className,
}, ref) => {
const btn = (
<button
ref={ref}
className={`ic-btn ic-btn-${variant} ${className}`}
className={`ic-btn ic-btn-${variant}`}
onMouseUp={(e) => blurOnBubbling(e, `.ic-btn-${variant}`)}
onClick={onClick}
// eslint-disable-next-line react/button-has-type
@@ -48,7 +47,6 @@ IconButton.defaultProps = {
tabIndex: 0,
disabled: false,
isImage: false,
className: '',
};
IconButton.propTypes = {
@@ -62,7 +60,6 @@ IconButton.propTypes = {
tabIndex: PropTypes.number,
disabled: PropTypes.bool,
isImage: PropTypes.bool,
className: PropTypes.string,
};
export default IconButton;
-59
View File
@@ -1,59 +0,0 @@
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
View File
@@ -1,79 +0,0 @@
@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
View File
@@ -1,33 +0,0 @@
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;
+2 -2
View File
@@ -74,7 +74,7 @@ Tabs.defaultProps = {
Tabs.propTypes = {
items: PropTypes.arrayOf(
PropTypes.shape({
PropTypes.exact({
iconSrc: PropTypes.string,
text: PropTypes.string,
disabled: PropTypes.bool,
@@ -84,4 +84,4 @@ Tabs.propTypes = {
onSelect: PropTypes.func.isRequired,
};
export default Tabs;
export { Tabs as default };
-25
View File
@@ -1,25 +0,0 @@
/* 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
View File
@@ -1,32 +0,0 @@
/* 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;
}
@@ -1,58 +0,0 @@
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);
},
);
});
@@ -1,11 +0,0 @@
.confirm-dialog {
padding: var(--sp-normal);
& > .text {
padding-bottom: var(--sp-normal);
}
&__btn {
display: flex;
gap: var(--sp-normal);
}
}
+2 -5
View File
@@ -12,11 +12,10 @@ import RawModal from '../../atoms/modal/RawModal';
function Dialog({
className, isOpen, title, onAfterOpen, onAfterClose,
contentOptions, onRequestClose, closeFromOutside, children,
invisibleScroll,
}) {
return (
<RawModal
className={`${className === null ? '' : `${className} `}dialog-modal`}
className={`${className === null ? '' : `${className} `}dialog-model`}
isOpen={isOpen}
onAfterOpen={onAfterOpen}
onAfterClose={onAfterClose}
@@ -37,7 +36,7 @@ function Dialog({
{contentOptions}
</Header>
<div className="dialog__content__wrapper">
<ScrollView autoHide={!invisibleScroll} invisible={invisibleScroll}>
<ScrollView autoHide>
<div className="dialog__content-container">
{children}
</div>
@@ -56,7 +55,6 @@ Dialog.defaultProps = {
onAfterClose: null,
onRequestClose: null,
closeFromOutside: true,
invisibleScroll: false,
};
Dialog.propTypes = {
@@ -69,7 +67,6 @@ Dialog.propTypes = {
onRequestClose: PropTypes.func,
closeFromOutside: PropTypes.bool,
children: PropTypes.node.isRequired,
invisibleScroll: PropTypes.bool,
};
export default Dialog;
+6 -1
View File
@@ -1,4 +1,4 @@
.dialog-modal {
.dialog-model {
--modal-height: 656px;
max-height: min(100%, var(--modal-height));
display: flex;
@@ -21,3 +21,8 @@
flex-direction: column;
}
}
.dialog__content-container {
padding-top: var(--sp-extra-tight);
padding-bottom: var(--sp-extra-loose);
}
@@ -1,49 +0,0 @@
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;
+9 -33
View File
@@ -36,8 +36,6 @@ import TickMarkIC from '../../../../public/res/ic/outlined/tick-mark.svg';
import CmdIC from '../../../../public/res/ic/outlined/cmd.svg';
import BinIC from '../../../../public/res/ic/outlined/bin.svg';
import { confirmDialog } from '../confirm-dialog/ConfirmDialog';
function PlaceholderMessage() {
return (
<div className="ph-msg">
@@ -123,26 +121,17 @@ const MessageReplyWrapper = React.memo(({ roomTimeline, eventId }) => {
const eTimeline = await mx.getEventTimeline(timelineSet, eventId);
await roomTimeline.decryptAllEventsOfTimeline(eTimeline);
let mEvent = eTimeline.getTimelineSet().findEventById(eventId);
const editedList = roomTimeline.editedTimeline.get(mEvent.getId());
if (editedList) {
mEvent = editedList[editedList.length - 1];
}
const mEvent = eTimeline.getTimelineSet().findEventById(eventId);
const rawBody = mEvent.getContent().body;
const username = getUsernameOfRoomMember(mEvent.sender);
if (isMountedRef.current === false) return;
const fallbackBody = mEvent.isRedacted() ? '*** This message has been deleted ***' : '*** Unable to load reply ***';
let parsedBody = parseReply(rawBody)?.body ?? rawBody ?? fallbackBody;
if (editedList && parsedBody.startsWith(' * ')) {
parsedBody = parsedBody.slice(3);
}
setReply({
to: username,
color: colorMXID(mEvent.getSender()),
body: parsedBody,
body: parseReply(rawBody)?.body ?? rawBody ?? fallbackBody,
event: mEvent,
});
} catch {
@@ -197,17 +186,9 @@ const MessageBody = React.memo(({
// if body is not string it is a React element.
if (typeof body !== 'string') return <div className="message__body">{body}</div>;
let content = null;
if (isCustomHTML) {
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);
}
let content = isCustomHTML
? twemojify(sanitizeCustomHtml(body), undefined, true, false)
: twemojify(body, undefined, true);
// Determine if this message should render with large emojis
// Criteria:
@@ -557,15 +538,10 @@ const MessageOptions = React.memo(({
<MenuItem
variant="danger"
iconSrc={BinIC}
onClick={async () => {
const isConfirmed = await confirmDialog(
'Delete message',
'Are you sure that you want to delete this message?',
'Delete',
'danger',
);
if (!isConfirmed) return;
redactEvent(roomId, mEvent.getId());
onClick={() => {
if (window.confirm('Are you sure you want to delete this event')) {
redactEvent(roomId, mEvent.getId());
}
}}
>
Delete
+1 -2
View File
@@ -1,7 +1,6 @@
@use '../../atoms/scroll/scrollbar';
@use '../../partials/text';
@use '../../partials/dir';
@use '../../partials/screen';
.message,
.ph-msg {
@@ -96,7 +95,7 @@
.message__reactions {
max-width: calc(100% - 88px);
min-width: 0;
@include screen.smallerThan(tabletBreakpoint) {
@media (max-width: 1124px) {
max-width: 100%;
}
}
@@ -58,8 +58,7 @@ function PopupWindow({
return (
<RawModal
className={`${className === null ? '' : `${className} `}pw-modal`}
overlayClassName="pw-modal__overlay"
className={`${className === null ? '' : `${className} `}pw-model`}
isOpen={isOpen}
onAfterClose={onAfterClose}
onRequestClose={onRequestClose}
@@ -1,18 +1,9 @@
@use '../../partials/dir';
@use '../../partials/screen';
.pw-modal {
--modal-height: 774px;
.pw-model {
--modal-height: 656px;
max-height: var(--modal-height) !important;
height: 100%;
@include screen.smallerThan(mobileBreakpoint) {
--modal-height: 100%;
border-radius: 0 !important;
&__overlay {
padding: 0 !important;
}
}
}
.pw {
@@ -81,4 +72,4 @@
@include dir.side(margin, 0, var(--sp-tight));
}
}
}
}
@@ -8,8 +8,6 @@ import Text from '../../atoms/text/Text';
import Toggle from '../../atoms/button/Toggle';
import SettingTile from '../setting-tile/SettingTile';
import { confirmDialog } from '../confirm-dialog/ConfirmDialog';
function RoomEncryption({ roomId }) {
const mx = initMatrix.matrixClient;
const room = mx.getRoom(roomId);
@@ -17,20 +15,17 @@ function RoomEncryption({ roomId }) {
const [isEncrypted, setIsEncrypted] = useState(encryptionEvents.length > 0);
const canEnableEncryption = room.currentState.maySendStateEvent('m.room.encryption', mx.getUserId());
const handleEncryptionEnable = async () => {
const handleEncryptionEnable = () => {
const joinRule = room.getJoinRule();
const confirmMsg1 = 'It is not recommended to add encryption in public room. Anyone can find and join public rooms, so anyone can read messages in them.';
const confirmMsg2 = 'Once enabled, encryption for a room cannot be disabled. Messages sent in an encrypted room cannot be seen by the server, only by the participants of the room. Enabling encryption may prevent many bots and bridges from working correctly';
const isConfirmed1 = (joinRule === 'public')
? await confirmDialog('Enable encryption', confirmMsg1, 'Continue', 'caution')
: true;
if (!isConfirmed1) return;
if (await confirmDialog('Enable encryption', confirmMsg2, 'Enable', 'caution')) {
setIsEncrypted(true);
mx.sendStateEvent(roomId, 'm.room.encryption', {
algorithm: 'm.megolm.v1.aes-sha2',
});
if (joinRule === 'public' ? confirm(confirmMsg1) : true) {
if (confirm(confirmMsg2)) {
setIsEncrypted(true);
mx.sendStateEvent(roomId, 'm.room.encryption', {
algorithm: 'm.megolm.v1.aes-sha2',
});
}
}
};
@@ -17,19 +17,19 @@ const visibility = {
const items = [{
iconSrc: null,
text: 'Anyone (including guests)',
text: 'World readable (anyone can read)',
type: visibility.WORLD_READABLE,
}, {
iconSrc: null,
text: 'Members (all messages)',
text: 'Member shared (since the point in time of selecting this option)',
type: visibility.SHARED,
}, {
iconSrc: null,
text: 'Members (messages after invite)',
text: 'Member invited (since they were invited)',
type: visibility.INVITED,
}, {
iconSrc: null,
text: 'Members (messages after join)',
text: 'Member joined (since they joined)',
type: visibility.JOINED,
}];
@@ -87,7 +87,7 @@ function RoomHistoryVisibility({ roomId }) {
</MenuItem>
))
}
<Text variant="b3">Changes to history visibility will only apply to future messages. The visibility of existing history will have no effect.</Text>
<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>
</div>
);
}
+6 -5
View File
@@ -2,6 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import './RoomIntro.scss';
import { twemojify } from '../../../util/twemojify';
import colorMXID from '../../../util/colorMXID';
import Text from '../../atoms/text/Text';
@@ -14,8 +15,8 @@ function RoomIntro({
<div className="room-intro">
<Avatar imageSrc={avatarSrc} text={name} bgColor={colorMXID(roomId)} size="large" />
<div className="room-intro__content">
<Text className="room-intro__name" variant="h1" weight="medium" primary>{heading}</Text>
<Text className="room-intro__desc" variant="b1">{desc}</Text>
<Text className="room-intro__name" variant="h1" weight="medium" primary>{twemojify(heading)}</Text>
<Text className="room-intro__desc" variant="b1">{twemojify(desc, undefined, true)}</Text>
{ time !== null && <Text className="room-intro__time" variant="b3">{time}</Text>}
</div>
</div>
@@ -34,9 +35,9 @@ RoomIntro.propTypes = {
PropTypes.bool,
]),
name: PropTypes.string.isRequired,
heading: PropTypes.node.isRequired,
desc: PropTypes.node.isRequired,
time: PropTypes.node,
heading: PropTypes.string.isRequired,
desc: PropTypes.string.isRequired,
time: PropTypes.string,
};
export default RoomIntro;
+21 -2
View File
@@ -9,7 +9,6 @@ import colorMXID from '../../../util/colorMXID';
import { openProfileViewer } from '../../../client/action/navigation';
import { getUsernameOfRoomMember, getPowerLabel } from '../../../util/matrixUtil';
import AsyncSearch from '../../../util/AsyncSearch';
import { memberByAtoZ, memberByPowerLevel } from '../../../util/sort';
import Text from '../../atoms/text/Text';
import Button from '../../atoms/button/Button';
@@ -20,6 +19,26 @@ import PeopleSelector from '../people-selector/PeopleSelector';
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) {
const mx = initMatrix.matrixClient;
return members.map((member) => ({
@@ -46,7 +65,7 @@ function useMemberOfMembership(roomId, membership) {
if (event && event?.getRoomId() !== roomId) return;
const memberOfMembership = normalizeMembers(
room.getMembersWithMembership(membership)
.sort(memberByAtoZ).sort(memberByPowerLevel),
.sort(AtoZ).sort(sortByPowerLevel),
);
setMembers(memberOfMembership);
};
@@ -35,12 +35,7 @@ const items = [{
function setRoomNotifType(roomId, newType) {
const mx = initMatrix.matrixClient;
const { notifications } = initMatrix;
let roomPushRule;
try {
roomPushRule = mx.getRoomPushRule('global', roomId);
} catch {
roomPushRule = undefined;
}
const roomPushRule = mx.getRoomPushRule('global', roomId);
const promises = [];
if (newType === cons.notifs.MUTE) {
+5 -12
View File
@@ -15,8 +15,6 @@ import TickMarkIC from '../../../../public/res/ic/outlined/tick-mark.svg';
import AddUserIC from '../../../../public/res/ic/outlined/add-user.svg';
import LeaveArrowIC from '../../../../public/res/ic/outlined/leave-arrow.svg';
import { confirmDialog } from '../confirm-dialog/ConfirmDialog';
function RoomOptions({ roomId, afterOptionSelect }) {
const mx = initMatrix.matrixClient;
const room = mx.getRoom(roomId);
@@ -31,16 +29,11 @@ function RoomOptions({ roomId, afterOptionSelect }) {
openInviteUser(roomId);
afterOptionSelect();
};
const handleLeaveClick = async () => {
afterOptionSelect();
const isConfirmed = await confirmDialog(
'Leave room',
`Are you sure that you want to leave "${room.name}" room?`,
'Leave',
'danger',
);
if (!isConfirmed) return;
roomActions.leave(roomId);
const handleLeaveClick = () => {
if (confirm('Are you really want to leave this room?')) {
roomActions.leave(roomId);
afterOptionSelect();
}
};
return (
@@ -179,7 +179,6 @@ function RoomPermissions({ roomId }) {
const pLEvent = room.currentState.getStateEvents('m.room.power_levels')[0];
const permissions = pLEvent.getContent();
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 handlePowerLevelChange = (newPowerLevel) => {
@@ -209,7 +208,7 @@ function RoomPermissions({ roomId }) {
(closeMenu) => (
<PowerLevelSelector
value={powerLevel}
max={myPowerLevel}
max={100}
onSelect={(pl) => {
closeMenu();
handlePowerLevelChange(pl);
@@ -19,7 +19,6 @@ import PencilIC from '../../../../public/res/ic/outlined/pencil.svg';
import { useStore } from '../../hooks/useStore';
import { useForceUpdate } from '../../hooks/useForceUpdate';
import { confirmDialog } from '../confirm-dialog/ConfirmDialog';
function RoomProfile({ roomId }) {
const isMountStore = useStore();
@@ -118,13 +117,7 @@ function RoomProfile({ roomId }) {
const handleAvatarUpload = async (url) => {
if (url === null) {
const isConfirmed = await confirmDialog(
'Remove avatar',
'Are you sure that you want to remove room avatar?',
'Remove',
'caution',
);
if (isConfirmed) {
if (confirm('Are you sure you want to remove avatar?')) {
await mx.sendStateEvent(roomId, 'm.room.avatar', { url }, '');
}
} else await mx.sendStateEvent(roomId, 'm.room.avatar', { url }, '');
+1
View File
@@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
import './RoomTile.scss';
import { twemojify } from '../../../util/twemojify';
import { sanitizeText } from '../../../util/sanitize';
import colorMXID from '../../../util/colorMXID';
@@ -70,7 +70,7 @@ function RoomVisibility({ roomId }) {
const noSpaceParent = currentState.getStateEvents('m.space.parent').length === 0;
const mCreate = currentState.getStateEvents('m.room.create')[0]?.getContent();
const roomVersion = Number(mCreate?.room_version ?? 0);
const roomVersion = Number(mCreate.room_version);
const myPowerlevel = room.getMember(mx.getUserId())?.powerLevel || 0;
const canChange = room.currentState.hasSufficientPowerLevelFor('state_default', myPowerlevel);
@@ -9,11 +9,7 @@ function SettingTile({ title, options, content }) {
<div className="setting-tile">
<div className="setting-tile__content">
<div className="setting-tile__title">
{
typeof title === 'string'
? <Text variant="b1">{title}</Text>
: title
}
<Text variant="b1">{title}</Text>
</div>
{content}
</div>
@@ -28,7 +24,7 @@ SettingTile.defaultProps = {
};
SettingTile.propTypes = {
title: PropTypes.node.isRequired,
title: PropTypes.string.isRequired,
options: PropTypes.node,
content: PropTypes.node,
};
@@ -9,12 +9,11 @@ import Tooltip from '../../atoms/tooltip/Tooltip';
import { blurOnBubbling } from '../../atoms/button/script';
const SidebarAvatar = React.forwardRef(({
className, tooltip, active, onClick,
onContextMenu, avatar, notificationBadge,
tooltip, active, onClick, onContextMenu,
avatar, notificationBadge,
}, ref) => {
const classes = ['sidebar-avatar'];
if (active) classes.push('sidebar-avatar--active');
if (className) classes.push(className);
let activeClass = '';
if (active) activeClass = ' sidebar-avatar--active';
return (
<Tooltip
content={<Text variant="b1">{twemojify(tooltip)}</Text>}
@@ -22,7 +21,7 @@ const SidebarAvatar = React.forwardRef(({
>
<button
ref={ref}
className={classes.join(' ')}
className={`sidebar-avatar${activeClass}`}
type="button"
onMouseUp={(e) => blurOnBubbling(e, '.sidebar-avatar')}
onClick={onClick}
@@ -35,7 +34,6 @@ const SidebarAvatar = React.forwardRef(({
);
});
SidebarAvatar.defaultProps = {
className: null,
active: false,
onClick: null,
onContextMenu: null,
@@ -43,7 +41,6 @@ SidebarAvatar.defaultProps = {
};
SidebarAvatar.propTypes = {
className: PropTypes.string,
tooltip: PropTypes.string.isRequired,
active: PropTypes.bool,
onClick: PropTypes.func,
@@ -88,7 +88,7 @@ function SpaceAddExistingContent({ roomId }) {
};
const handleSearch = (ev) => {
const term = ev.target.value.toLocaleLowerCase().replace(/\s/g, '');
const term = ev.target.value.toLocaleLowerCase().replaceAll(' ', '');
if (term === '') {
setSearchIds(null);
return;
@@ -100,7 +100,7 @@ function SpaceAddExistingContent({ roomId }) {
if (!name) return false;
name = name.normalize('NFKC')
.toLocaleLowerCase()
.replace(/\s/g, '');
.replaceAll(' ', '');
return name.includes(term);
});
setSearchIds(searchedIds);
@@ -24,8 +24,6 @@ import LeaveArrowIC from '../../../../public/res/ic/outlined/leave-arrow.svg';
import PinIC from '../../../../public/res/ic/outlined/pin.svg';
import PinFilledIC from '../../../../public/res/ic/filled/pin.svg';
import { confirmDialog } from '../confirm-dialog/ConfirmDialog';
function SpaceOptions({ roomId, afterOptionSelect }) {
const mx = initMatrix.matrixClient;
const room = mx.getRoom(roomId);
@@ -56,16 +54,11 @@ function SpaceOptions({ roomId, afterOptionSelect }) {
afterOptionSelect();
};
const handleLeaveClick = async () => {
afterOptionSelect();
const isConfirmed = await confirmDialog(
'Leave space',
`Are you sure that you want to leave "${room.name}" space?`,
'Leave',
'danger',
);
if (!isConfirmed) return;
leave(roomId);
const handleLeaveClick = () => {
if (confirm('Are you really want to leave this space?')) {
leave(roomId);
afterOptionSelect();
}
};
return (
+1 -1
View File
@@ -210,7 +210,7 @@ function CreateRoomContent({ isSpace, parentId, onRequestClose }) {
/>
)}
content={(
<Text variant="b3">Selecting Admin sets 100 power level whereas Founder sets 101.</Text>
<Text variant="b3">Override the default (100) power level.</Text>
)}
/>
<Input name="topic" minHeight={174} resizable label="Topic (optional)" />
@@ -2,7 +2,6 @@
@use '../../partials/dir';
.create-room {
margin: var(--sp-normal);
@include dir.side(margin, var(--sp-normal), var(--sp-extra-tight));
&__form > * {
+1 -1
View File
@@ -8,7 +8,7 @@ import Text from '../../atoms/text/Text';
function DragDrop({ isOpen }) {
return (
<RawModal
className="drag-drop__modal"
className="drag-drop__model"
overlayClassName="drag-drop__overlay"
isOpen={isOpen}
>
+1 -1
View File
@@ -1,4 +1,4 @@
.drag-drop__modal {
.drag-drop__model {
box-shadow: none;
text-align: center;
+6 -25
View File
@@ -12,7 +12,6 @@ import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons';
import navigation from '../../../client/state/navigation';
import AsyncSearch from '../../../util/AsyncSearch';
import { addRecentEmoji, getRecentEmojis } from './recent';
import Text from '../../atoms/text/Text';
import RawIcon from '../../atoms/system-icons/RawIcon';
@@ -21,7 +20,6 @@ import Input from '../../atoms/input/Input';
import ScrollView from '../../atoms/scroll/ScrollView';
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 DogIC from '../../../../public/res/ic/outlined/dog.svg';
import CupIC from '../../../../public/res/ic/outlined/cup.svg';
@@ -31,11 +29,10 @@ import BulbIC from '../../../../public/res/ic/outlined/bulb.svg';
import PeaceIC from '../../../../public/res/ic/outlined/peace.svg';
import FlagIC from '../../../../public/res/ic/outlined/flag.svg';
const ROW_EMOJIS_COUNT = 7;
const EmojiGroup = React.memo(({ name, groupEmojis }) => {
function getEmojiBoard() {
const emojiBoard = [];
const ROW_EMOJIS_COUNT = 7;
const totalEmojis = groupEmojis.length;
for (let r = 0; r < totalEmojis; r += ROW_EMOJIS_COUNT) {
@@ -150,9 +147,8 @@ function EmojiBoard({ onSelect, searchRef }) {
function selectEmoji(e) {
if (isTargetNotEmoji(e.target)) return;
const emoji = getEmojiDataFromTarget(e.target);
onSelect(emoji);
if (emoji.hexcode) addRecentEmoji(emoji.unicode);
const emoji = e.target;
onSelect(getEmojiDataFromTarget(emoji));
}
function setEmojiInfo(emoji) {
@@ -192,9 +188,6 @@ function EmojiBoard({ onSelect, searchRef }) {
}
const [availableEmojis, setAvailableEmojis] = useState([]);
const [recentEmojis, setRecentEmojis] = useState([]);
const recentOffset = recentEmojis.length > 0 ? 1 : 0;
useEffect(() => {
const updateAvailableEmoji = (selectedRoomId) => {
@@ -222,9 +215,6 @@ function EmojiBoard({ onSelect, searchRef }) {
const onOpen = () => {
searchRef.current.value = '';
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);
@@ -240,7 +230,7 @@ function EmojiBoard({ onSelect, searchRef }) {
const $emojiContent = scrollEmojisRef.current.firstElementChild;
const groupCount = $emojiContent.childElementCount;
if (groupCount > emojiGroups.length) {
tabIndex += groupCount - emojiGroups.length - availableEmojis.length - recentOffset;
tabIndex += groupCount - emojiGroups.length - availableEmojis.length;
}
$emojiContent.children[tabIndex].scrollIntoView();
}
@@ -256,7 +246,6 @@ function EmojiBoard({ onSelect, searchRef }) {
<ScrollView ref={scrollEmojisRef} autoHide>
<div onMouseMove={hoverEmoji} onClick={selectEmoji}>
<SearchedEmoji />
{recentEmojis.length > 0 && <EmojiGroup name="Recently used" groupEmojis={recentEmojis} />}
{
availableEmojis.map((pack) => (
<EmojiGroup
@@ -282,21 +271,13 @@ function EmojiBoard({ onSelect, searchRef }) {
</div>
<ScrollView invisible>
<div className="emoji-board__nav">
{recentEmojis.length > 0 && (
<IconButton
onClick={() => openGroup(0)}
src={RecentClockIC}
tooltip="Recent"
tooltipPlacement="right"
/>
)}
<div className="emoji-board__nav-custom">
{
availableEmojis.map((pack) => {
const src = initMatrix.matrixClient.mxcUrlToHttp(pack.avatar ?? pack.images[0].mxc);
return (
<IconButton
onClick={() => openGroup(recentOffset + pack.packIndex)}
onClick={() => openGroup(pack.packIndex)}
src={src}
key={pack.packIndex}
tooltip={pack.displayName}
@@ -320,7 +301,7 @@ function EmojiBoard({ onSelect, searchRef }) {
[7, FlagIC, 'Flags'],
].map(([indx, ico, name]) => (
<IconButton
onClick={() => openGroup(recentOffset + availableEmojis.length + indx)}
onClick={() => openGroup(availableEmojis.length + indx)}
key={indx}
src={ico}
tooltip={name}
@@ -6,8 +6,6 @@
--emoji-board-height: 390px;
--emoji-board-width: 286px;
display: flex;
max-width: 90vw;
max-height: 90vh;
&__content {
@extend .cp-fx__item-one;
@@ -93,10 +91,6 @@
}
}
.emoji-row {
display: flex;
}
.emoji-group {
--emoji-padding: 6px;
position: relative;
-36
View File
@@ -1,36 +0,0 @@
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),
});
}
@@ -1,200 +0,0 @@
/* eslint-disable react/prop-types */
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import './EmojiVerification.scss';
import { twemojify } from '../../../util/twemojify';
import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons';
import navigation from '../../../client/state/navigation';
import { hasPrivateKey } from '../../../client/state/secretStorageKeys';
import { getDefaultSSKey, isCrossVerified } from '../../../util/matrixUtil';
import Text from '../../atoms/text/Text';
import IconButton from '../../atoms/button/IconButton';
import Button from '../../atoms/button/Button';
import Spinner from '../../atoms/spinner/Spinner';
import Dialog from '../../molecules/dialog/Dialog';
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
import { useStore } from '../../hooks/useStore';
import { accessSecretStorage } from '../settings/SecretStorageAccess';
function EmojiVerificationContent({ data, requestClose }) {
const [sas, setSas] = useState(null);
const [process, setProcess] = useState(false);
const { request, targetDevice } = data;
const mx = initMatrix.matrixClient;
const mountStore = useStore();
const beginStore = useStore();
const beginVerification = async () => {
if (
isCrossVerified(mx.deviceId)
&& (mx.getCrossSigningId() === null || await mx.crypto.crossSigningInfo.isStoredInKeyCache('self_signing') === false)
) {
if (!hasPrivateKey(getDefaultSSKey())) {
const keyData = await accessSecretStorage('Emoji verification');
if (!keyData) {
request.cancel();
return;
}
}
await mx.checkOwnCrossSigningTrust();
}
setProcess(true);
await request.accept();
const verifier = request.beginKeyVerification('m.sas.v1', targetDevice);
const handleVerifier = (sasData) => {
verifier.off('show_sas', handleVerifier);
if (!mountStore.getItem()) return;
setSas(sasData);
setProcess(false);
};
verifier.on('show_sas', handleVerifier);
await verifier.verify();
};
const sasMismatch = () => {
sas.mismatch();
setProcess(true);
};
const sasConfirm = () => {
sas.confirm();
setProcess(true);
};
useEffect(() => {
mountStore.setItem(true);
const handleChange = () => {
if (request.done || request.cancelled) {
requestClose();
return;
}
if (targetDevice && !beginStore.getItem()) {
beginStore.setItem(true);
beginVerification();
}
};
if (request === null) return null;
const req = request;
req.on('change', handleChange);
return () => {
req.off('change', handleChange);
if (req.cancelled === false && req.done === false) {
req.cancel();
}
};
}, [request]);
const renderWait = () => (
<>
<Spinner size="small" />
<Text>Waiting for response from other device...</Text>
</>
);
if (sas !== null) {
return (
<div className="emoji-verification__content">
<Text>Confirm the emoji below are displayed on both devices, in the same order:</Text>
<div className="emoji-verification__emojis">
{sas.sas.emoji.map((emoji, i) => (
// eslint-disable-next-line react/no-array-index-key
<div className="emoji-verification__emoji-block" key={`${emoji[1]}-${i}`}>
<Text variant="h1">{twemojify(emoji[0])}</Text>
<Text>{emoji[1]}</Text>
</div>
))}
</div>
<div className="emoji-verification__buttons">
{process ? renderWait() : (
<>
<Button variant="primary" onClick={sasConfirm}>They match</Button>
<Button onClick={sasMismatch}>{'They don\'t match'}</Button>
</>
)}
</div>
</div>
);
}
if (targetDevice) {
return (
<div className="emoji-verification__content">
<Text>Please accept the request from other device.</Text>
<div className="emoji-verification__buttons">
{renderWait()}
</div>
</div>
);
}
return (
<div className="emoji-verification__content">
<Text>Click accept to start the verification process.</Text>
<div className="emoji-verification__buttons">
{
process
? renderWait()
: <Button variant="primary" onClick={beginVerification}>Accept</Button>
}
</div>
</div>
);
}
EmojiVerificationContent.propTypes = {
data: PropTypes.shape({}).isRequired,
requestClose: PropTypes.func.isRequired,
};
function useVisibilityToggle() {
const [data, setData] = useState(null);
const mx = initMatrix.matrixClient;
useEffect(() => {
const handleOpen = (request, targetDevice) => {
setData({ request, targetDevice });
};
navigation.on(cons.events.navigation.EMOJI_VERIFICATION_OPENED, handleOpen);
mx.on('crypto.verification.request', handleOpen);
return () => {
navigation.removeListener(cons.events.navigation.EMOJI_VERIFICATION_OPENED, handleOpen);
mx.removeListener('crypto.verification.request', handleOpen);
};
}, []);
const requestClose = () => setData(null);
return [data, requestClose];
}
function EmojiVerification() {
const [data, requestClose] = useVisibilityToggle();
return (
<Dialog
isOpen={data !== null}
className="emoji-verification"
title={(
<Text variant="s1" weight="medium" primary>
Emoji verification
</Text>
)}
contentOptions={<IconButton src={CrossIC} onClick={requestClose} tooltip="Close" />}
onRequestClose={requestClose}
>
{
data !== null
? <EmojiVerificationContent data={data} requestClose={requestClose} />
: <div />
}
</Dialog>
);
}
export default EmojiVerification;
@@ -1,35 +0,0 @@
@use '../../partials/flex';
@use '../../partials/dir';
.emoji-verification {
&__content {
padding: var(--sp-normal);
@include dir.side(padding, var(--sp-normal), var(--sp-extra-tight));
display: flex;
flex-direction: column;
gap: var(--sp-normal);
}
&__emojis {
margin: var(--sp-loose) 0;
display: flex;
align-items: center;
justify-content: space-around;
gap: var(--sp-extra-tight);
flex-wrap: wrap;
}
&__emoji-block {
@extend .cp-fx__column;
flex: 1;
align-items: center;
gap: var(--sp-extra-tight);
white-space: nowrap;
text-transform: capitalize;
}
&__buttons {
display: flex;
gap: var(--sp-normal);
}
}
+4 -8
View File
@@ -54,20 +54,17 @@ function InviteList({ isOpen, onRequestClose }) {
}, [procInvite]);
function renderRoomTile(roomId) {
const mx = initMatrix.matrixClient;
const myRoom = mx.getRoom(roomId);
if (!myRoom) return null;
const myRoom = initMatrix.matrixClient.getRoom(roomId);
const roomName = myRoom.name;
let roomAlias = myRoom.getCanonicalAlias();
if (!roomAlias) roomAlias = myRoom.roomId;
const inviterName = myRoom.getMember(mx.getUserId())?.events?.member?.getSender?.() ?? '';
if (roomAlias === null) roomAlias = myRoom.roomId;
return (
<RoomTile
key={myRoom.roomId}
name={roomName}
avatarSrc={initMatrix.matrixClient.getRoom(roomId).getAvatarUrl(initMatrix.matrixClient.baseUrl, 42, 42, 'crop')}
id={roomAlias}
inviterName={inviterName}
inviterName={myRoom.getJoinedMembers()[0].userId}
options={
procInvite.has(myRoom.roomId)
? (<Spinner size="small" />)
@@ -98,13 +95,12 @@ function InviteList({ isOpen, onRequestClose }) {
{
Array.from(initMatrix.roomList.inviteDirects).map((roomId) => {
const myRoom = initMatrix.matrixClient.getRoom(roomId);
if (myRoom === null) return null;
const roomName = myRoom.name;
return (
<RoomTile
key={myRoom.roomId}
name={roomName}
id={myRoom.getDMInviter() || roomId}
id={myRoom.getDMInviter()}
options={
procInvite.has(myRoom.roomId)
? (<Spinner size="small" />)
+1 -13
View File
@@ -103,18 +103,6 @@ function InviteUser({
updateIsSearching(false);
}
async function hasDevices(userId) {
try {
const usersDeviceMap = await mx.downloadKeys([userId, mx.getUserId()]);
return Object.values(usersDeviceMap).every((userDevices) =>
Object.keys(userDevices).length > 0,
);
} catch (e) {
console.error("Error determining if it's possible to encrypt to all users: ", e);
return false;
}
}
async function createDM(userId) {
if (mx.getUserId() === userId) return;
const dmRoomId = hasDMWith(userId);
@@ -129,7 +117,7 @@ function InviteUser({
procUserError.delete(userId);
updateUserProcError(getMapCopy(procUserError));
const result = await roomActions.createDM(userId, await hasDevices(userId));
const result = await roomActions.createDM(userId);
roomIdToUserId.set(result.room_id, userId);
updateRoomIdToUserId(getMapCopy(roomIdToUserId));
} catch (e) {
-155
View File
@@ -1,155 +0,0 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import './JoinAlias.scss';
import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons';
import navigation from '../../../client/state/navigation';
import { join } from '../../../client/action/room';
import { selectRoom, selectSpace } from '../../../client/action/navigation';
import Text from '../../atoms/text/Text';
import IconButton from '../../atoms/button/IconButton';
import Button from '../../atoms/button/Button';
import Input from '../../atoms/input/Input';
import Spinner from '../../atoms/spinner/Spinner';
import Dialog from '../../molecules/dialog/Dialog';
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
import { useStore } from '../../hooks/useStore';
const ALIAS_OR_ID_REG = /^[#|!].+:.+\..+$/;
function JoinAliasContent({ term, requestClose }) {
const [process, setProcess] = useState(false);
const [error, setError] = useState(undefined);
const [lastJoinId, setLastJoinId] = useState(undefined);
const mx = initMatrix.matrixClient;
const mountStore = useStore();
const openRoom = (roomId) => {
const room = mx.getRoom(roomId);
if (!room) return;
if (room.isSpaceRoom()) selectSpace(roomId);
else selectRoom(roomId);
requestClose();
};
useEffect(() => {
const handleJoin = (roomId) => {
if (lastJoinId !== roomId) return;
openRoom(roomId);
};
initMatrix.roomList.on(cons.events.roomList.ROOM_JOINED, handleJoin);
return () => {
initMatrix.roomList.removeListener(cons.events.roomList.ROOM_JOINED, handleJoin);
};
}, [lastJoinId]);
const handleSubmit = async (e) => {
e.preventDefault();
mountStore.setItem(true);
const alias = e.target.alias.value;
if (alias?.trim() === '') return;
if (alias.match(ALIAS_OR_ID_REG) === null) {
setError('Invalid address.');
return;
}
setProcess('Looking for address...');
setError(undefined);
let via;
if (alias.startsWith('#')) {
try {
const aliasData = await mx.resolveRoomAlias(alias);
via = aliasData?.servers.slice(0, 3) || [];
if (mountStore.getItem()) {
setProcess(`Joining ${alias}...`);
}
} catch (err) {
if (!mountStore.getItem()) return;
setProcess(false);
setError(`Unable to find room/space with ${alias}. Either room/space is private or doesn't exist.`);
}
}
try {
const roomId = await join(alias, false, via);
if (!mountStore.getItem()) return;
setLastJoinId(roomId);
openRoom(roomId);
} catch {
if (!mountStore.getItem()) return;
setProcess(false);
setError(`Unable to join ${alias}. Either room/space is private or doesn't exist.`);
}
};
return (
<form className="join-alias" onSubmit={handleSubmit}>
<Input
label="Address"
value={term}
name="alias"
required
/>
{error && <Text className="join-alias__error" variant="b3">{error}</Text>}
<div className="join-alias__btn">
{
process
? (
<>
<Spinner size="small" />
<Text>{process}</Text>
</>
)
: <Button variant="primary" type="submit">Join</Button>
}
</div>
</form>
);
}
JoinAliasContent.defaultProps = {
term: undefined,
};
JoinAliasContent.propTypes = {
term: PropTypes.string,
requestClose: PropTypes.func.isRequired,
};
function useWindowToggle() {
const [data, setData] = useState(null);
useEffect(() => {
const handleOpen = (term) => {
setData({ term });
};
navigation.on(cons.events.navigation.JOIN_ALIAS_OPENED, handleOpen);
return () => {
navigation.removeListener(cons.events.navigation.JOIN_ALIAS_OPENED, handleOpen);
};
}, []);
const onRequestClose = () => setData(null);
return [data, onRequestClose];
}
function JoinAlias() {
const [data, requestClose] = useWindowToggle();
return (
<Dialog
isOpen={data !== null}
title={(
<Text variant="s1" weight="medium" primary>Join with address</Text>
)}
contentOptions={<IconButton src={CrossIC} onClick={requestClose} tooltip="Close" />}
onRequestClose={requestClose}
>
{ data ? <JoinAliasContent term={data.term} requestClose={requestClose} /> : <div /> }
</Dialog>
);
}
export default JoinAlias;
@@ -1,20 +0,0 @@
@use '../../partials/dir';
.join-alias {
padding: var(--sp-normal);
@include dir.side(padding, var(--sp-normal), var(--sp-extra-tight));
& > *:not(:first-child) {
margin-top: var(--sp-normal);
}
&__error {
color: var(--tc-danger-high);
margin-top: var(--sp-extra-tight) !important;
}
&__btn {
display: flex;
gap: var(--sp-normal);
}
}
+5 -29
View File
@@ -1,39 +1,18 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import React, { useEffect } from 'react';
import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons';
import navigation from '../../../client/state/navigation';
import Postie from '../../../util/Postie';
import { roomIdByActivity } from '../../../util/sort';
import RoomsCategory from './RoomsCategory';
import { AtoZ } from './common';
const drawerPostie = new Postie();
function Directs({ size }) {
const mx = initMatrix.matrixClient;
function Directs() {
const { roomList, notifications } = initMatrix;
const [directIds, setDirectIds] = useState([]);
useEffect(() => setDirectIds([...roomList.directs].sort(roomIdByActivity)), [size]);
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]);
const directIds = [...roomList.directs].sort(AtoZ);
useEffect(() => {
const selectorChanged = (selectedRoomId, prevSelectedRoomId) => {
@@ -64,8 +43,5 @@ function Directs({ size }) {
return <RoomsCategory name="People" hideHeader roomIds={directIds} drawerPostie={drawerPostie} />;
}
Directs.propTypes = {
size: PropTypes.number.isRequired,
};
export default Directs;
+5 -10
View File
@@ -42,15 +42,12 @@ function Drawer() {
const [spaceId] = useSelectedSpace();
const [, forceUpdate] = useForceUpdate();
const scrollRef = useRef(null);
const { roomList } = initMatrix;
useEffect(() => {
const handleUpdate = () => {
forceUpdate();
};
roomList.on(cons.events.roomList.ROOMLIST_UPDATED, handleUpdate);
const { roomList } = initMatrix;
roomList.on(cons.events.roomList.ROOMLIST_UPDATED, forceUpdate);
return () => {
roomList.removeListener(cons.events.roomList.ROOMLIST_UPDATED, handleUpdate);
roomList.removeListener(cons.events.roomList.ROOMLIST_UPDATED, forceUpdate);
};
}, []);
@@ -64,16 +61,14 @@ function Drawer() {
<div className="drawer">
<DrawerHeader selectedTab={selectedTab} spaceId={spaceId} />
<div className="drawer__content-wrapper">
{navigation.selectedSpacePath.length > 1 && selectedTab !== cons.tabs.DIRECTS && (
<DrawerBreadcrumb spaceId={spaceId} />
)}
{navigation.selectedSpacePath.length > 1 && <DrawerBreadcrumb spaceId={spaceId} />}
<div className="rooms__wrapper">
<ScrollView ref={scrollRef} autoHide>
<div className="rooms-container">
{
selectedTab !== cons.tabs.DIRECTS
? <Home spaceId={spaceId} />
: <Directs size={roomList.directs.size} />
: <Directs />
}
</div>
</ScrollView>
@@ -7,7 +7,7 @@ import { twemojify } from '../../../util/twemojify';
import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons';
import {
openPublicRooms, openCreateRoom, openSpaceManage, openJoinAlias,
openPublicRooms, openCreateRoom, openSpaceManage,
openSpaceAddExisting, openInviteUser, openReusableContextMenu,
} from '../../../client/action/navigation';
import { getEventCords } from '../../../util/common';
@@ -60,14 +60,6 @@ export function HomeSpaceOptions({ spaceId, afterOptionSelect }) {
Join public room
</MenuItem>
)}
{ !spaceId && (
<MenuItem
iconSrc={PlusIC}
onClick={() => { afterOptionSelect(); openJoinAlias(); }}
>
Join with address
</MenuItem>
)}
{ spaceId && (
<MenuItem
iconSrc={PlusIC}
+17 -23
View File
@@ -5,11 +5,11 @@ import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons';
import navigation from '../../../client/state/navigation';
import Postie from '../../../util/Postie';
import { roomIdByActivity, roomIdByAtoZ } from '../../../util/sort';
import RoomsCategory from './RoomsCategory';
import { useCategorizedSpaces } from '../../hooks/useCategorizedSpaces';
import { AtoZ, RoomToDM } from './common';
const drawerPostie = new Postie();
function Home({ spaceId }) {
@@ -34,6 +34,10 @@ function Home({ spaceId }) {
roomIds = roomList.getOrphanRooms();
}
spaceIds.sort(AtoZ);
roomIds.sort(AtoZ);
directIds.sort(AtoZ);
if (isCategorized) {
categories = roomList.getCategorizedSpaces(spaceIds);
categories.delete(spaceId);
@@ -69,36 +73,26 @@ function Home({ spaceId }) {
return (
<>
{ !isCategorized && spaceIds.length !== 0 && (
<RoomsCategory name="Spaces" roomIds={spaceIds.sort(roomIdByAtoZ)} drawerPostie={drawerPostie} />
<RoomsCategory name="Spaces" roomIds={spaceIds} drawerPostie={drawerPostie} />
)}
{ roomIds.length !== 0 && (
<RoomsCategory name="Rooms" roomIds={roomIds.sort(roomIdByAtoZ)} drawerPostie={drawerPostie} />
<RoomsCategory name="Rooms" roomIds={roomIds} drawerPostie={drawerPostie} />
)}
{ directIds.length !== 0 && (
<RoomsCategory name="People" roomIds={directIds.sort(roomIdByActivity)} drawerPostie={drawerPostie} />
<RoomsCategory name="People" roomIds={directIds} drawerPostie={drawerPostie} />
)}
{ isCategorized && [...categories].map(([catId, childIds]) => {
const rms = [];
const dms = [];
childIds.forEach((id) => {
if (directs.has(id)) dms.push(id);
else rms.push(id);
});
rms.sort(roomIdByAtoZ);
dms.sort(roomIdByActivity);
return (
<RoomsCategory
key={catId}
spaceId={catId}
name={mx.getRoom(catId).name}
roomIds={rms.concat(dms)}
drawerPostie={drawerPostie}
/>
);
})}
{ isCategorized && [...categories].map(([catId, childIds]) => (
<RoomsCategory
key={catId}
spaceId={catId}
name={mx.getRoom(catId).name}
roomIds={[...childIds].sort(AtoZ).sort(RoomToDM)}
drawerPostie={drawerPostie}
/>
))}
</>
);
}
+1 -23
View File
@@ -14,7 +14,6 @@ import {
} from '../../../client/action/navigation';
import { moveSpaceShortcut } from '../../../client/action/accountData';
import { abbreviateNumber, getEventCords } from '../../../util/common';
import { isCrossVerified } from '../../../util/matrixUtil';
import Avatar from '../../atoms/avatar/Avatar';
import NotificationBadge from '../../atoms/badge/NotificationBadge';
@@ -27,12 +26,8 @@ import UserIC from '../../../../public/res/ic/outlined/user.svg';
import AddPinIC from '../../../../public/res/ic/outlined/add-pin.svg';
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
import InviteIC from '../../../../public/res/ic/outlined/invite.svg';
import ShieldUserIC from '../../../../public/res/ic/outlined/shield-user.svg';
import { useSelectedTab } from '../../hooks/useSelectedTab';
import { useDeviceList } from '../../hooks/useDeviceList';
import { tabText as settingTabText } from '../settings/Settings';
function useNotificationUpdate() {
const { notifications } = initMatrix;
@@ -77,7 +72,7 @@ function ProfileAvatarMenu() {
return (
<SidebarAvatar
onClick={openSettings}
tooltip="Settings"
tooltip={profile.displayName}
avatar={(
<Avatar
text={profile.displayName}
@@ -90,22 +85,6 @@ function ProfileAvatarMenu() {
);
}
function CrossSigninAlert() {
const deviceList = useDeviceList();
const unverified = deviceList?.filter((device) => isCrossVerified(device.device_id) === false);
if (!unverified?.length) return null;
return (
<SidebarAvatar
className="sidebar__cross-signin-alert"
tooltip={`${unverified.length} unverified sessions`}
onClick={() => openSettings(settingTabText.SECURITY)}
avatar={<Avatar iconSrc={ShieldUserIC} iconColor="var(--ic-danger-normal)" size="normal" />}
/>
);
}
function FeaturedTab() {
const { roomList, accountData, notifications } = initMatrix;
const [selectedTab] = useSelectedTab();
@@ -379,7 +358,6 @@ function SideBar() {
notificationBadge={<NotificationBadge alert content={totalInvites} />}
/>
)}
<CrossSigninAlert />
<ProfileAvatarMenu />
</div>
</div>
-17
View File
@@ -57,21 +57,4 @@
width: 24px;
height: 1px;
background-color: var(--bg-surface-border);
}
.sidebar__cross-signin-alert .avatar-container {
box-shadow: var(--bs-danger-border);
animation-name: pushRight;
animation-duration: 400ms;
animation-iteration-count: infinite;
animation-direction: alternate;
}
@keyframes pushRight {
from {
transform: translateX(4px) scale(1);
}
to {
transform: translateX(0) scale(1);
}
}
+30
View File
@@ -0,0 +1,30 @@
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,53 +1,37 @@
import React, { useState, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import { twemojify } from '../../../util/twemojify';
import initMatrix from '../../../client/initMatrix';
import colorMXID from '../../../util/colorMXID';
import Text from '../../atoms/text/Text';
import IconButton from '../../atoms/button/IconButton';
import Button from '../../atoms/button/Button';
import ImageUpload from '../../molecules/image-upload/ImageUpload';
import Input from '../../atoms/input/Input';
import PencilIC from '../../../../public/res/ic/outlined/pencil.svg';
import { confirmDialog } from '../../molecules/confirm-dialog/ConfirmDialog';
import './ProfileEditor.scss';
// TODO Fix bug that prevents 'Save' button from enabling up until second changed.
function ProfileEditor({ userId }) {
const [isEditing, setIsEditing] = useState(false);
function ProfileEditor({
userId,
}) {
const mx = initMatrix.matrixClient;
const user = mx.getUser(mx.getUserId());
const displayNameRef = useRef(null);
const [avatarSrc, setAvatarSrc] = useState(user.avatarUrl ? mx.mxcUrlToHttp(user.avatarUrl, 80, 80, 'crop') : null);
const [username, setUsername] = useState(user.displayName);
const bgColor = colorMXID(userId);
const [avatarSrc, setAvatarSrc] = useState(null);
const [disabled, setDisabled] = useState(true);
let username = mx.getUser(mx.getUserId()).displayName;
useEffect(() => {
let isMounted = true;
mx.getProfileInfo(mx.getUserId()).then((info) => {
if (!isMounted) return;
setAvatarSrc(info.avatar_url ? mx.mxcUrlToHttp(info.avatar_url, 80, 80, 'crop') : null);
setUsername(info.displayname);
});
return () => {
isMounted = false;
};
}, [userId]);
const handleAvatarUpload = async (url) => {
// Sets avatar URL and updates the avatar component in profile editor to reflect new upload
function handleAvatarUpload(url) {
if (url === null) {
const isConfirmed = await confirmDialog(
'Remove avatar',
'Are you sure that you want to remove avatar?',
'Remove',
'caution',
);
if (isConfirmed) {
if (confirm('Are you sure you want to remove avatar?')) {
mx.setAvatarUrl('');
setAvatarSrc(null);
}
@@ -55,72 +39,48 @@ function ProfileEditor({ userId }) {
}
mx.setAvatarUrl(url);
setAvatarSrc(mx.mxcUrlToHttp(url, 80, 80, 'crop'));
};
}
const saveDisplayName = () => {
function saveDisplayName() {
const newDisplayName = displayNameRef.current.value;
if (newDisplayName !== null && newDisplayName !== username) {
mx.setDisplayName(newDisplayName);
setUsername(newDisplayName);
username = newDisplayName;
setDisabled(true);
setIsEditing(false);
}
};
}
const onDisplayNameInputChange = () => {
function onDisplayNameInputChange() {
setDisabled(username === displayNameRef.current.value || displayNameRef.current.value == null);
};
const cancelDisplayNameChanges = () => {
}
function cancelDisplayNameChanges() {
displayNameRef.current.value = username;
onDisplayNameInputChange();
setIsEditing(false);
};
const renderForm = () => (
<form
className="profile-editor__form"
style={{ marginBottom: avatarSrc ? '24px' : '0' }}
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">
<form
className="profile-editor"
onSubmit={(e) => { e.preventDefault(); saveDisplayName(); }}
>
<ImageUpload
text={username}
bgColor={colorMXID(userId)}
bgColor={bgColor}
imageSrc={avatarSrc}
onUpload={handleAvatarUpload}
onRequestRemove={() => handleAvatarUpload(null)}
/>
{
isEditing ? renderForm() : renderInfo()
}
</div>
<div className="profile-editor__input-wrapper">
<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>
</div>
</form>
);
}
@@ -1,41 +1,28 @@
@use '../../partials/dir';
@use '../../partials/flex';
.profile-editor {
display: flex;
align-items: flex-end;
align-items: flex-start;
}
.profile-editor__info,
.profile-editor__form {
@extend .cp-fx__item-one;
@include dir.side(margin, var(--sp-loose), 0);
display: flex;
}
.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 {
.profile-editor__input-wrapper {
flex: 1;
min-width: 0;
margin-top: 10px;
flex-wrap: wrap;
display: flex;
align-items: flex-end;
flex-wrap: wrap;
& > .input-container {
@extend .cp-fx__item-one;
flex: 1;
}
& > button {
height: 46px;
margin-top: var(--sp-normal);
@include dir.side(margin, var(--sp-normal), 0);
}
& > * {
@include dir.side(margin, var(--sp-normal), 0);
}
}
@@ -32,7 +32,6 @@ import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.s
import CrossIC from '../../../../public/res/ic/outlined/cross.svg';
import { useForceUpdate } from '../../hooks/useForceUpdate';
import { confirmDialog } from '../../molecules/confirm-dialog/ConfirmDialog';
function ModerationTools({
roomId, userId,
@@ -363,7 +362,7 @@ function ProfileViewer() {
&& (powerLevel < myPowerLevel || userId === mx.getUserId())
);
const handleChangePowerLevel = async (newPowerLevel) => {
const handleChangePowerLevel = (newPowerLevel) => {
if (newPowerLevel === powerLevel) return;
const SHARED_POWER_MSG = 'You will not be able to undo this change as you are promoting the user to have the same power level as yourself. Are you sure?';
const DEMOTING_MYSELF_MSG = 'You will not be able to undo this change as you are demoting yourself. Are you sure?';
@@ -371,14 +370,9 @@ function ProfileViewer() {
const isSharedPower = newPowerLevel === myPowerLevel;
const isDemotingMyself = userId === mx.getUserId();
if (isSharedPower || isDemotingMyself) {
const isConfirmed = await confirmDialog(
'Change power level',
isSharedPower ? SHARED_POWER_MSG : DEMOTING_MYSELF_MSG,
'Change',
'caution',
);
if (!isConfirmed) return;
roomActions.setPowerLevel(roomId, userId, newPowerLevel);
if (confirm(isSharedPower ? SHARED_POWER_MSG : DEMOTING_MYSELF_MSG)) {
roomActions.setPowerLevel(roomId, userId, newPowerLevel);
}
} else {
roomActions.setPowerLevel(roomId, userId, newPowerLevel);
}
@@ -4,7 +4,7 @@ import './PublicRooms.scss';
import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons';
import { selectRoom, selectTab } from '../../../client/action/navigation';
import { selectRoom } from '../../../client/action/navigation';
import * as roomActions from '../../../client/action/room';
import Text from '../../atoms/text/Text';
@@ -179,9 +179,7 @@ function PublicRooms({ isOpen, searchTerm, onRequestClose }) {
}, [joiningRooms]);
function handleViewRoom(roomId) {
const room = initMatrix.matrixClient.getRoom(roomId);
if (room.isSpaceRoom()) selectTab(roomId);
else selectRoom(roomId);
selectRoom(roomId);
onRequestClose();
}
@@ -195,7 +193,7 @@ function PublicRooms({ isOpen, searchTerm, onRequestClose }) {
return rooms.map((room) => {
const alias = typeof room.canonical_alias === 'string' ? room.canonical_alias : room.room_id;
const name = typeof room.name === 'string' ? room.name : alias;
const isJoined = initMatrix.matrixClient.getRoom(room.room_id)?.getMyMembership() === 'join';
const isJoined = initMatrix.roomList.rooms.has(room.room_id);
return (
<RoomTile
key={room.room_id}
-8
View File
@@ -7,10 +7,6 @@ import SpaceAddExisting from '../../molecules/space-add-existing/SpaceAddExistin
import Search from '../search/Search';
import ViewSource from '../view-source/ViewSource';
import CreateRoom from '../create-room/CreateRoom';
import JoinAlias from '../join-alias/JoinAlias';
import EmojiVerification from '../emoji-verification/EmojiVerification';
import ReusableDialog from '../../molecules/dialog/ReusableDialog';
function Dialogs() {
return (
@@ -20,12 +16,8 @@ function Dialogs() {
<ProfileViewer />
<ShortcutSpaces />
<CreateRoom />
<JoinAlias />
<SpaceAddExisting />
<Search />
<EmojiVerification />
<ReusableDialog />
</>
);
}
+10 -1
View File
@@ -18,6 +18,7 @@ function Windows() {
const [inviteUser, changeInviteUser] = useState({
isOpen: false, roomId: undefined, term: undefined,
});
const [settings, changeSettings] = useState(false);
function openInviteList() {
changeInviteList(true);
@@ -35,15 +36,20 @@ function Windows() {
searchTerm,
});
}
function openSettings() {
changeSettings(true);
}
useEffect(() => {
navigation.on(cons.events.navigation.INVITE_LIST_OPENED, openInviteList);
navigation.on(cons.events.navigation.PUBLIC_ROOMS_OPENED, openPublicRooms);
navigation.on(cons.events.navigation.INVITE_USER_OPENED, openInviteUser);
navigation.on(cons.events.navigation.SETTINGS_OPENED, openSettings);
return () => {
navigation.removeListener(cons.events.navigation.INVITE_LIST_OPENED, openInviteList);
navigation.removeListener(cons.events.navigation.PUBLIC_ROOMS_OPENED, openPublicRooms);
navigation.removeListener(cons.events.navigation.INVITE_USER_OPENED, openInviteUser);
navigation.removeListener(cons.events.navigation.SETTINGS_OPENED, openSettings);
};
}, []);
@@ -64,7 +70,10 @@ function Windows() {
searchTerm={inviteUser.searchTerm}
onRequestClose={() => changeInviteUser({ isOpen: false, roomId: undefined })}
/>
<Settings />
<Settings
isOpen={settings}
onRequestClose={() => changeSettings(false)}
/>
<SpaceSettings />
<SpaceManage />
</>
@@ -64,11 +64,9 @@ function ReadReceipts() {
onRequestClose={() => setIsOpen(false)}
contentOptions={<IconButton src={CrossIC} onClick={() => setIsOpen(false)} tooltip="Close" />}
>
<div style={{ marginTop: 'var(--sp-tight)', marginBottom: 'var(--sp-extra-loose)' }}>
{
readers.map(renderPeople)
}
</div>
{
readers.map(renderPeople)
}
</Dialog>
);
}
+21 -2
View File
@@ -9,7 +9,6 @@ import { getPowerLabel, getUsernameOfRoomMember } from '../../../util/matrixUtil
import colorMXID from '../../../util/colorMXID';
import { openInviteUser, openProfileViewer } from '../../../client/action/navigation';
import AsyncSearch from '../../../util/AsyncSearch';
import { memberByAtoZ, memberByPowerLevel } from '../../../util/sort';
import Text from '../../atoms/text/Text';
import Header, { TitleWrapper } from '../../atoms/header/Header';
@@ -25,6 +24,26 @@ import AddUserIC from '../../../../public/res/ic/outlined/add-user.svg';
import SearchIC from '../../../../public/res/ic/outlined/search.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) {
const mx = initMatrix.matrixClient;
return members.map((member) => ({
@@ -92,7 +111,7 @@ function PeopleDrawer({ roomId }) {
setMemberList(
simplyfiMembers(
getMembersWithMembership(membership)
.sort(memberByAtoZ).sort(memberByPowerLevel),
.sort(AtoZ).sort(sortByPowerLevel),
),
);
};
+3 -7
View File
@@ -3,10 +3,9 @@ import './Room.scss';
import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons';
import navigation from '../../../client/state/navigation';
import settings from '../../../client/state/settings';
import RoomTimeline from '../../../client/state/RoomTimeline';
import navigation from '../../../client/state/navigation';
import { openNavigation } from '../../../client/action/navigation';
import Welcome from '../welcome/Welcome';
import RoomView from './RoomView';
@@ -54,10 +53,7 @@ function Room() {
}, []);
const { roomTimeline, eventId } = roomInfo;
if (roomTimeline === null) {
setTimeout(() => openNavigation());
return <Welcome />;
}
if (roomTimeline === null) return <Welcome />;
return (
<div className="room">
@@ -65,7 +61,7 @@ function Room() {
<RoomSettings roomId={roomTimeline.roomId} />
<RoomView roomTimeline={roomTimeline} eventId={eventId} />
</div>
{isDrawer && <PeopleDrawer roomId={roomTimeline.roomId} />}
{ isDrawer && <PeopleDrawer roomId={roomTimeline.roomId} />}
</div>
);
}
+1 -8
View File
@@ -1,5 +1,4 @@
@use '../../partials/flex';
@use '../../partials/screen';
.room {
@extend .cp-fx__row;
@@ -10,10 +9,4 @@
position: relative;
overflow: hidden;
}
}
.room .people-drawer {
@include screen.smallerThan(tabletBreakpoint) {
display: none;
}
}
}
+5 -11
View File
@@ -36,7 +36,6 @@ import LeaveArrowIC from '../../../../public/res/ic/outlined/leave-arrow.svg';
import ChevronTopIC from '../../../../public/res/ic/outlined/chevron-top.svg';
import { useForceUpdate } from '../../hooks/useForceUpdate';
import { confirmDialog } from '../../molecules/confirm-dialog/ConfirmDialog';
const tabText = {
GENERAL: 'General',
@@ -86,15 +85,10 @@ function GeneralSettings({ roomId }) {
</MenuItem>
<MenuItem
variant="danger"
onClick={async () => {
const isConfirmed = await confirmDialog(
'Leave room',
`Are you sure that you want to leave "${room.name}" room?`,
'Leave',
'danger',
);
if (!isConfirmed) return;
roomActions.leave(roomId);
onClick={() => {
if (confirm('Are you really want to leave this room?')) {
roomActions.leave(roomId);
}
}}
iconSrc={LeaveArrowIC}
>
@@ -129,7 +123,7 @@ function SecuritySettings({ roomId }) {
<RoomEncryption roomId={roomId} />
</div>
<div className="room-settings__card">
<MenuHeader>Message history visibility</MenuHeader>
<MenuHeader>Message history visibility (Who can read history)</MenuHeader>
<RoomHistoryVisibility roomId={roomId} />
</div>
</>
-8
View File
@@ -1,6 +1,4 @@
@use '../../partials/flex';
@use '../../partials/screen';
@use '../../partials/dir';
.room-view {
@extend .cp-fx__column;
@@ -20,12 +18,6 @@
box-shadow: var(--bs-popup);
}
& .header {
@include screen.smallerThan(mobileBreakpoint) {
padding: 0 var(--sp-tight);
}
}
&__content-wrapper {
@extend .cp-fx__item-one;
@extend .cp-fx__column;
+1 -5
View File
@@ -21,7 +21,6 @@ import AsyncSearch from '../../../util/AsyncSearch';
import Text from '../../atoms/text/Text';
import ScrollView from '../../atoms/scroll/ScrollView';
import FollowingMembers from '../../molecules/following-members/FollowingMembers';
import { addRecentEmoji } from '../emoji-board/recent';
const commands = [{
name: 'markdown',
@@ -44,9 +43,7 @@ const commands = [{
}, {
name: 'leave',
description: 'Leave current room',
exe: (roomId) => {
roomActions.leave(roomId);
},
exe: (roomId) => roomActions.leave(roomId),
}, {
name: 'invite',
isOptions: true,
@@ -240,7 +237,6 @@ function RoomViewCmdBar({ roomId, roomTimeline, viewEvent }) {
viewEvent.emit('cmd_fired');
}
if (myCmd.prefix === ':') {
if (!myCmd.result.mxc) addRecentEmoji(myCmd.result.unicode);
viewEvent.emit('cmd_fired', {
replace: myCmd.result.mxc ? `:${myCmd.result.shortcode}: ` : myCmd.result.unicode,
});
+1 -1
View File
@@ -10,7 +10,7 @@
&__info {
display: flex;
width: 40px;
@include dir.side(margin, 14px, 10px);
@include dir.side(margin, 10px, 14px);
& > * {
margin: auto;
+14 -50
View File
@@ -8,7 +8,6 @@ import PropTypes from 'prop-types';
import './RoomViewContent.scss';
import dateFormat from 'dateformat';
import { twemojify } from '../../../util/twemojify';
import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons';
@@ -51,54 +50,21 @@ function loadingMsgPlaceholders(key, count = 2) {
);
}
function RoomIntroContainer({ event, timeline }) {
const [, nameForceUpdate] = useForceUpdate();
function genRoomIntro(mEvent, roomTimeline) {
const mx = initMatrix.matrixClient;
const { roomList } = initMatrix;
const { room } = timeline;
const roomTopic = room.currentState.getStateEvents('m.room.topic')[0]?.getContent().topic;
const isDM = roomList.directs.has(timeline.roomId);
let avatarSrc = room.getAvatarUrl(mx.baseUrl, 80, 80, 'crop');
avatarSrc = isDM ? room.getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 80, 80, 'crop') : avatarSrc;
const heading = isDM ? room.name : `Welcome to ${room.name}`;
const topic = twemojify(roomTopic || '', undefined, true);
const nameJsx = twemojify(room.name);
const desc = isDM
? (
<>
This is the beginning of your direct message history with @
<b>{nameJsx}</b>
{'. '}
{topic}
</>
)
: (
<>
{'This is the beginning of the '}
<b>{nameJsx}</b>
{' room. '}
{topic}
</>
);
useEffect(() => {
const handleUpdate = () => nameForceUpdate();
roomList.on(cons.events.roomList.ROOM_PROFILE_UPDATED, handleUpdate);
return () => {
roomList.removeListener(cons.events.roomList.ROOM_PROFILE_UPDATED, handleUpdate);
};
}, []);
const roomTopic = roomTimeline.room.currentState.getStateEvents('m.room.topic')[0]?.getContent().topic;
const isDM = initMatrix.roomList.directs.has(roomTimeline.roomId);
let avatarSrc = roomTimeline.room.getAvatarUrl(mx.baseUrl, 80, 80, 'crop');
avatarSrc = isDM ? roomTimeline.room.getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 80, 80, 'crop') : avatarSrc;
return (
<RoomIntro
roomId={timeline.roomId}
key={mEvent ? mEvent.getId() : 'room-intro'}
roomId={roomTimeline.roomId}
avatarSrc={avatarSrc}
name={room.name}
heading={twemojify(heading)}
desc={desc}
time={event ? `Created at ${dateFormat(event.getDate(), 'dd mmmm yyyy, hh:MM TT')}` : null}
name={roomTimeline.room.name}
heading={`Welcome to ${roomTimeline.room.name}`}
desc={`This is the beginning of ${roomTimeline.room.name} room.${typeof roomTopic !== 'undefined' ? (` Topic: ${roomTopic}`) : ''}`}
time={mEvent ? `Created at ${dateFormat(mEvent.getDate(), 'dd mmmm yyyy, hh:MM TT')}` : null}
/>
);
}
@@ -233,7 +199,7 @@ function usePaginate(
};
roomTimeline.on(cons.events.roomTimeline.PAGINATED, handlePaginatedFromServer);
return () => {
roomTimeline.removeListener(cons.events.roomTimeline.PAGINATED, handlePaginatedFromServer);
roomTimeline.on(cons.events.roomTimeline.PAGINATED, handlePaginatedFromServer);
};
}, [roomTimeline]);
@@ -504,14 +470,12 @@ function RoomViewContent({ eventId, roomTimeline }) {
if (i === 0 && !roomTimeline.canPaginateBackward()) {
if (mEvent.getType() === 'm.room.create') {
tl.push(
<RoomIntroContainer key={mEvent.getId()} event={mEvent} timeline={roomTimeline} />,
);
tl.push(genRoomIntro(mEvent, roomTimeline));
itemCountIndex += 1;
// eslint-disable-next-line no-continue
continue;
} else {
tl.push(<RoomIntroContainer key="room-intro" event={null} timeline={roomTimeline} />);
tl.push(genRoomIntro(undefined, roomTimeline));
itemCountIndex += 1;
}
}
+7 -4
View File
@@ -9,9 +9,10 @@ import { markAsRead } from '../../../client/action/notifications';
import Text from '../../atoms/text/Text';
import Button from '../../atoms/button/Button';
import IconButton from '../../atoms/button/IconButton';
import MessageIC from '../../../../public/res/ic/outlined/message.svg';
import MessageUnreadIC from '../../../../public/res/ic/outlined/message-unread.svg';
import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.svg';
import TickMarkIC from '../../../../public/res/ic/outlined/tick-mark.svg';
import { getUsersActionJsx } from './common';
@@ -110,9 +111,11 @@ function RoomViewFloating({
<Text variant="b2">{getUsersActionJsx(roomId, [...typingMembers], 'typing...')}</Text>
</div>
<div className={`room-view__STB${isAtBottom ? '' : ' room-view__STB--open'}`}>
<Button iconSrc={MessageIC} onClick={handleScrollToBottom}>
<Text variant="b3" weight="medium">Jump to latest</Text>
</Button>
<IconButton
onClick={handleScrollToBottom}
src={ChevronBottomIC}
tooltip="Scroll to Bottom"
/>
</div>
</>
);
+22 -25
View File
@@ -72,54 +72,51 @@
}
}
&__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 {
position: absolute;
@include dir.prop(left, 50%, unset);
@include dir.prop(right, unset, 50%);
@include dir.prop(right, var(--sp-normal), unset);
@include dir.prop(left, unset, var(--sp-normal));
bottom: 0;
border-radius: var(--bo-radius);
box-shadow: var(--bs-surface-border);
background-color: var(--bg-surface-low);
transition: transform 200ms ease-in-out;
transform: translate(-50%, 100%);
transform: translateY(100%) scale(0);
&--open {
transform: translate(-50%, -28px);
transform: translateY(-28px) scale(1);
}
}
&__unread {
position: absolute;
top: var(--sp-extra-tight);
top: 0;
@include dir.prop(left, var(--sp-normal), unset);
@include dir.prop(right, unset, var(--sp-normal));
z-index: 999;
display: none;
width: calc(100% - var(--sp-extra-loose));
box-shadow: 0 0 2px 0 rgba(0, 0, 0, 20%);
background-color: var(--bg-surface);
border-radius: 0 0 var(--bo-radius) var(--bo-radius);
box-shadow: var(--bs-primary-border);
overflow: hidden;
&--open {
display: flex;
}
& button:first-child {
& .btn-primary {
justify-content: flex-start;
border-radius: 0;
padding: 2px var(--sp-tight);
& .ic-raw {
width: 16px;
height: 16px;
}
}
& .btn-primary:first-child {
@extend .cp-fx__item-one;
padding: var(--sp-ultra-tight) var(--sp-extra-tight);
}
}
}
+2 -10
View File
@@ -8,7 +8,7 @@ import { blurOnBubbling } from '../../atoms/button/script';
import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons';
import navigation from '../../../client/state/navigation';
import { toggleRoomSettings, openReusableContextMenu, openNavigation } from '../../../client/action/navigation';
import { toggleRoomSettings, openReusableContextMenu } from '../../../client/action/navigation';
import { togglePeopleDrawer } from '../../../client/action/settings';
import colorMXID from '../../../util/colorMXID';
import { getEventCords } from '../../../util/common';
@@ -25,7 +25,6 @@ import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.s
import SearchIC from '../../../../public/res/ic/outlined/search.svg';
import UserIC from '../../../../public/res/ic/outlined/user.svg';
import VerticalMenuIC from '../../../../public/res/ic/outlined/vertical-menu.svg';
import BackArrowIC from '../../../../public/res/ic/outlined/chevron-left.svg';
import { useForceUpdate } from '../../hooks/useForceUpdate';
@@ -74,12 +73,6 @@ function RoomViewHeader({ roomId }) {
return (
<Header>
<IconButton
src={BackArrowIC}
className="room-header__back-btn"
tooltip="Return to navigation"
onClick={() => openNavigation()}
/>
<button
ref={roomHeaderBtnRef}
className="room-header__btn"
@@ -94,8 +87,7 @@ function RoomViewHeader({ roomId }) {
<RawIcon src={ChevronBottomIC} />
</button>
<IconButton onClick={() => toggleRoomSettings(tabText.SEARCH)} tooltip="Search" src={SearchIC} />
<IconButton className="room-header__drawer-btn" onClick={togglePeopleDrawer} tooltip="People" src={UserIC} />
<IconButton className="room-header__members-btn" onClick={() => toggleRoomSettings(tabText.MEMBERS)} tooltip="Members" src={UserIC} />
<IconButton onClick={togglePeopleDrawer} tooltip="People" src={UserIC} />
<IconButton
onClick={openRoomOptions}
tooltip="Options"
+1 -21
View File
@@ -1,6 +1,5 @@
@use '../../partials/flex';
@use '../../partials/dir';
@use '../../partials/screen';
.room-header__btn {
min-width: 0;
@@ -25,23 +24,4 @@
box-shadow: var(--bs-surface-outline);
outline: none;
}
}
.room-header__drawer-btn {
@include screen.smallerThan(tabletBreakpoint) {
display: none;
}
}
.room-header__members-btn {
@include screen.biggerThan(tabletBreakpoint) {
display: none;
}
}
.room-header__back-btn {
@include dir.side(margin, 0, var(--sp-tight));
@include screen.biggerThan(mobileBreakpoint) {
display: none;
}
}
}
+33 -30
View File
@@ -7,7 +7,6 @@ import navigation from '../../../client/state/navigation';
import AsyncSearch from '../../../util/AsyncSearch';
import { selectRoom, selectTab } from '../../../client/action/navigation';
import { joinRuleToIconSrc } from '../../../util/matrixUtil';
import { roomIdByActivity } from '../../../util/sort';
import Text from '../../atoms/text/Text';
import RawIcon from '../../atoms/system-icons/RawIcon';
@@ -48,24 +47,27 @@ function useVisiblityToggle(setResult) {
return [isOpen, requestClose];
}
function mapRoomIds(roomIds) {
function mapRoomIds(roomIds, type) {
const mx = initMatrix.matrixClient;
const { directs, roomIdToParents } = initMatrix.roomList;
return roomIds.map((roomId) => {
let roomType = type;
if (!roomType) {
roomType = directs.has(roomId) ? 'direct' : 'room';
}
const room = mx.getRoom(roomId);
const parentSet = roomIdToParents.get(roomId);
const parentNames = parentSet ? [] : undefined;
parentSet?.forEach((parentId) => parentNames.push(mx.getRoom(parentId).name));
const parentNames = parentSet
? [...parentSet].map((parentId) => mx.getRoom(parentId).name)
: undefined;
const parents = parentNames ? parentNames.join(', ') : null;
let type = 'room';
if (room.isSpaceRoom()) type = 'space';
else if (directs.has(roomId)) type = 'direct';
return ({
type,
type: roomType,
name: room.name,
parents,
roomId,
@@ -91,32 +93,34 @@ function Search() {
const generateResults = (term) => {
const prefix = term.match(/^[#@*]/)?.[0];
if (term.length > 1) {
asyncSearch.search(prefix ? term.slice(1) : term);
return;
}
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();
const { spaces, rooms, directs } = initMatrix.roomList;
let ids = null;
if (prefix) {
if (prefix === '#') ids = [...rooms];
else if (prefix === '@') ids = [...directs];
else ids = [...spaces];
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 {
ids = [...rooms].concat([...directs], [...spaces]);
asyncSearch.search(prefix ? term.slice(1) : term);
}
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 { recentRooms } = navigation;
handleSearchResults(mapRoomIds(recentRooms).reverse());
handleSearchResults(mapRoomIds(recentRooms).reverse(), '');
};
const handleAfterOpen = () => {
@@ -150,7 +154,6 @@ function Search() {
else {
searchRef.current.value = '';
searchRef.current.focus();
loadRecentRooms();
}
};
@@ -199,7 +202,7 @@ function Search() {
return (
<RawModal
className="search-dialog__modal dialog-modal"
className="search-dialog__model dialog-model"
isOpen={isOpen}
onAfterOpen={handleAfterOpen}
onAfterClose={handleAfterClose}
+1 -1
View File
@@ -1,6 +1,6 @@
@use '../../partials/dir';
.search-dialog__modal {
.search-dialog__model {
--modal-height: 380px;
height: 100%;
background-color: var(--bg-surface);
-117
View File
@@ -1,117 +0,0 @@
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;
@@ -1,12 +0,0 @@
.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
View File
@@ -1,223 +0,0 @@
/* 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;
@@ -1,55 +0,0 @@
.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);
}
}
-262
View File
@@ -1,262 +0,0 @@
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, openEmojiVerification } from '../../../client/action/navigation';
import Text from '../../atoms/text/Text';
import Button from '../../atoms/button/Button';
import IconButton from '../../atoms/button/IconButton';
import Input from '../../atoms/input/Input';
import { MenuHeader } from '../../atoms/context-menu/ContextMenu';
import InfoCard from '../../atoms/card/InfoCard';
import Spinner from '../../atoms/spinner/Spinner';
import SettingTile from '../../molecules/setting-tile/SettingTile';
import PencilIC from '../../../../public/res/ic/outlined/pencil.svg';
import BinIC from '../../../../public/res/ic/outlined/bin.svg';
import InfoIC from '../../../../public/res/ic/outlined/info.svg';
import { authRequest } from './AuthRequest';
import { confirmDialog } from '../../molecules/confirm-dialog/ConfirmDialog';
import { useStore } from '../../hooks/useStore';
import { useDeviceList } from '../../hooks/useDeviceList';
import { useCrossSigningStatus } from '../../hooks/useCrossSigningStatus';
import { accessSecretStorage } from './SecretStorageAccess';
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);
const isMeVerified = isCrossVerified(mx.deviceId);
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 verifyWithKey = async (device) => {
const keyData = await accessSecretStorage('Session verification');
if (!keyData) return;
addToProcessing(device);
await mx.checkOwnCrossSigningTrust();
};
const verifyWithEmojis = async (deviceId) => {
const req = await mx.requestVerification(mx.getUserId(), [deviceId]);
openEmojiVerification(req, { userId: mx.getUserId(), deviceId });
};
const verify = (deviceId, isCurrentDevice) => {
if (isCurrentDevice) {
verifyWithKey(deviceId);
return;
}
verifyWithEmojis(deviceId);
};
const renderDevice = (device, isVerified) => {
const deviceId = device.device_id;
const displayName = device.display_name;
const lastIP = device.last_seen_ip;
const lastTS = device.last_seen_ts;
const isCurrentDevice = mx.deviceId === deviceId;
return (
<SettingTile
key={deviceId}
title={(
<Text style={{ color: isVerified !== false ? '' : 'var(--tc-danger-high)' }}>
{displayName}
<Text variant="b3" span>{`${displayName ? ' — ' : ''}${deviceId}`}</Text>
{isCurrentDevice && <Text span className="device-manage__current-label" variant="b3">Current</Text>}
</Text>
)}
options={
processing.includes(deviceId)
? <Spinner size="small" />
: (
<>
{((isMeVerified && isVerified === false) || (isCurrentDevice && isVerified === false)) && <Button onClick={() => verify(deviceId, isCurrentDevice)} variant="positive">Verify</Button>}
<IconButton size="small" onClick={() => handleRename(device)} src={PencilIC} tooltip="Rename" />
<IconButton size="small" onClick={() => handleRemove(device)} src={BinIC} tooltip="Remove session" />
</>
)
}
content={(
<>
<Text variant="b3">
Last activity
<span style={{ color: 'var(--tc-surface-normal)' }}>
{dateFormat(new Date(lastTS), ' hh:MM TT, dd/mm/yyyy')}
</span>
{lastIP ? ` at ${lastIP}` : ''}
</Text>
{isCurrentDevice && (
<Text style={{ marginTop: 'var(--sp-ultra-tight)' }} variant="b3">
{`Session Key: ${initMatrix.matrixClient.getDeviceEd25519Key().match(/.{1,4}/g).join(' ')}`}
</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, null))}
</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 sessions</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;
@@ -1,46 +0,0 @@
@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;
}
& .setting-tile__options {
display: flex;
align-items: center;
gap: var(--sp-ultra-tight);
& .btn-positive {
padding: 6px var(--sp-tight);
min-width: 0;
}
}
&__current-label {
margin: 0 var(--sp-extra-tight);
padding: 2px var(--sp-ultra-tight);
color: var(--bg-surface);
background-color: var(--tc-surface-low);
border-radius: 4px;
}
&__rename {
padding: var(--sp-normal);
& > *:not(:last-child) {
margin-bottom: var(--sp-normal);
}
&-btn {
display: flex;
gap: var(--sp-normal);
}
}
}
-288
View File
@@ -1,288 +0,0 @@
/* eslint-disable react/prop-types */
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import './KeyBackup.scss';
import { twemojify } from '../../../util/twemojify';
import initMatrix from '../../../client/initMatrix';
import { openReusableDialog } from '../../../client/action/navigation';
import { deletePrivateKey } from '../../../client/state/secretStorageKeys';
import Text from '../../atoms/text/Text';
import Button from '../../atoms/button/Button';
import IconButton from '../../atoms/button/IconButton';
import Spinner from '../../atoms/spinner/Spinner';
import InfoCard from '../../atoms/card/InfoCard';
import SettingTile from '../../molecules/setting-tile/SettingTile';
import { accessSecretStorage } from './SecretStorageAccess';
import InfoIC from '../../../../public/res/ic/outlined/info.svg';
import BinIC from '../../../../public/res/ic/outlined/bin.svg';
import DownloadIC from '../../../../public/res/ic/outlined/download.svg';
import { useStore } from '../../hooks/useStore';
import { useCrossSigningStatus } from '../../hooks/useCrossSigningStatus';
function CreateKeyBackupDialog({ keyData }) {
const [done, setDone] = useState(false);
const mx = initMatrix.matrixClient;
const mountStore = useStore();
const doBackup = async () => {
setDone(false);
let info;
try {
info = await mx.prepareKeyBackupVersion(
null,
{ secureSecretStorage: true },
);
info = await mx.createKeyBackupVersion(info);
await mx.scheduleAllGroupSessionsForBackup();
if (!mountStore.getItem()) return;
setDone(true);
} catch (e) {
deletePrivateKey(keyData.keyId);
await mx.deleteKeyBackupVersion(info.version);
if (!mountStore.getItem()) return;
setDone(null);
}
};
useEffect(() => {
mountStore.setItem(true);
doBackup();
}, []);
return (
<div className="key-backup__create">
{done === false && (
<div>
<Spinner size="small" />
<Text>Creating backup...</Text>
</div>
)}
{done === true && (
<>
<Text variant="h1">{twemojify('✅')}</Text>
<Text>Successfully created backup</Text>
</>
)}
{done === null && (
<>
<Text>Failed to create backup</Text>
<Button onClick={doBackup}>Retry</Button>
</>
)}
</div>
);
}
CreateKeyBackupDialog.propTypes = {
keyData: PropTypes.shape({}).isRequired,
};
function RestoreKeyBackupDialog({ keyData }) {
const [status, setStatus] = useState(false);
const mx = initMatrix.matrixClient;
const mountStore = useStore();
const restoreBackup = async () => {
setStatus(false);
let meBreath = true;
const progressCallback = (progress) => {
if (!progress.successes) return;
if (meBreath === false) return;
meBreath = false;
setTimeout(() => {
meBreath = true;
}, 200);
setStatus({ message: `Restoring backup keys... (${progress.successes}/${progress.total})` });
};
try {
const backupInfo = await mx.getKeyBackupVersion();
const info = await mx.restoreKeyBackupWithSecretStorage(
backupInfo,
undefined,
undefined,
{ progressCallback },
);
if (!mountStore.getItem()) return;
setStatus({ done: `Successfully restored backup keys (${info.imported}/${info.total}).` });
} catch (e) {
if (!mountStore.getItem()) return;
if (e.errcode === 'RESTORE_BACKUP_ERROR_BAD_KEY') {
deletePrivateKey(keyData.keyId);
setStatus({ error: 'Failed to restore backup. Key is invalid!', errorCode: 'BAD_KEY' });
} else {
setStatus({ error: 'Failed to restore backup.', errCode: 'UNKNOWN' });
}
}
};
useEffect(() => {
mountStore.setItem(true);
restoreBackup();
}, []);
return (
<div className="key-backup__restore">
{(status === false || status.message) && (
<div>
<Spinner size="small" />
<Text>{status.message ?? 'Restoring backup keys...'}</Text>
</div>
)}
{status.done && (
<>
<Text variant="h1">{twemojify('✅')}</Text>
<Text>{status.done}</Text>
</>
)}
{status.error && (
<>
<Text>{status.error}</Text>
<Button onClick={restoreBackup}>Retry</Button>
</>
)}
</div>
);
}
RestoreKeyBackupDialog.propTypes = {
keyData: PropTypes.shape({}).isRequired,
};
function DeleteKeyBackupDialog({ requestClose }) {
const [isDeleting, setIsDeleting] = useState(false);
const mx = initMatrix.matrixClient;
const mountStore = useStore();
const deleteBackup = async () => {
mountStore.setItem(true);
setIsDeleting(true);
try {
const backupInfo = await mx.getKeyBackupVersion();
if (backupInfo) await mx.deleteKeyBackupVersion(backupInfo.version);
if (!mountStore.getItem()) return;
requestClose(true);
} catch {
if (!mountStore.getItem()) return;
setIsDeleting(false);
}
};
return (
<div className="key-backup__delete">
<Text variant="h1">{twemojify('🗑')}</Text>
<Text weight="medium">Deleting key backup is permanent.</Text>
<Text>All encrypted messages keys stored on server will be deleted.</Text>
{
isDeleting
? <Spinner size="small" />
: <Button variant="danger" onClick={deleteBackup}>Delete</Button>
}
</div>
);
}
DeleteKeyBackupDialog.propTypes = {
requestClose: PropTypes.func.isRequired,
};
function KeyBackup() {
const mx = initMatrix.matrixClient;
const isCSEnabled = useCrossSigningStatus();
const [keyBackup, setKeyBackup] = useState(undefined);
const mountStore = useStore();
const fetchKeyBackupVersion = async () => {
const info = await mx.getKeyBackupVersion();
if (!mountStore.getItem()) return;
setKeyBackup(info);
};
useEffect(() => {
mountStore.setItem(true);
fetchKeyBackupVersion();
const handleAccountData = (event) => {
if (event.getType() === 'm.megolm_backup.v1') {
fetchKeyBackupVersion();
}
};
mx.on('accountData', handleAccountData);
return () => {
mx.removeListener('accountData', handleAccountData);
};
}, [isCSEnabled]);
const openCreateKeyBackup = async () => {
const keyData = await accessSecretStorage('Create Key Backup');
if (keyData === null) return;
openReusableDialog(
<Text variant="s1" weight="medium">Create Key Backup</Text>,
() => <CreateKeyBackupDialog keyData={keyData} />,
() => fetchKeyBackupVersion(),
);
};
const openRestoreKeyBackup = async () => {
const keyData = await accessSecretStorage('Restore Key Backup');
if (keyData === null) return;
openReusableDialog(
<Text variant="s1" weight="medium">Restore Key Backup</Text>,
() => <RestoreKeyBackupDialog keyData={keyData} />,
);
};
const openDeleteKeyBackup = () => openReusableDialog(
<Text variant="s1" weight="medium">Delete Key Backup</Text>,
(requestClose) => (
<DeleteKeyBackupDialog
requestClose={(isDone) => {
if (isDone) setKeyBackup(null);
requestClose();
}}
/>
),
);
const renderOptions = () => {
if (keyBackup === undefined) return <Spinner size="small" />;
if (keyBackup === null) return <Button variant="primary" onClick={openCreateKeyBackup}>Create Backup</Button>;
return (
<>
<IconButton src={DownloadIC} variant="positive" onClick={openRestoreKeyBackup} tooltip="Restore backup" />
<IconButton src={BinIC} onClick={openDeleteKeyBackup} tooltip="Delete backup" />
</>
);
};
return (
<SettingTile
title="Encrypted messages backup"
content={(
<>
<Text variant="b3">Online backup your encrypted messages keys with your account data in case you lose access to your sessions. Your keys will be secured with a unique Security Key.</Text>
{!isCSEnabled && (
<InfoCard
style={{ marginTop: 'var(--sp-ultra-tight)' }}
rounded
variant="caution"
iconSrc={InfoIC}
title="Setup cross signing to backup your encrypted messages."
/>
)}
</>
)}
options={isCSEnabled ? renderOptions() : null}
/>
);
}
export default KeyBackup;
Loaded 100 of 133 files, more files were not shown because too many files have changed in this diff. Show more