commit 026f835a875d3d567a3d85ece941f2b6f1e310ce Author: unknown Date: Wed Jul 28 18:45:52 2021 +0530 initial commit diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..8ace574 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,5 @@ +webpack.common.js +webpack.dev.js +webpack.prod.js +experiment +node_modules \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..8ad268d --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,24 @@ +module.exports = { + env: { + browser: true, + es2021: true, + }, + extends: [ + 'plugin:react/recommended', + 'airbnb', + ], + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + ecmaVersion: 12, + sourceType: 'module', + }, + plugins: [ + 'react', + ], + rules: { + 'linebreak-style': 0, + 'no-underscore-dangle': 0, + }, +}; diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..1e14f86 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +liberapay: kfiven \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..da55602 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,32 @@ +--- +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. iOS] + - Browser [e.g. chrome, safari] + - Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..bbcbbe7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -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. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..426c4d6 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,24 @@ + + +# Description + +Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. + +Fixes # (issue) + +## 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) +- [ ] This change requires a documentation update + +# Checklist: + +- [ ] My code follows the style guidelines of this project +- [ ] I have performed a self-review of my own code +- [ ] I have commented my code, particularly in hard-to-understand areas +- [ ] I have made corresponding changes to the documentation +- [ ] My changes generate no new warnings \ No newline at end of file diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 0000000..2dd642d --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,3 @@ +# Reporting a Vulnerability + +**If you've found a security vulnerability, please report it to cinnyapp@gmail.com** \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f7efb60 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +experiment +package-lock.json +dist +node_modules +devAssets \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..86a4e4c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,139 @@ + +# Contributing to Cinny + +First off, thanks for taking the time to contribute! ❤️ + +All types of contributions are encouraged and valued. See the [Table of Contents](#table-of-contents) for different ways to help and details about how this project handles them. Please make sure to read the relevant section before making your contribution. It will make it a lot easier for us maintainers and smooth out the experience for all involved. The community looks forward to your contributions. 🎉 + +> And if you like the project, but just don't have time to contribute, that's fine. There are other easy ways to support the project and show your appreciation, which we would also be very happy about: +> - Star the project +> - Tweet about it (tag @cinnyapp) +> - Refer this project in your project's readme +> - Mention the project at local meetups and tell your friends/colleagues +> - [Donate to us](https://liberapay.com/kfiven/donate) + + +## Table of Contents + +- [I Have a Question](#i-have-a-question) +- [I Want To Contribute](#i-want-to-contribute) + - [Reporting Bugs](#reporting-bugs) + - [Suggesting Enhancements](#suggesting-enhancements) + - [Your First Code Contribution](#your-first-code-contribution) +- [Styleguides](#styleguides) + - [Commit Messages](#commit-messages) + - [Coding conventions](#coding-conventions) + +## I Have a Question + +Before you ask a question, it is best to search for existing [Issues](https://github.com/ajbura/cinny/issues) that might help you. In case you have found a suitable issue and still need clarification, you can write your question in this issue. + +If you then still feel the need to ask a question and need clarification, we recommend the following: + +- Ask in our [Matrix room](https://matrix.to/#/#cinny:matrix.org) or [IRC channel](https://web.libera.chat/?channel=#cinny). +- If no one respond in our channel, please open an [Issue](https://github.com/ajbura/cinny/issues/new). +- Provide as much context as you can about what you're running into. +- Provide project and platform versions (nodejs, npm, etc), depending on what seems relevant. + +We will then take care of the issue as soon as possible. + + +## I Want To Contribute + +> ### Legal Notice +> When contributing to this project, you must agree that you have authored 100% of the content, that you have the necessary rights to the content and that the content you contribute may be provided under the project license. + +### Reporting Bugs + + +#### Before Submitting a Bug Report + +A good bug report shouldn't leave others needing to chase you up for more information. Therefore, we ask you to investigate carefully, collect information and describe the issue in detail in your report. Please complete the following steps in advance to help us fix any potential bug as fast as possible. + +- Make sure that you are using the latest version. +- Determine if your bug is really a bug and not an error on your side. If you are looking for support, you might want to check [this section](#i-have-a-question)). +- To see if other users have experienced (and potentially already solved) the same issue you are having, check if there is not already a bug report existing for your bug or error in the [bug tracker](https://github.com/ajbura/cinny/issues?q=label%3Abug). +- Collect information about the bug: + - OS, Platform and Version (Windows, Linux, macOS, x86, ARM) + - Possibly your input and the output + - Can you reliably reproduce the issue? + + +#### How Do I Submit a Good Bug Report? + +> You must never report security related issues, vulnerabilities or bugs to the issue tracker, or elsewhere in public. Instead sensitive bugs must be sent by email to . + +We use GitHub issues to track bugs and errors. If you run into an issue with the project: + +- Open an [Issue](https://github.com/ajbura/cinny/issues/new). (Since we can't be sure at this point whether it is a bug or not, we ask you not to talk about a bug yet and not to label the issue.) +- Explain the behavior you would expect and the actual behavior. +- Please provide as much context as possible and describe the *reproduction steps* that someone else can follow to recreate the issue on their own. For good bug reports you should isolate the problem and create a reduced test case. +- Provide the information you collected in the previous section. + +Once it's filed: + +- The project team will label the issue accordingly. +- A team member will try to reproduce the issue with your provided steps. If there are no reproduction steps or no obvious way to reproduce the issue, the team will ask you for those steps and mark the issue as `needs-repro`. Bugs with the `needs-repro` tag will not be addressed until they are reproduced. +- If the team is able to reproduce the issue, it will be marked `needs-fix`, as well as possibly other tags (such as `critical`), and the issue will be left to be [implemented by someone](#your-first-code-contribution). + + +### Suggesting Enhancements + +This section guides you through submitting an enhancement suggestion for Cinny, **including completely new features and minor improvements to existing functionality**. Following these guidelines will help maintainers and the community to understand your suggestion and find related suggestions. + + +#### Before Submitting an Enhancement + +- Make sure that you are using the latest version. +- Perform a [search](https://github.com/ajbura/cinny/issues) to see if the enhancement has already been suggested. If it has, add a comment to the existing issue instead of opening a new one. +- Find out whether your idea fits with the scope and aims of the project. It's up to you to make a strong case to convince the project's developers of the merits of this feature. Keep in mind that we want features that will be useful to the majority of our users and not just a small subset. + + +#### How Do I Submit a Good Enhancement Suggestion? + +Enhancement suggestions are tracked as [GitHub issues](https://github.com/ajbura/cinny/issues). + +- Use a **clear and descriptive title** for the issue to identify the suggestion. +- Provide a **step-by-step description of the suggested enhancement** in as many details as possible. +- **Describe the current behavior** and **explain which behavior you expected to see instead** and why. At this point you can also tell which alternatives do not work for you. +- You may want to **include screenshots and animated GIFs** which help you demonstrate the steps or point out the part which the suggestion is related to. You can use [this tool](https://www.cockos.com/licecap/) to record GIFs on macOS and Windows, and [this tool](https://github.com/colinkeenan/silentcast) on Linux. +- **Explain why this enhancement would be useful** to most Cinny users. You may also want to point out the other projects that solved it better and which could serve as inspiration. + +### Your First Code Contribution +Please send a [GitHub Pull Request to cinny](https://github.com/ajbura/cinny/pull/new/master) with a clear list of what you've done (read more about [pull requests](http://help.github.com/pull-requests/)). + +When proposing a PR: + +- Describe what problem it solves, what side effects come with it. +- Adding some screenshots will help. +- Add some documentation if relevant. +- Add some comments around blocks/functions if relevant. + +Some reasons why a PR could be refused: + +- PR is not meeting one of the previous points. +- PR is not meeting project goals. +- PR is conflicting with another PR, and the latter is being preferred. +- PR slows down Cinny, or it obviously does too many + computations for the task being accomplished. It needs to be optimized. +- PR is using copy-n-paste-programming. It needs to be factorized. +- PR contains commented code: remove it. +- PR adds new features or changes the behavior of Cinny without + having be approved by the current project owners first. +- PR is too big and needs to be splitted in many smaller ones. +- PR contains unnecessary "space/indentations fixes". + +If a PR stays in a stale/WIP/POC state for too long, it may be closed +at any time. + + +## Styleguides +### Commit Messages +Always write a clear log message for your commits. One-line messages are fine for small changes, but bigger changes should look like this: + + $ git commit -m "A brief summary of the commit + > + > A paragraph describing what changed and its impact." + +### Coding conventions +We use [ESLint](https://eslint.org/) for clean and stylistically consistent code syntax. \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ff85e6a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Ajay Bura (ajbura) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..b3cc043 --- /dev/null +++ b/README.md @@ -0,0 +1,11 @@ +# Cinny + +## Table of Contents + +- [About](#about) +- [Getting Started](https://cinny.in) +- [Contributing](./CONTRIBUTING.md) + +## About + +Cinny is a [matrix](https://matrix.org) client focusing primarily on simple, elegant and secure interface. diff --git a/olm.wasm b/olm.wasm new file mode 100644 index 0000000..97cce63 Binary files /dev/null and b/olm.wasm differ diff --git a/package.json b/package.json new file mode 100644 index 0000000..ad211e2 --- /dev/null +++ b/package.json @@ -0,0 +1,76 @@ +{ + "name": "cinny", + "version": "1.0.0", + "description": "Organized and powerful matrix client.", + "main": "index.js", + "engines": { + "npm": "6.14.11", + "node": "14.6.0" + }, + "scripts": { + "start": "webpack serve --config ./webpack.dev.js --open", + "build": "webpack --config ./webpack.prod.js" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "@tippyjs/react": "^4.2.5", + "babel-polyfill": "^6.26.0", + "browser-encrypt-attachment": "^0.3.0", + "dateformat": "^4.5.1", + "emojibase-data": "^6.2.0", + "flux": "^4.0.1", + "fuse.js": "^6.4.6", + "html-react-parser": "^1.2.7", + "linkifyjs": "^3.0.0-beta.3", + "matrix-js-sdk": "^11.2.0", + "olm": "https://packages.matrix.org/npm/olm/olm-3.2.1.tgz", + "prop-types": "^15.7.2", + "react": "^17.0.2", + "react-autosize-textarea": "^7.1.0", + "react-dom": "^17.0.2", + "react-google-recaptcha": "^2.1.0", + "react-markdown": "^6.0.1", + "react-modal": "^3.13.1", + "react-router-dom": "^5.2.0", + "react-syntax-highlighter": "^15.4.3", + "remark-gfm": "^1.0.0", + "tippy.js": "^6.3.1", + "twemoji": "^13.1.0" + }, + "devDependencies": { + "@babel/core": "^7.13.13", + "@babel/preset-env": "^7.13.12", + "@babel/preset-react": "^7.13.13", + "babel-loader": "^8.2.2", + "browserify-fs": "^1.0.0", + "buffer": "^6.0.3", + "clean-webpack-plugin": "^3.0.0", + "crypto-browserify": "^3.12.0", + "css-loader": "^5.2.0", + "css-minimizer-webpack-plugin": "^1.3.0", + "eslint": "^7.23.0", + "eslint-config-airbnb": "^18.2.1", + "eslint-plugin-import": "^2.22.1", + "eslint-plugin-jsx-a11y": "^6.4.1", + "eslint-plugin-react": "^7.23.1", + "eslint-plugin-react-hooks": "^4.2.0", + "favicons": "^6.2.1", + "favicons-webpack-plugin": "^5.0.2", + "file-loader": "^6.2.0", + "html-loader": "^2.1.2", + "html-webpack-plugin": "^5.3.1", + "mini-css-extract-plugin": "^1.4.0", + "path-browserify": "^1.0.1", + "sass": "^1.32.8", + "sass-loader": "^11.0.1", + "stream-browserify": "^3.0.0", + "style-loader": "^2.0.0", + "util": "^0.12.3", + "webpack": "^5.28.0", + "webpack-cli": "^4.5.0", + "webpack-dev-server": "^3.11.2", + "webpack-merge": "^5.7.3" + } +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..280e6dc --- /dev/null +++ b/public/index.html @@ -0,0 +1,22 @@ + + + + + + + + Cinny + + + + + + + + + + + +
+ + \ No newline at end of file diff --git a/public/res/ic/outlined/add-user.svg b/public/res/ic/outlined/add-user.svg new file mode 100644 index 0000000..c3803d8 --- /dev/null +++ b/public/res/ic/outlined/add-user.svg @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/public/res/ic/outlined/ball.svg b/public/res/ic/outlined/ball.svg new file mode 100644 index 0000000..d4b89ff --- /dev/null +++ b/public/res/ic/outlined/ball.svg @@ -0,0 +1,12 @@ + + + + + + diff --git a/public/res/ic/outlined/bell.svg b/public/res/ic/outlined/bell.svg new file mode 100644 index 0000000..d3d2f6d --- /dev/null +++ b/public/res/ic/outlined/bell.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/public/res/ic/outlined/bulb.svg b/public/res/ic/outlined/bulb.svg new file mode 100644 index 0000000..00e8088 --- /dev/null +++ b/public/res/ic/outlined/bulb.svg @@ -0,0 +1,10 @@ + + + + + + diff --git a/public/res/ic/outlined/chevron-bottom.svg b/public/res/ic/outlined/chevron-bottom.svg new file mode 100644 index 0000000..5562b7a --- /dev/null +++ b/public/res/ic/outlined/chevron-bottom.svg @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/public/res/ic/outlined/chevron-left.svg b/public/res/ic/outlined/chevron-left.svg new file mode 100644 index 0000000..ba9e12c --- /dev/null +++ b/public/res/ic/outlined/chevron-left.svg @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/public/res/ic/outlined/chevron-right.svg b/public/res/ic/outlined/chevron-right.svg new file mode 100644 index 0000000..7f6a806 --- /dev/null +++ b/public/res/ic/outlined/chevron-right.svg @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/public/res/ic/outlined/chevron-top.svg b/public/res/ic/outlined/chevron-top.svg new file mode 100644 index 0000000..f5948fe --- /dev/null +++ b/public/res/ic/outlined/chevron-top.svg @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/public/res/ic/outlined/circle-plus.svg b/public/res/ic/outlined/circle-plus.svg new file mode 100644 index 0000000..41690a0 --- /dev/null +++ b/public/res/ic/outlined/circle-plus.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/public/res/ic/outlined/coin.svg b/public/res/ic/outlined/coin.svg new file mode 100644 index 0000000..025424e --- /dev/null +++ b/public/res/ic/outlined/coin.svg @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/public/res/ic/outlined/cross.svg b/public/res/ic/outlined/cross.svg new file mode 100644 index 0000000..0acda88 --- /dev/null +++ b/public/res/ic/outlined/cross.svg @@ -0,0 +1,7 @@ + + + + + + diff --git a/public/res/ic/outlined/cup.svg b/public/res/ic/outlined/cup.svg new file mode 100644 index 0000000..8921e2c --- /dev/null +++ b/public/res/ic/outlined/cup.svg @@ -0,0 +1,9 @@ + + + + + + diff --git a/public/res/ic/outlined/dog.svg b/public/res/ic/outlined/dog.svg new file mode 100644 index 0000000..3b25295 --- /dev/null +++ b/public/res/ic/outlined/dog.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + diff --git a/public/res/ic/outlined/download.svg b/public/res/ic/outlined/download.svg new file mode 100644 index 0000000..677014f --- /dev/null +++ b/public/res/ic/outlined/download.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + diff --git a/public/res/ic/outlined/emoji.svg b/public/res/ic/outlined/emoji.svg new file mode 100644 index 0000000..0daac87 --- /dev/null +++ b/public/res/ic/outlined/emoji.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/public/res/ic/outlined/explore.svg b/public/res/ic/outlined/explore.svg new file mode 100644 index 0000000..7cc2a47 --- /dev/null +++ b/public/res/ic/outlined/explore.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/public/res/ic/outlined/external.svg b/public/res/ic/outlined/external.svg new file mode 100644 index 0000000..92b007c --- /dev/null +++ b/public/res/ic/outlined/external.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + diff --git a/public/res/ic/outlined/file.svg b/public/res/ic/outlined/file.svg new file mode 100644 index 0000000..d6a2a27 --- /dev/null +++ b/public/res/ic/outlined/file.svg @@ -0,0 +1,7 @@ + + + + + + diff --git a/public/res/ic/outlined/flag.svg b/public/res/ic/outlined/flag.svg new file mode 100644 index 0000000..8fce98d --- /dev/null +++ b/public/res/ic/outlined/flag.svg @@ -0,0 +1,8 @@ + + + + + + diff --git a/public/res/ic/outlined/hash-lock.svg b/public/res/ic/outlined/hash-lock.svg new file mode 100644 index 0000000..ae263ce --- /dev/null +++ b/public/res/ic/outlined/hash-lock.svg @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/public/res/ic/outlined/hash-plus.svg b/public/res/ic/outlined/hash-plus.svg new file mode 100644 index 0000000..69737fd --- /dev/null +++ b/public/res/ic/outlined/hash-plus.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/public/res/ic/outlined/hash-search.svg b/public/res/ic/outlined/hash-search.svg new file mode 100644 index 0000000..f135e89 --- /dev/null +++ b/public/res/ic/outlined/hash-search.svg @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/public/res/ic/outlined/hash-shield.svg b/public/res/ic/outlined/hash-shield.svg new file mode 100644 index 0000000..dfd344b --- /dev/null +++ b/public/res/ic/outlined/hash-shield.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/public/res/ic/outlined/hash.svg b/public/res/ic/outlined/hash.svg new file mode 100644 index 0000000..dcb8b96 --- /dev/null +++ b/public/res/ic/outlined/hash.svg @@ -0,0 +1,7 @@ + + + + + + diff --git a/public/res/ic/outlined/heart.svg b/public/res/ic/outlined/heart.svg new file mode 100644 index 0000000..c5b940b --- /dev/null +++ b/public/res/ic/outlined/heart.svg @@ -0,0 +1,10 @@ + + + + + + diff --git a/public/res/ic/outlined/home.svg b/public/res/ic/outlined/home.svg new file mode 100644 index 0000000..3c7a02d --- /dev/null +++ b/public/res/ic/outlined/home.svg @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/public/res/ic/outlined/inbox.svg b/public/res/ic/outlined/inbox.svg new file mode 100644 index 0000000..6543587 --- /dev/null +++ b/public/res/ic/outlined/inbox.svg @@ -0,0 +1,9 @@ + + + + + + diff --git a/public/res/ic/outlined/invite-arrow.svg b/public/res/ic/outlined/invite-arrow.svg new file mode 100644 index 0000000..370bf8e --- /dev/null +++ b/public/res/ic/outlined/invite-arrow.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/public/res/ic/outlined/invite-cancel-arrow.svg b/public/res/ic/outlined/invite-cancel-arrow.svg new file mode 100644 index 0000000..795a773 --- /dev/null +++ b/public/res/ic/outlined/invite-cancel-arrow.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/public/res/ic/outlined/invite.svg b/public/res/ic/outlined/invite.svg new file mode 100644 index 0000000..3896e15 --- /dev/null +++ b/public/res/ic/outlined/invite.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/public/res/ic/outlined/join-arrow.svg b/public/res/ic/outlined/join-arrow.svg new file mode 100644 index 0000000..90cfa65 --- /dev/null +++ b/public/res/ic/outlined/join-arrow.svg @@ -0,0 +1,8 @@ + + + + + + diff --git a/public/res/ic/outlined/leave-arrow.svg b/public/res/ic/outlined/leave-arrow.svg new file mode 100644 index 0000000..a51ac1d --- /dev/null +++ b/public/res/ic/outlined/leave-arrow.svg @@ -0,0 +1,8 @@ + + + + + + diff --git a/public/res/ic/outlined/lock.svg b/public/res/ic/outlined/lock.svg new file mode 100644 index 0000000..77021f0 --- /dev/null +++ b/public/res/ic/outlined/lock.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/public/res/ic/outlined/pause.svg b/public/res/ic/outlined/pause.svg new file mode 100644 index 0000000..c312613 --- /dev/null +++ b/public/res/ic/outlined/pause.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + diff --git a/public/res/ic/outlined/peace.svg b/public/res/ic/outlined/peace.svg new file mode 100644 index 0000000..8a7c81a --- /dev/null +++ b/public/res/ic/outlined/peace.svg @@ -0,0 +1,9 @@ + + + + + + diff --git a/public/res/ic/outlined/photo.svg b/public/res/ic/outlined/photo.svg new file mode 100644 index 0000000..af01a33 --- /dev/null +++ b/public/res/ic/outlined/photo.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/public/res/ic/outlined/play.svg b/public/res/ic/outlined/play.svg new file mode 100644 index 0000000..87b3a8f --- /dev/null +++ b/public/res/ic/outlined/play.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/public/res/ic/outlined/plus.svg b/public/res/ic/outlined/plus.svg new file mode 100644 index 0000000..ce37594 --- /dev/null +++ b/public/res/ic/outlined/plus.svg @@ -0,0 +1,7 @@ + + + + + + diff --git a/public/res/ic/outlined/power.svg b/public/res/ic/outlined/power.svg new file mode 100644 index 0000000..8aeb6db --- /dev/null +++ b/public/res/ic/outlined/power.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/public/res/ic/outlined/reply-arrow.svg b/public/res/ic/outlined/reply-arrow.svg new file mode 100644 index 0000000..3cda01c --- /dev/null +++ b/public/res/ic/outlined/reply-arrow.svg @@ -0,0 +1,7 @@ + + + + + + diff --git a/public/res/ic/outlined/search.svg b/public/res/ic/outlined/search.svg new file mode 100644 index 0000000..75dd632 --- /dev/null +++ b/public/res/ic/outlined/search.svg @@ -0,0 +1,8 @@ + + + + + + diff --git a/public/res/ic/outlined/send.svg b/public/res/ic/outlined/send.svg new file mode 100644 index 0000000..aa48713 --- /dev/null +++ b/public/res/ic/outlined/send.svg @@ -0,0 +1,7 @@ + + + + + + diff --git a/public/res/ic/outlined/settings.svg b/public/res/ic/outlined/settings.svg new file mode 100644 index 0000000..ee640b3 --- /dev/null +++ b/public/res/ic/outlined/settings.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + diff --git a/public/res/ic/outlined/shield.svg b/public/res/ic/outlined/shield.svg new file mode 100644 index 0000000..9bb46fa --- /dev/null +++ b/public/res/ic/outlined/shield.svg @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/public/res/ic/outlined/space-lock.svg b/public/res/ic/outlined/space-lock.svg new file mode 100644 index 0000000..b15705c --- /dev/null +++ b/public/res/ic/outlined/space-lock.svg @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/public/res/ic/outlined/space.svg b/public/res/ic/outlined/space.svg new file mode 100644 index 0000000..a4b54b3 --- /dev/null +++ b/public/res/ic/outlined/space.svg @@ -0,0 +1,10 @@ + + + + + + diff --git a/public/res/ic/outlined/sun.svg b/public/res/ic/outlined/sun.svg new file mode 100644 index 0000000..d8ed06f --- /dev/null +++ b/public/res/ic/outlined/sun.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/res/ic/outlined/tick-mark.svg b/public/res/ic/outlined/tick-mark.svg new file mode 100644 index 0000000..8e76ed5 --- /dev/null +++ b/public/res/ic/outlined/tick-mark.svg @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/public/res/ic/outlined/user.svg b/public/res/ic/outlined/user.svg new file mode 100644 index 0000000..6756a1b --- /dev/null +++ b/public/res/ic/outlined/user.svg @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/public/res/ic/outlined/vertical-menu.svg b/public/res/ic/outlined/vertical-menu.svg new file mode 100644 index 0000000..ec5c544 --- /dev/null +++ b/public/res/ic/outlined/vertical-menu.svg @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/public/res/ic/outlined/vlc.svg b/public/res/ic/outlined/vlc.svg new file mode 100644 index 0000000..8a2b844 --- /dev/null +++ b/public/res/ic/outlined/vlc.svg @@ -0,0 +1,8 @@ + + + + + + diff --git a/public/res/ic/outlined/volume-full.svg b/public/res/ic/outlined/volume-full.svg new file mode 100644 index 0000000..20419e7 --- /dev/null +++ b/public/res/ic/outlined/volume-full.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + diff --git a/public/res/ic/outlined/volume-mute.svg b/public/res/ic/outlined/volume-mute.svg new file mode 100644 index 0000000..beb0677 --- /dev/null +++ b/public/res/ic/outlined/volume-mute.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/public/res/svg/cinny.svg b/public/res/svg/cinny.svg new file mode 100644 index 0000000..8701d67 --- /dev/null +++ b/public/res/svg/cinny.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/res/svg/matrix-logo.svg b/public/res/svg/matrix-logo.svg new file mode 100644 index 0000000..93c0eea --- /dev/null +++ b/public/res/svg/matrix-logo.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/app/atoms/avatar/Avatar.jsx b/src/app/atoms/avatar/Avatar.jsx new file mode 100644 index 0000000..616cea6 --- /dev/null +++ b/src/app/atoms/avatar/Avatar.jsx @@ -0,0 +1,57 @@ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import './Avatar.scss'; + +import Text from '../text/Text'; +import RawIcon from '../system-icons/RawIcon'; + +function Avatar({ + text, bgColor, iconSrc, imageSrc, size, +}) { + const [image, updateImage] = useState(imageSrc); + let textSize = 's1'; + if (size === 'large') textSize = 'h1'; + if (size === 'small') textSize = 'b1'; + if (size === 'extra-small') textSize = 'b3'; + + useEffect(() => updateImage(imageSrc), [imageSrc]); + + return ( +
+ { + image !== null + ? updateImage(null)} alt="avatar" /> + : ( + + { + iconSrc !== null + ? + : text !== null && {text} + } + + ) + } +
+ ); +} + +Avatar.defaultProps = { + text: null, + bgColor: 'transparent', + iconSrc: null, + imageSrc: null, + size: 'normal', +}; + +Avatar.propTypes = { + text: PropTypes.string, + bgColor: PropTypes.string, + iconSrc: PropTypes.string, + imageSrc: PropTypes.string, + size: PropTypes.oneOf(['large', 'normal', 'small', 'extra-small']), +}; + +export default Avatar; diff --git a/src/app/atoms/avatar/Avatar.scss b/src/app/atoms/avatar/Avatar.scss new file mode 100644 index 0000000..d7ddc6e --- /dev/null +++ b/src/app/atoms/avatar/Avatar.scss @@ -0,0 +1,52 @@ +.avatar-container { + display: inline-flex; + width: 42px; + height: 42px; + border-radius: var(--bo-radius); + position: relative; + + &__large { + width: var(--av-large); + height: var(--av-large); + } + &__normal { + width: var(--av-normal); + height: var(--av-normal); + } + + &__small { + width: var(--av-small); + height: var(--av-small); + } + + &__extra-small { + width: var(--av-extra-small); + height: var(--av-extra-small); + } + + + img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: inherit; + } + + .avatar__bordered { + box-shadow: var(--bs-surface-border); + } + + .avatar__border { + position: absolute; + top: 0; + left: 0; + + width: 100%; + height: 100%; + border-radius: inherit; + + .text { + color: var(--tc-primary-high); + } + } +} \ No newline at end of file diff --git a/src/app/atoms/badge/NotificationBadge.jsx b/src/app/atoms/badge/NotificationBadge.jsx new file mode 100644 index 0000000..846f99a --- /dev/null +++ b/src/app/atoms/badge/NotificationBadge.jsx @@ -0,0 +1,28 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import './NotificationBadge.scss'; + +import Text from '../text/Text'; + +function NotificationBadge({ alert, children }) { + const notificationClass = alert ? ' notification-badge--alert' : ''; + return ( +
+ {children} +
+ ); +} + +NotificationBadge.defaultProps = { + alert: false, +}; + +NotificationBadge.propTypes = { + alert: PropTypes.bool, + children: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number, + ]).isRequired, +}; + +export default NotificationBadge; diff --git a/src/app/atoms/badge/NotificationBadge.scss b/src/app/atoms/badge/NotificationBadge.scss new file mode 100644 index 0000000..797edae --- /dev/null +++ b/src/app/atoms/badge/NotificationBadge.scss @@ -0,0 +1,18 @@ +.notification-badge { + min-width: 18px; + padding: 1px var(--sp-ultra-tight); + background-color: var(--tc-surface-low); + border-radius: 9px; + + .text { + color: var(--bg-surface-low); + text-align: center; + } + + &--alert { + background-color: var(--bg-positive); + .text { + color: white; + } + } +} \ No newline at end of file diff --git a/src/app/atoms/button/Button.jsx b/src/app/atoms/button/Button.jsx new file mode 100644 index 0000000..b6e4a0f --- /dev/null +++ b/src/app/atoms/button/Button.jsx @@ -0,0 +1,47 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import './Button.scss'; + +import Text from '../text/Text'; +import RawIcon from '../system-icons/RawIcon'; +import { blurOnBubbling } from './script'; + +function Button({ + id, variant, iconSrc, type, onClick, children, disabled, +}) { + const iconClass = (iconSrc === null) ? '' : `btn-${variant}--icon`; + return ( + + ); +} + +Button.defaultProps = { + id: '', + variant: 'surface', + iconSrc: null, + type: 'button', + onClick: null, + disabled: false, +}; + +Button.propTypes = { + id: PropTypes.string, + variant: PropTypes.oneOf(['surface', 'primary', 'caution', 'danger']), + iconSrc: PropTypes.string, + type: PropTypes.oneOf(['button', 'submit']), + onClick: PropTypes.func, + children: PropTypes.node.isRequired, + disabled: PropTypes.bool, +}; + +export default Button; diff --git a/src/app/atoms/button/Button.scss b/src/app/atoms/button/Button.scss new file mode 100644 index 0000000..224c634 --- /dev/null +++ b/src/app/atoms/button/Button.scss @@ -0,0 +1,83 @@ +@use 'state'; + +.btn-surface, +.btn-primary, +.btn-caution, +.btn-danger { + display: inline-flex; + align-items: center; + justify-content: center; + + min-width: 80px; + padding: var(--sp-extra-tight) var(--sp-normal); + background-color: transparent; + border: none; + border-radius: var(--bo-radius); + cursor: pointer; + @include state.disabled; + + &--icon { + padding: { + left: var(--sp-tight); + right: var(--sp-loose); + } + + [dir=rtl] & { + padding: { + left: var(--sp-loose); + right: var(--sp-tight); + } + } + + .ic-raw { + margin-right: var(--sp-extra-tight); + + [dir=rtl] & { + margin: { + right: 0; + left: var(--sp-extra-tight); + } + } + } + } +} + +@mixin color($textColor, $iconColor) { + .text { + color: $textColor; + } + .ic-raw { + background-color: $iconColor; + } +} + + +.btn-surface { + box-shadow: var(--bs-surface-border); + @include color(var(--tc-surface-high), var(--ic-surface-normal)); + @include state.hover(var(--bg-surface-hover)); + @include state.focus(var(--bs-surface-outline)); + @include state.active(var(--bg-surface-active)); +} + +.btn-primary { + background-color: var(--bg-primary); + @include color(var(--tc-primary-high), var(--ic-primary-normal)); + @include state.hover(var(--bg-primary-hover)); + @include state.focus(var(--bs-primary-outline)); + @include state.active(var(--bg-primary-active)); +} +.btn-caution { + box-shadow: var(--bs-caution-border); + @include color(var(--tc-caution-high), var(--ic-caution-normal)); + @include state.hover(var(--bg-caution-hover)); + @include state.focus(var(--bs-caution-outline)); + @include state.active(var(--bg-caution-active)); +} +.btn-danger { + box-shadow: var(--bs-danger-border); + @include color(var(--tc-danger-high), var(--ic-danger-normal)); + @include state.hover(var(--bg-danger-hover)); + @include state.focus(var(--bs-danger-outline)); + @include state.active(var(--bg-danger-active)); +} \ No newline at end of file diff --git a/src/app/atoms/button/IconButton.jsx b/src/app/atoms/button/IconButton.jsx new file mode 100644 index 0000000..cda6f98 --- /dev/null +++ b/src/app/atoms/button/IconButton.jsx @@ -0,0 +1,60 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import './IconButton.scss'; + +import Tippy from '@tippyjs/react'; +import RawIcon from '../system-icons/RawIcon'; +import { blurOnBubbling } from './script'; +import Text from '../text/Text'; + +// TODO: +// 1. [done] an icon only button have "src" +// 2. have multiple variant +// 3. [done] should have a smart accessibility "label" arial-label +// 4. [done] have size as RawIcon + +const IconButton = React.forwardRef(({ + variant, size, type, + tooltip, tooltipPlacement, src, onClick, +}, ref) => ( + {tooltip}} + className="ic-btn-tippy" + touch="hold" + arrow={false} + maxWidth={250} + placement={tooltipPlacement} + delay={[0, 0]} + duration={[100, 0]} + > + + +)); + +IconButton.defaultProps = { + variant: 'surface', + size: 'normal', + type: 'button', + tooltipPlacement: 'top', + onClick: null, +}; + +IconButton.propTypes = { + variant: PropTypes.oneOf(['surface']), + size: PropTypes.oneOf(['normal', 'small', 'extra-small']), + type: PropTypes.oneOf(['button', 'submit']), + tooltip: PropTypes.string.isRequired, + tooltipPlacement: PropTypes.oneOf(['top', 'right', 'bottom', 'left']), + src: PropTypes.string.isRequired, + onClick: PropTypes.func, +}; + +export default IconButton; diff --git a/src/app/atoms/button/IconButton.scss b/src/app/atoms/button/IconButton.scss new file mode 100644 index 0000000..7bd327d --- /dev/null +++ b/src/app/atoms/button/IconButton.scss @@ -0,0 +1,45 @@ +@use 'state'; + +.ic-btn-surface, +.ic-btn-primary, +.ic-btn-caution, +.ic-btn-danger { + padding: var(--sp-extra-tight); + border: none; + border-radius: var(--bo-radius); + background-color: transparent; + font-size: 0; + line-height: 0; + cursor: pointer; + @include state.disabled; +} + +@mixin color($color) { + .ic-raw { + background-color: $color; + } +} +@mixin focus($color) { + &:focus { + outline: none; + background-color: $color; + } +} + +.ic-btn-surface { + @include color(var(--ic-surface-normal)); + @include state.hover(var(--bg-surface-hover)); + @include focus(var(--bg-surface-hover)); + @include state.active(var(--bg-surface-active)); +} + +.ic-btn-tippy { + padding: var(--sp-extra-tight) var(--sp-normal); + background-color: var(--bg-tooltip); + border-radius: var(--bo-radius); + box-shadow: var(--bs-popup); + + .text { + color: var(--tc-tooltip); + } +} \ No newline at end of file diff --git a/src/app/atoms/button/Toggle.jsx b/src/app/atoms/button/Toggle.jsx new file mode 100644 index 0000000..5d83c49 --- /dev/null +++ b/src/app/atoms/button/Toggle.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import './Toggle.scss'; + +function Toggle({ isActive, onToggle }) { + return ( + // eslint-disable-next-line jsx-a11y/control-has-associated-label + + + ); +} + +MenuItem.defaultProps = { + variant: 'surface', + iconSrc: 'none', + type: 'button', +}; + +MenuItem.propTypes = { + variant: PropTypes.oneOf(['surface', 'caution', 'danger']), + iconSrc: PropTypes.string, + type: PropTypes.oneOf(['button', 'submit']), + onClick: PropTypes.func.isRequired, + children: PropTypes.string.isRequired, +}; + +function MenuBorder() { + return
; +} + +export { + ContextMenu as default, MenuHeader, MenuItem, MenuBorder, +}; diff --git a/src/app/atoms/context-menu/ContextMenu.scss b/src/app/atoms/context-menu/ContextMenu.scss new file mode 100644 index 0000000..82a645b --- /dev/null +++ b/src/app/atoms/context-menu/ContextMenu.scss @@ -0,0 +1,71 @@ +.context-menu { + background-color: var(--bg-surface); + box-shadow: var(--bs-popup); + border-radius: var(--bo-radius); + overflow: hidden; + + &:focus { + outline: none; + } + & .tippy-content > div > .scrollbar { + max-height: 90vh; + } +} + +.context-menu__click-wrapper { + display: inline-flex; + + &:focus { + outline: none; + } +} + +.context-menu__header { + height: 34px; + padding: 0 var(--sp-tight); + margin-bottom: var(--sp-ultra-tight); + display: flex; + align-items: center; + border-bottom: 1px solid var(--bg-surface-border); + + .text { + color: var(--tc-surface-low); + } + + &:not(:first-child) { + margin-top: var(--sp-normal); + border-top: 1px solid var(--bg-surface-border); + } +} + +.context-menu__item { + button[class^="btn"] { + width: 100%; + justify-content: start; + border-radius: 0; + box-shadow: none; + + .text:first-child { + margin: { + left: calc(var(--ic-small) + var(--sp-ultra-tight)); + right: var(--sp-extra-tight); + } + + [dir=rtl] & { + margin: { + left: var(--sp-extra-tight); + right: calc(var(--ic-small) + var(--sp-ultra-tight)); + } + } + } + } + .btn-surface:focus { + background-color: var(--bg-surface-hover); + } + .btn-caution:focus { + background-color: var(--bg-caution-hover); + } + .btn-danger:focus { + background-color: var(--bg-danger-hover); + } +} \ No newline at end of file diff --git a/src/app/atoms/divider/Divider.jsx b/src/app/atoms/divider/Divider.jsx new file mode 100644 index 0000000..479fcec --- /dev/null +++ b/src/app/atoms/divider/Divider.jsx @@ -0,0 +1,29 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import './Divider.scss'; + +import Text from '../text/Text'; + +function Divider({ text, variant }) { + const dividerClass = ` divider--${variant}`; + return ( +
+ {text !== false && {text}} +
+ ); +} + +Divider.defaultProps = { + text: false, + variant: 'surface', +}; + +Divider.propTypes = { + text: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.bool, + ]), + variant: PropTypes.oneOf(['surface', 'primary', 'caution', 'danger']), +}; + +export default Divider; diff --git a/src/app/atoms/divider/Divider.scss b/src/app/atoms/divider/Divider.scss new file mode 100644 index 0000000..ded59af --- /dev/null +++ b/src/app/atoms/divider/Divider.scss @@ -0,0 +1,68 @@ +.divider { + --local-divider-color: var(--bg-surface-border); + + margin: var(--sp-extra-tight) var(--sp-normal); + margin-right: var(--sp-extra-tight); + display: flex; + align-items: center; + position: relative; + + &::before { + content: ""; + display: inline-block; + flex: 1; + margin-left: calc(var(--av-small) + var(--sp-tight)); + border-bottom: 1px solid var(--local-divider-color); + opacity: 0.18; + + [dir=rtl] & { + margin: { + left: 0; + right: calc(var(--av-small) + var(--sp-tight)); + } + } + } + + &__text { + margin-left: var(--sp-normal); + } + + [dir=rtl] & { + margin: { + left: var(--sp-extra-tight); + right: var(--sp-normal); + } + + &__text { + margin: { + left: 0; + right: var(--sp-normal); + } + } + } +} + +.divider--surface { + --local-divider-color: var(--tc-surface-low); + .divider__text { + color: var(--tc-surface-low); + } +} +.divider--primary { + --local-divider-color: var(--bg-primary); + .divider__text { + color: var(--bg-primary); + } +} +.divider--danger { + --local-divider-color: var(--bg-danger); + .divider__text { + color: var(--bg-danger); + } +} +.divider--caution { + --local-divider-color: var(--bg-caution); + .divider__text { + color: var(--bg-caution); + } +} \ No newline at end of file diff --git a/src/app/atoms/header/Header.jsx b/src/app/atoms/header/Header.jsx new file mode 100644 index 0000000..3c81e42 --- /dev/null +++ b/src/app/atoms/header/Header.jsx @@ -0,0 +1,29 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import './Header.scss'; + +function Header({ children }) { + return ( +
+ {children} +
+ ); +} + +Header.propTypes = { + children: PropTypes.node.isRequired, +}; + +function TitleWrapper({ children }) { + return ( +
+ {children} +
+ ); +} + +TitleWrapper.propTypes = { + children: PropTypes.node.isRequired, +}; + +export { Header as default, TitleWrapper }; diff --git a/src/app/atoms/header/Header.scss b/src/app/atoms/header/Header.scss new file mode 100644 index 0000000..05b1a15 --- /dev/null +++ b/src/app/atoms/header/Header.scss @@ -0,0 +1,63 @@ +.header { + padding: { + left: var(--sp-normal); + right: var(--sp-extra-tight); + } + width: 100%; + height: var(--header-height); + border-bottom: 1px solid var(--bg-surface-border); + display: flex; + align-items: center; + + [dir=rtl] & { + padding: { + left: var(--sp-extra-tight); + right: var(--sp-normal); + } + } + + &__title-wrapper { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + margin: 0 var(--sp-tight); + + &:first-child { + margin-left: 0; + [dir=rtl] & { + margin-right: 0; + } + } + + & > .text:first-child { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + & > .text-b3{ + flex: 1; + min-width: 0; + + margin-top: var(--sp-ultra-tight); + margin-left: var(--sp-tight); + padding-left: var(--sp-tight); + border-left: 1px solid var(--bg-surface-border); + max-height: calc(2 * var(--lh-b3)); + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + display: -webkit-box; + + [dir=rtl] & { + margin-left: 0; + padding-left: 0; + border-left: none; + margin-right: var(--sp-tight); + padding-right: var(--sp-tight); + border-right: 1px solid var(--bg-surface-border); + } + } + } +} \ No newline at end of file diff --git a/src/app/atoms/input/Input.jsx b/src/app/atoms/input/Input.jsx new file mode 100644 index 0000000..c5401a3 --- /dev/null +++ b/src/app/atoms/input/Input.jsx @@ -0,0 +1,77 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import './Input.scss'; + +import TextareaAutosize from 'react-autosize-textarea'; + +function Input({ + id, label, value, placeholder, + required, type, onChange, forwardRef, + resizable, minHeight, onResize, state, +}) { + return ( +
+ { label !== '' && } + { resizable + ? ( + + ) : ( + + )} +
+ ); +} + +Input.defaultProps = { + id: null, + label: '', + value: '', + placeholder: '', + type: 'text', + required: false, + onChange: null, + forwardRef: null, + resizable: false, + minHeight: 46, + onResize: null, + state: 'normal', +}; + +Input.propTypes = { + id: PropTypes.string, + label: PropTypes.string, + value: PropTypes.string, + placeholder: PropTypes.string, + required: PropTypes.bool, + type: PropTypes.string, + onChange: PropTypes.func, + forwardRef: PropTypes.shape({}), + resizable: PropTypes.bool, + minHeight: PropTypes.number, + onResize: PropTypes.func, + state: PropTypes.oneOf(['normal', 'success', 'error']), +}; + +export default Input; diff --git a/src/app/atoms/input/Input.scss b/src/app/atoms/input/Input.scss new file mode 100644 index 0000000..d029205 --- /dev/null +++ b/src/app/atoms/input/Input.scss @@ -0,0 +1,40 @@ +.input { + display: block; + width: 100%; + min-width: 0px; + padding: var(--sp-tight) var(--sp-normal); + background-color: var(--bg-surface-low); + color: var(--tc-surface-normal); + box-shadow: none; + border-radius: var(--bo-radius); + border: 1px solid var(--bg-surface-border); + font-size: var(--fs-b2); + letter-spacing: var(--ls-b2); + line-height: var(--lh-b2); + + &__label { + display: inline-block; + margin-bottom: var(--sp-ultra-tight); + color: var(--tc-surface-low); + } + + &--resizable { + resize: vertical !important; + } + &--success { + border: 1px solid var(--bg-positive); + box-shadow: none !important; + } + &--error { + border: 1px solid var(--bg-danger); + box-shadow: none !important; + } + + &:focus { + outline: none; + box-shadow: var(--bs-primary-border); + } + &::placeholder { + color: var(--tc-surface-low) + } +} \ No newline at end of file diff --git a/src/app/atoms/modal/RawModal.jsx b/src/app/atoms/modal/RawModal.jsx new file mode 100644 index 0000000..995ac60 --- /dev/null +++ b/src/app/atoms/modal/RawModal.jsx @@ -0,0 +1,67 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import './RawModal.scss'; + +import Modal from 'react-modal'; + +Modal.setAppElement('#root'); + +function RawModal({ + className, overlayClassName, + isOpen, size, onAfterOpen, onAfterClose, + onRequestClose, closeFromOutside, children, +}) { + let modalClass = (className !== null) ? `${className} ` : ''; + switch (size) { + case 'large': + modalClass += 'raw-modal__large '; + break; + case 'medium': + modalClass += 'raw-modal__medium '; + break; + case 'small': + default: + modalClass += 'raw-modal__small '; + } + const modalOverlayClass = (overlayClassName !== null) ? `${overlayClassName} ` : ''; + return ( + + {children} + + ); +} + +RawModal.defaultProps = { + className: null, + overlayClassName: null, + size: 'small', + onAfterOpen: null, + onAfterClose: null, + onRequestClose: null, + closeFromOutside: true, +}; + +RawModal.propTypes = { + className: PropTypes.string, + overlayClassName: PropTypes.string, + isOpen: PropTypes.bool.isRequired, + size: PropTypes.oneOf(['large', 'medium', 'small']), + onAfterOpen: PropTypes.func, + onAfterClose: PropTypes.func, + onRequestClose: PropTypes.func, + closeFromOutside: PropTypes.bool, + children: PropTypes.node.isRequired, +}; + +export default RawModal; diff --git a/src/app/atoms/modal/RawModal.scss b/src/app/atoms/modal/RawModal.scss new file mode 100644 index 0000000..d008cc0 --- /dev/null +++ b/src/app/atoms/modal/RawModal.scss @@ -0,0 +1,63 @@ +.ReactModal__Overlay { + opacity: 0; + transition: opacity 200ms cubic-bezier(0.13, 0.56, 0.25, 0.99); +} +.ReactModal__Overlay--after-open{ + opacity: 1; +} +.ReactModal__Overlay--before-close{ + opacity: 0; +} + +.ReactModal__Content { + transform: translateY(100%); + transition: transform 200ms cubic-bezier(0.13, 0.56, 0.25, 0.99); +} + +.ReactModal__Content--after-open{ + transform: translateY(0); +} + +.ReactModal__Content--before-close{ + transform: translateY(100%); +} + +.raw-modal { + --small-modal-width: 525px; + --medium-modal-width: 712px; + --large-modal-width: 1024px; + + + width: 100%; + max-height: 100%; + border-radius: var(--bo-radius); + box-shadow: var(--bs-popup); + outline: none; + overflow: hidden; + + &__small { + max-width: var(--small-modal-width); + } + &__medium { + max-width: var(--medium-modal-width); + } + &__large { + max-width: var(--large-modal-width); + } + + &__overlay { + position: fixed; + top: 0; + left: 0; + z-index: 999; + + display: flex; + justify-content: center; + align-items: center; + + padding: var(--sp-normal); + width: 100%; + height: 100%; + background-color: var(--bg-overlay); + } +} \ No newline at end of file diff --git a/src/app/atoms/scroll/ScrollView.jsx b/src/app/atoms/scroll/ScrollView.jsx new file mode 100644 index 0000000..26c0c83 --- /dev/null +++ b/src/app/atoms/scroll/ScrollView.jsx @@ -0,0 +1,37 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import './ScrollView.scss'; + +const ScrollView = React.forwardRef(({ + horizontal, vertical, autoHide, invisible, onScroll, children, +}, ref) => { + let scrollbarClasses = ''; + if (horizontal) scrollbarClasses += ' scrollbar__h'; + if (vertical) scrollbarClasses += ' scrollbar__v'; + if (autoHide) scrollbarClasses += ' scrollbar--auto-hide'; + if (invisible) scrollbarClasses += ' scrollbar--invisible'; + return ( +
+ {children} +
+ ); +}); + +ScrollView.defaultProps = { + horizontal: false, + vertical: true, + autoHide: false, + invisible: false, + onScroll: null, +}; + +ScrollView.propTypes = { + horizontal: PropTypes.bool, + vertical: PropTypes.bool, + autoHide: PropTypes.bool, + invisible: PropTypes.bool, + onScroll: PropTypes.func, + children: PropTypes.node.isRequired, +}; + +export default ScrollView; diff --git a/src/app/atoms/scroll/ScrollView.scss b/src/app/atoms/scroll/ScrollView.scss new file mode 100644 index 0000000..6c7d709 --- /dev/null +++ b/src/app/atoms/scroll/ScrollView.scss @@ -0,0 +1,22 @@ +@use '_scrollbar'; + +.scrollbar { + width: 100%; + height: 100%; + @include scrollbar.scroll; + + &__h { + @include scrollbar.scroll__h; + } + + &__v { + @include scrollbar.scroll__v; + } + + &--auto-hide { + @include scrollbar.scroll--auto-hide; + } + &--invisible { + @include scrollbar.scroll--invisible; + } +} \ No newline at end of file diff --git a/src/app/atoms/scroll/_scrollbar.scss b/src/app/atoms/scroll/_scrollbar.scss new file mode 100644 index 0000000..5baaaa6 --- /dev/null +++ b/src/app/atoms/scroll/_scrollbar.scss @@ -0,0 +1,62 @@ +.firefox-scrollbar { + scrollbar-width: thin; + scrollbar-color: var(--bg-surface-hover) transparent; + &--transparent { + scrollbar-color: transparent transparent; + } +} +.webkit-scrollbar { + &::-webkit-scrollbar { + width: 8px; + height: 8px; + } +} +.webkit-scrollbar-track { + &::-webkit-scrollbar-track { + background-color: transparent; + } +} +.webkit-scrollbar-thumb { + &::-webkit-scrollbar-thumb { + background-color: var(--bg-surface-hover); + } + &::-webkit-scrollbar-thumb:hover { + background-color: var(--bg-surface-active); + } + &--transparent { + &::-webkit-scrollbar-thumb { + background-color: transparent; + } + } +} + +@mixin scroll { + overflow: hidden; + @extend .firefox-scrollbar; + @extend .webkit-scrollbar; + @extend .webkit-scrollbar-track; + @extend .webkit-scrollbar-thumb; +} + +@mixin scroll__h { + overflow-x: scroll; +} +@mixin scroll__v { + overflow-y: scroll; +} +@mixin scroll--auto-hide { + @extend .firefox-scrollbar--transparent; + @extend .webkit-scrollbar-thumb--transparent; + + &:hover { + @extend .firefox-scrollbar; + @extend .webkit-scrollbar-thumb; + } +} +@mixin scroll--invisible { + -ms-overflow-style: none; + scrollbar-width: none; + &::-webkit-scrollbar { + display: none; + } +} \ No newline at end of file diff --git a/src/app/atoms/segmented-controls/SegmentedControls.jsx b/src/app/atoms/segmented-controls/SegmentedControls.jsx new file mode 100644 index 0000000..2faaf2b --- /dev/null +++ b/src/app/atoms/segmented-controls/SegmentedControls.jsx @@ -0,0 +1,51 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import './SegmentedControls.scss'; + +import { blurOnBubbling } from '../button/script'; + +import Text from '../text/Text'; +import RawIcon from '../system-icons/RawIcon'; + +function SegmentedControls({ + selected, segments, onSelect, +}) { + const [select, setSelect] = useState(selected); + + function selectSegment(segmentIndex) { + setSelect(segmentIndex); + onSelect(segmentIndex); + } + + return ( +
+ { + segments.map((segment, index) => ( + + )) + } +
+ ); +} + +SegmentedControls.propTypes = { + selected: PropTypes.number.isRequired, + segments: PropTypes.arrayOf(PropTypes.shape({ + iconSrc: PropTypes.string, + text: PropTypes.string, + })).isRequired, + onSelect: PropTypes.func.isRequired, +}; + +export default SegmentedControls; diff --git a/src/app/atoms/segmented-controls/SegmentedControls.scss b/src/app/atoms/segmented-controls/SegmentedControls.scss new file mode 100644 index 0000000..6df4130 --- /dev/null +++ b/src/app/atoms/segmented-controls/SegmentedControls.scss @@ -0,0 +1,61 @@ +@use '../button/state'; + +.segmented-controls { + background-color: var(--bg-surface-low); + border-radius: var(--bo-radius); + border: 1px solid var(--bg-surface-border); + + display: inline-flex; + overflow: hidden; +} + +.segment-btn { + padding: var(--sp-extra-tight) 0; + cursor: pointer; + @include state.hover(var(--bg-surface-hover)); + @include state.active(var(--bg-surface-active)); + + &__base { + padding: 0 var(--sp-normal); + display: flex; + align-items: center; + justify-content: center; + border-left: 1px solid var(--bg-surface-border); + + [dir=rtl] & { + border-left: none; + border-right: 1px solid var(--bg-surface-border); + } + + & .text:nth-child(2) { + margin: 0 var(--sp-extra-tight); + } + } + &:first-child &__base { + border: none; + } + + &--active { + background-color: var(--bg-surface); + border: 1px solid var(--bg-surface-border); + border-width: 0 1px 0 1px; + + & .segment-btn__base, + & + .segment-btn .segment-btn__base { + border: none; + } + &:first-child{ + border-left: none; + } + &:last-child { + border-right: none; + } + [dir=rtl] & { + border-left: 1px solid var(--bg-surface-border); + border-right: 1px solid var(--bg-surface-border); + + &:first-child { border-right: none;} + &:last-child { border-left: none;} + } + } +} \ No newline at end of file diff --git a/src/app/atoms/spinner/Spinner.jsx b/src/app/atoms/spinner/Spinner.jsx new file mode 100644 index 0000000..61c9747 --- /dev/null +++ b/src/app/atoms/spinner/Spinner.jsx @@ -0,0 +1,19 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import './Spinner.scss'; + +function Spinner({ size }) { + return ( +
+ ); +} + +Spinner.defaultProps = { + size: 'normal', +}; + +Spinner.propTypes = { + size: PropTypes.oneOf(['normal', 'small']), +}; + +export default Spinner; diff --git a/src/app/atoms/spinner/Spinner.scss b/src/app/atoms/spinner/Spinner.scss new file mode 100644 index 0000000..73fbf67 --- /dev/null +++ b/src/app/atoms/spinner/Spinner.scss @@ -0,0 +1,22 @@ +.donut-spinner { + display: inline-block; + border: 4px solid var(--bg-surface-border); + border-left-color: var(--tc-surface-normal); + border-radius: 50%; + animation: donut-spin 1.2s cubic-bezier(0.73, 0.32, 0.67, 0.86) infinite; + + &--normal { + width: 40px; + height: 40px; + } + &--small { + width: 28px; + height: 28px; + } +} + +@keyframes donut-spin { + to { + transform: rotate(1turn); + } +} \ No newline at end of file diff --git a/src/app/atoms/system-icons/RawIcon.jsx b/src/app/atoms/system-icons/RawIcon.jsx new file mode 100644 index 0000000..dff91ea --- /dev/null +++ b/src/app/atoms/system-icons/RawIcon.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import './RawIcon.scss'; + +function RawIcon({ color, size, src }) { + const style = { + WebkitMaskImage: `url(${src})`, + maskImage: `url(${src})`, + }; + if (color !== null) style.backgroundColor = color; + return ; +} + +RawIcon.defaultProps = { + color: null, + size: 'normal', +}; + +RawIcon.propTypes = { + color: PropTypes.string, + size: PropTypes.oneOf(['large', 'normal', 'small', 'extra-small']), + src: PropTypes.string.isRequired, +}; + +export default RawIcon; diff --git a/src/app/atoms/system-icons/RawIcon.scss b/src/app/atoms/system-icons/RawIcon.scss new file mode 100644 index 0000000..fa74069 --- /dev/null +++ b/src/app/atoms/system-icons/RawIcon.scss @@ -0,0 +1,25 @@ +@mixin icSize($size) { + width: $size; + height: $size; +} + +.ic-raw { + display: inline-block; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-size: cover; + mask-size: cover; + background-color: var(--ic-surface-normal); +} +.ic-raw-large { + @include icSize(var(--ic-large)); +} +.ic-raw-normal { + @include icSize(var(--ic-normal)); +} +.ic-raw-small { + @include icSize(var(--ic-small)); +} +.ic-raw-extra-small { + @include icSize(var(--ic-extra-small)); +} \ No newline at end of file diff --git a/src/app/atoms/text/Text.jsx b/src/app/atoms/text/Text.jsx new file mode 100644 index 0000000..cbd8d01 --- /dev/null +++ b/src/app/atoms/text/Text.jsx @@ -0,0 +1,28 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import './Text.scss'; + +function Text({ + id, className, variant, children, +}) { + const cName = className !== '' ? `${className} ` : ''; + if (variant === 'h1') return

{ children }

; + if (variant === 'h2') return

{ children }

; + if (variant === 's1') return

{ children }

; + return

{ children }

; +} + +Text.defaultProps = { + id: '', + className: '', + variant: 'b1', +}; + +Text.propTypes = { + id: PropTypes.string, + className: PropTypes.string, + variant: PropTypes.oneOf(['h1', 'h2', 's1', 'b1', 'b2', 'b3']), + children: PropTypes.node.isRequired, +}; + +export default Text; diff --git a/src/app/atoms/text/Text.scss b/src/app/atoms/text/Text.scss new file mode 100644 index 0000000..b640353 --- /dev/null +++ b/src/app/atoms/text/Text.scss @@ -0,0 +1,41 @@ +@mixin font($type, $weight) { + + font-size: var(--fs-#{$type}); + font-weight: $weight; + letter-spacing: var(--ls-#{$type}); + line-height: var(--lh-#{$type}); +} + +%text { + margin: 0; + padding: 0; + color: var(--tc-surface-high); +} + +.text-h1 { + @extend %text; + @include font(h1, 500); +} +.text-h2 { + @extend %text; + @include font(h2, 500); +} +.text-s1 { + @extend %text; + @include font(s1, 400); +} +.text-b1 { + @extend %text; + @include font(b1, 400); + color: var(--tc-surface-normal); +} +.text-b2 { + @extend %text; + @include font(b2, 400); + color: var(--tc-surface-normal); +} +.text-b3 { + @extend %text; + @include font(b3, 400); + color: var(--tc-surface-low); +} \ No newline at end of file diff --git a/src/app/molecules/channel-intro/ChannelIntro.jsx b/src/app/molecules/channel-intro/ChannelIntro.jsx new file mode 100644 index 0000000..84c0c14 --- /dev/null +++ b/src/app/molecules/channel-intro/ChannelIntro.jsx @@ -0,0 +1,46 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import './ChannelIntro.scss'; + +import Linkify from 'linkifyjs/react'; +import colorMXID from '../../../util/colorMXID'; + +import Text from '../../atoms/text/Text'; +import Avatar from '../../atoms/avatar/Avatar'; + +function linkifyContent(content) { + return {content}; +} + +function ChannelIntro({ + avatarSrc, name, heading, desc, time, +}) { + return ( +
+ +
+ {heading} + {linkifyContent(desc)} + { time !== null && {time}} +
+
+ ); +} + +ChannelIntro.defaultProps = { + avatarSrc: false, + time: null, +}; + +ChannelIntro.propTypes = { + avatarSrc: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.bool, + ]), + name: PropTypes.string.isRequired, + heading: PropTypes.string.isRequired, + desc: PropTypes.string.isRequired, + time: PropTypes.string, +}; + +export default ChannelIntro; diff --git a/src/app/molecules/channel-intro/ChannelIntro.scss b/src/app/molecules/channel-intro/ChannelIntro.scss new file mode 100644 index 0000000..35186af --- /dev/null +++ b/src/app/molecules/channel-intro/ChannelIntro.scss @@ -0,0 +1,31 @@ +.channel-intro { + margin-top: calc(2 * var(--sp-extra-loose)); + margin-bottom: var(--sp-extra-loose); + padding-left: calc(var(--sp-normal) + var(--av-small) + var(--sp-tight)); + padding-right: var(--sp-extra-tight); + + [dir=rtl] & { + padding: { + left: var(--sp-extra-tight); + right: calc(var(--sp-normal) + var(--av-small) + var(--sp-tight)); + } + } + + .channel-intro__content { + margin-top: var(--sp-extra-loose); + max-width: 640px; + } + &__name { + color: var(--tc-surface-high); + } + &__desc { + color: var(--tc-surface-normal); + margin: var(--sp-tight) 0 var(--sp-extra-tight); + & a { + word-break: break-all; + } + } + &__time { + color: var(--tc-surface-low); + } +} \ No newline at end of file diff --git a/src/app/molecules/channel-selector/ChannelSelector.jsx b/src/app/molecules/channel-selector/ChannelSelector.jsx new file mode 100644 index 0000000..aded303 --- /dev/null +++ b/src/app/molecules/channel-selector/ChannelSelector.jsx @@ -0,0 +1,73 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import './ChannelSelector.scss'; + +import colorMXID from '../../../util/colorMXID'; + +import Text from '../../atoms/text/Text'; +import Avatar from '../../atoms/avatar/Avatar'; +import NotificationBadge from '../../atoms/badge/NotificationBadge'; +import { blurOnBubbling } from '../../atoms/button/script'; + +function ChannelSelector({ + selected, unread, notificationCount, alert, + iconSrc, imageSrc, roomId, onClick, children, +}) { + return ( + + ); +} + +ChannelSelector.defaultProps = { + selected: false, + unread: false, + notificationCount: 0, + alert: false, + iconSrc: null, + imageSrc: null, +}; + +ChannelSelector.propTypes = { + selected: PropTypes.bool, + unread: PropTypes.bool, + notificationCount: PropTypes.number, + alert: PropTypes.bool, + iconSrc: PropTypes.string, + imageSrc: PropTypes.string, + roomId: PropTypes.string.isRequired, + onClick: PropTypes.func.isRequired, + children: PropTypes.string.isRequired, +}; + +export default ChannelSelector; diff --git a/src/app/molecules/channel-selector/ChannelSelector.scss b/src/app/molecules/channel-selector/ChannelSelector.scss new file mode 100644 index 0000000..3c6d3db --- /dev/null +++ b/src/app/molecules/channel-selector/ChannelSelector.scss @@ -0,0 +1,66 @@ +.channel-selector__button-wrapper { + display: block; + width: calc(100% - var(--sp-extra-tight)); + margin-left: auto; + padding: var(--sp-extra-tight) var(--sp-extra-tight); + border: 1px solid transparent; + border-radius: var(--bo-radius); + cursor: pointer; + + [dir=rtl] & { + margin: { + left: 0; + right: auto; + } + } + + @media (hover: hover) { + &:hover { + background-color: var(--bg-surface-hover); + } + } + &:focus { + outline: none; + background-color: var(--bg-surface-hover); + } + &:active { + background-color: var(--bg-surface-active); + } +} +.channel-selector { + display: flex; + align-items: center; + + &__icon { + width: 24px; + height: 24px; + .avatar__border { + box-shadow: none; + } + } + &__text-container { + flex: 1; + min-width: 0; + margin: 0 var(--sp-extra-tight); + + & .text { + color: var(--tc-surface-normal); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + } +} + +.channel-selector--unread { + margin: 0 var(--sp-ultra-tight); + height: 8px; + width: 8px; + background-color: var(--tc-surface-low); + border-radius: 50%; + opacity: .4; +} +.channel-selector--selected { + background-color: var(--bg-surface); + border-color: var(--bg-surface-border); +} \ No newline at end of file diff --git a/src/app/molecules/channel-tile/ChannelTile.jsx b/src/app/molecules/channel-tile/ChannelTile.jsx new file mode 100644 index 0000000..dfb384d --- /dev/null +++ b/src/app/molecules/channel-tile/ChannelTile.jsx @@ -0,0 +1,72 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import './ChannelTile.scss'; + +import Linkify from 'linkifyjs/react'; +import colorMXID from '../../../util/colorMXID'; + +import Text from '../../atoms/text/Text'; +import Avatar from '../../atoms/avatar/Avatar'; + +function linkifyContent(content) { + return {content}; +} + +function ChannelTile({ + avatarSrc, name, id, + inviterName, memberCount, desc, options, +}) { + return ( +
+
+ +
+
+ {name} + + { + inviterName !== null + ? `Invited by ${inviterName} to ${id}${memberCount === null ? '' : ` • ${memberCount} members`}` + : id + (memberCount === null ? '' : ` • ${memberCount} members`) + } + + { + desc !== null && (typeof desc === 'string') + ? {linkifyContent(desc)} + : desc + } +
+ { options !== null && ( +
+ {options} +
+ )} +
+ ); +} + +ChannelTile.defaultProps = { + avatarSrc: null, + inviterName: null, + options: null, + desc: null, + memberCount: null, +}; +ChannelTile.propTypes = { + avatarSrc: PropTypes.string, + name: PropTypes.string.isRequired, + id: PropTypes.string.isRequired, + inviterName: PropTypes.string, + memberCount: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number, + ]), + desc: PropTypes.node, + options: PropTypes.node, +}; + +export default ChannelTile; diff --git a/src/app/molecules/channel-tile/ChannelTile.scss b/src/app/molecules/channel-tile/ChannelTile.scss new file mode 100644 index 0000000..ce20195 --- /dev/null +++ b/src/app/molecules/channel-tile/ChannelTile.scss @@ -0,0 +1,21 @@ +.channel-tile { + display: flex; + + &__content { + flex: 1; + min-width: 0; + + margin: 0 var(--sp-normal); + + &__desc { + white-space: pre-wrap; + & a { + white-space: wrap; + } + } + + & .text:not(:first-child) { + margin-top: var(--sp-ultra-tight); + } + } +} \ No newline at end of file diff --git a/src/app/molecules/media/Media.jsx b/src/app/molecules/media/Media.jsx new file mode 100644 index 0000000..6bbdcfb --- /dev/null +++ b/src/app/molecules/media/Media.jsx @@ -0,0 +1,307 @@ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import './Media.scss'; + +import encrypt from 'browser-encrypt-attachment'; + +import Text from '../../atoms/text/Text'; +import IconButton from '../../atoms/button/IconButton'; +import Spinner from '../../atoms/spinner/Spinner'; + +import DownloadSVG from '../../../../public/res/ic/outlined/download.svg'; +import ExternalSVG from '../../../../public/res/ic/outlined/external.svg'; +import PlaySVG from '../../../../public/res/ic/outlined/play.svg'; + +// https://github.com/matrix-org/matrix-react-sdk/blob/a9e28db33058d1893d964ec96cd247ecc3d92fc3/src/utils/blobs.ts#L73 +const ALLOWED_BLOB_MIMETYPES = [ + 'image/jpeg', + 'image/gif', + 'image/png', + + 'video/mp4', + 'video/webm', + 'video/ogg', + + 'audio/mp4', + 'audio/webm', + 'audio/aac', + 'audio/mpeg', + 'audio/ogg', + 'audio/wave', + 'audio/wav', + 'audio/x-wav', + 'audio/x-pn-wav', + 'audio/flac', + 'audio/x-flac', +]; +function getBlobSafeMimeType(mimetype) { + if (!ALLOWED_BLOB_MIMETYPES.includes(mimetype)) { + return 'application/octet-stream'; + } + return mimetype; +} + +async function getDecryptedBlob(response, type, decryptData) { + const arrayBuffer = await response.arrayBuffer(); + const dataArray = await encrypt.decryptAttachment(arrayBuffer, decryptData); + const blob = new Blob([dataArray], { type: getBlobSafeMimeType(type) }); + return blob; +} + +async function getUrl(link, type, decryptData) { + try { + const response = await fetch(link, { method: 'GET' }); + if (decryptData !== null) { + return URL.createObjectURL(await getDecryptedBlob(response, type, decryptData)); + } + const blob = await response.blob(); + return URL.createObjectURL(blob); + } catch (e) { + return link; + } +} + +function getNativeHeight(width, height) { + const MEDIA_MAX_WIDTH = 296; + const scale = MEDIA_MAX_WIDTH / width; + return scale * height; +} + +function FileHeader({ + name, link, external, + file, type, +}) { + const [url, setUrl] = useState(null); + + async function getFile() { + const myUrl = await getUrl(link, type, file); + setUrl(myUrl); + } + + async function handleDownload(e) { + if (file !== null && url === null) { + e.preventDefault(); + await getFile(); + e.target.click(); + } + } + return ( +
+ {name} + { link !== null && ( + <> + { + external && ( + window.open(url || link)} + /> + ) + } + + + + + )} +
+ ); +} +FileHeader.defaultProps = { + external: false, + file: null, + link: null, +}; +FileHeader.propTypes = { + name: PropTypes.string.isRequired, + link: PropTypes.string, + external: PropTypes.bool, + file: PropTypes.shape({}), + type: PropTypes.string.isRequired, +}; + +function File({ + name, link, file, type, +}) { + return ( +
+ +
+ ); +} +File.defaultProps = { + file: null, +}; +File.propTypes = { + name: PropTypes.string.isRequired, + link: PropTypes.string.isRequired, + type: PropTypes.string.isRequired, + file: PropTypes.shape({}), +}; + +function Image({ + name, width, height, link, file, type, +}) { + const [url, setUrl] = useState(null); + + useEffect(() => { + let unmounted = false; + async function fetchUrl() { + const myUrl = await getUrl(link, type, file); + if (unmounted) return; + setUrl(myUrl); + } + fetchUrl(); + return () => { + unmounted = true; + }; + }, []); + + return ( +
+ +
+ { url !== null && {name}} +
+
+ ); +} +Image.defaultProps = { + file: null, + width: null, + height: null, +}; +Image.propTypes = { + name: PropTypes.string.isRequired, + width: PropTypes.number, + height: PropTypes.number, + link: PropTypes.string.isRequired, + file: PropTypes.shape({}), + type: PropTypes.string.isRequired, +}; + +function Audio({ + name, link, type, file, +}) { + const [isLoading, setIsLoading] = useState(false); + const [url, setUrl] = useState(null); + + async function loadAudio() { + const myUrl = await getUrl(link, type, file); + setUrl(myUrl); + setIsLoading(false); + } + function handlePlayAudio() { + setIsLoading(true); + loadAudio(); + } + + return ( +
+ +
+ { url === null && isLoading && } + { url === null && !isLoading && } + { url !== null && ( + /* eslint-disable-next-line jsx-a11y/media-has-caption */ + + )} +
+
+ ); +} +Audio.defaultProps = { + file: null, +}; +Audio.propTypes = { + name: PropTypes.string.isRequired, + link: PropTypes.string.isRequired, + type: PropTypes.string.isRequired, + file: PropTypes.shape({}), +}; + +function Video({ + name, link, thumbnail, + width, height, file, type, thumbnailFile, thumbnailType, +}) { + const [isLoading, setIsLoading] = useState(false); + const [url, setUrl] = useState(null); + const [thumbUrl, setThumbUrl] = useState(null); + + useEffect(() => { + let unmounted = false; + async function fetchUrl() { + const myThumbUrl = await getUrl(thumbnail, thumbnailType, thumbnailFile); + if (unmounted) return; + setThumbUrl(myThumbUrl); + } + if (thumbnail !== null) fetchUrl(); + return () => { + unmounted = true; + }; + }, []); + + async function loadVideo() { + const myUrl = await getUrl(link, type, file); + setUrl(myUrl); + setIsLoading(false); + } + + function handlePlayVideo() { + setIsLoading(true); + loadVideo(); + } + + return ( +
+ +
+ { url === null && isLoading && } + { url === null && !isLoading && } + { url !== null && ( + /* eslint-disable-next-line jsx-a11y/media-has-caption */ + + )} +
+
+ ); +} +Video.defaultProps = { + width: null, + height: null, + file: null, + thumbnail: null, + thumbnailType: null, + thumbnailFile: null, +}; +Video.propTypes = { + name: PropTypes.string.isRequired, + link: PropTypes.string.isRequired, + thumbnail: PropTypes.string, + width: PropTypes.number, + height: PropTypes.number, + file: PropTypes.shape({}), + type: PropTypes.string.isRequired, + thumbnailFile: PropTypes.shape({}), + thumbnailType: PropTypes.string, +}; + +export { + File, Image, Audio, Video, +}; diff --git a/src/app/molecules/media/Media.scss b/src/app/molecules/media/Media.scss new file mode 100644 index 0000000..db67ea4 --- /dev/null +++ b/src/app/molecules/media/Media.scss @@ -0,0 +1,62 @@ +.file-header { + display: flex; + align-items: center; + padding: var(--sp-ultra-tight) var(--sp-tight); + min-height: 42px; + + & .file-name { + flex: 1; + color: var(--tc-surface-low); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } +} + +.file-container { + --media-max-width: 296px; + + background-color: var(--bg-surface-hover); + border-radius: calc(var(--bo-radius) / 2); + overflow: hidden; + max-width: var(--media-max-width); + white-space: initial; +} + +.image-container, +.video-container, +.audio-container { + font-size: 0; + line-height: 0; + + display: flex; + justify-content: center; + align-items: center; + + background-position: center; + background-repeat: no-repeat; + background-size: cover; +} + +.image-container { + & img { + max-width: unset !important; + width: 100% !important; + border-radius: 0 !important; + margin: 0 !important; + } +} + +.video-container { + & .ic-btn-surface { + background-color: var(--bg-surface-low); + } + video { + width: 100% + } +} +.audio-container { + audio { + width: 100% + } +} \ No newline at end of file diff --git a/src/app/molecules/message/Message.jsx b/src/app/molecules/message/Message.jsx new file mode 100644 index 0000000..ad32b0c --- /dev/null +++ b/src/app/molecules/message/Message.jsx @@ -0,0 +1,149 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import './Message.scss'; + +import Linkify from 'linkifyjs/react'; +import ReactMarkdown from 'react-markdown'; +import gfm from 'remark-gfm'; +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { coy } from 'react-syntax-highlighter/dist/esm/styles/prism'; + +import Text from '../../atoms/text/Text'; +import RawIcon from '../../atoms/system-icons/RawIcon'; +import Avatar from '../../atoms/avatar/Avatar'; + +import ReplyArrowIC from '../../../../public/res/ic/outlined/reply-arrow.svg'; + +const components = { + code({ + // eslint-disable-next-line react/prop-types + inline, className, children, + }) { + const match = /language-(\w+)/.exec(className || ''); + return !inline && match ? ( + + {String(children).replace(/\n$/, '')} + + ) : ( + {String(children)} + ); + }, +}; + +function linkifyContent(content) { + return {content}; +} +function genMarkdown(content) { + return {content}; +} + +function PlaceholderMessage() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); +} + +function Message({ + color, avatarSrc, name, content, + time, markdown, contentOnly, reply, + edited, reactions, +}) { + const msgClass = contentOnly ? 'message--content-only' : 'message--full'; + return ( +
+
+ {!contentOnly && } +
+
+ { !contentOnly && ( +
+
+ {name} +
+
+ {time} +
+
+ )} +
+ { reply !== null && ( +
+ + + {reply.to} + <>{` ${reply.content}`} + +
+ )} +
+ { markdown ? genMarkdown(content) : linkifyContent(content) } +
+ { edited && (edited)} + { reactions && ( +
+ { + reactions.map((reaction) => ( + + )) + } +
+ )} +
+
+
+ ); +} + +Message.defaultProps = { + color: 'var(--tc-surface-high)', + avatarSrc: null, + markdown: false, + contentOnly: false, + reply: null, + edited: false, + reactions: null, +}; + +Message.propTypes = { + color: PropTypes.string, + avatarSrc: PropTypes.string, + name: PropTypes.string.isRequired, + content: PropTypes.node.isRequired, + time: PropTypes.string.isRequired, + markdown: PropTypes.bool, + contentOnly: PropTypes.bool, + reply: PropTypes.shape({ + color: PropTypes.string.isRequired, + to: PropTypes.string.isRequired, + content: PropTypes.string.isRequired, + }), + edited: PropTypes.bool, + reactions: PropTypes.arrayOf(PropTypes.exact({ + id: PropTypes.string, + key: PropTypes.string, + count: PropTypes.number, + active: PropTypes.bool, + })), +}; + +export { Message as default, PlaceholderMessage }; diff --git a/src/app/molecules/message/Message.scss b/src/app/molecules/message/Message.scss new file mode 100644 index 0000000..a1c7bbc --- /dev/null +++ b/src/app/molecules/message/Message.scss @@ -0,0 +1,293 @@ +@use '../../atoms/scroll/scrollbar'; + +.message, +.ph-msg { + padding: var(--sp-ultra-tight) var(--sp-normal); + padding-right: var(--sp-extra-tight); + display: flex; + + &:hover { + background-color: var(--bg-surface-hover); + } + + [dir=rtl] & { + padding: { + left: var(--sp-extra-tight); + right: var(--sp-normal); + } + } + + &__avatar-container { + padding-top: 6px; + } + + &__avatar-container, + &__profile { + margin-right: var(--sp-tight); + + [dir=rtl] & { + margin: { + left: var(--sp-tight); + right: 0; + } + } + } + + &__main-container { + flex: 1; + min-width: 0; + } +} + +.message { + &--full + &--full, + &--content-only + &--full, + & + .timeline-change, + .timeline-change + & { + margin-top: var(--sp-normal); + } + &__avatar-container { + width: var(--av-small); + } + &__reply-content { + .text { + color: var(--tc-surface-low); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .ic-raw { + width: 16px; + height: 14px; + } + } + &__edited { + color: var(--tc-surface-low); + } + &__reactions { + margin-top: var(--sp-ultra-tight); + } +} + +.ph-msg { + &__avatar { + width: var(--av-small); + height: var(--av-small); + background-color: var(--bg-surface-hover); + border-radius: var(--bo-radius); + } + + &__header, + &__content > div { + margin: var(--sp-ultra-tight) 0; + margin-right: var(--sp-extra-tight); + height: var(--fs-b1); + width: 100%; + max-width: 100px; + background-color: var(--bg-surface-hover); + border-radius: calc(var(--bo-radius) / 2); + + [dir=rtl] & { + margin: { + right: 0; + left: var(--sp-extra-tight); + } + } + } + &__content { + display: flex; + flex-wrap: wrap; + } + &__content > div:nth-child(1n) { + max-width: 10%; + } + &__content > div:nth-child(2n) { + max-width: 50%; + } +} + +.message__header { + display: flex; + align-items: baseline; + + & .message__profile { + flex: 1; + min-width: 0; + color: var(--tc-surface-high); + + & > .text { + color: inherit; + font-weight: 500; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + } + + & .message__time { + & > .text { + color: var(--tc-surface-low); + } + } +} +.message__content { + max-width: 640px; + word-break: break-word; + + & > .text > * { + white-space: pre-wrap; + } + + & a { + word-break: break-all; + } +} +.msg__reaction { + --reaction-height: 24px; + --reaction-padding: 6px; + --reaction-radius: calc(var(--bo-radius) / 2); + display: inline-flex; + align-items: center; + color: var(--tc-surface-normal); + border: 1px solid var(--bg-surface-border); + padding: 0 var(--reaction-padding); + border-radius: var(--reaction-radius); + cursor: pointer; + height: var(--reaction-height); + + margin-right: var(--sp-extra-tight); + + [dir=rtl] & { + margin: { + right: 0; + left: var(--sp-extra-tight); + } + } + + @media (hover: hover) { + &:hover { + background-color: var(--bg-surface-hover); + } + } + &:active { + background-color: var(--bg-surface-active) + } + + &--active { + background-color: var(--bg-caution-active); + + @media (hover: hover) { + &:hover { + background-color: var(--bg-caution-hover); + } + } + &:active { + background-color: var(--bg-caution-active) + } + } +} + +// markdown formating +.message { + & h1, + & h2 { + color: var(--tc-surface-high); + margin: var(--sp-extra-loose) 0 var(--sp-normal); + line-height: var(--lh-h1); + } + & h3, + & h4 { + color: var(--tc-surface-high); + margin: var(--sp-loose) 0 var(--sp-tight); + line-height: var(--lh-h2); + } + & h5, + & h6 { + color: var(--tc-surface-high); + margin: var(--sp-normal) 0 var(--sp-extra-tight); + line-height: var(--lh-s1); + } + & hr { + border-color: var(--bg-surface-border); + } + + .text img { + margin: var(--sp-ultra-tight) 0; + max-width: 296px; + border-radius: calc(var(--bo-radius) / 2); + } + + & p, + & pre, + & blockquote { + margin: 0; + padding: 0; + } + & pre, + & blockquote { + margin: var(--sp-ultra-tight) 0; + padding: var(--sp-extra-tight); + background-color: var(--bg-surface-hover) !important; + border-radius: calc(var(--bo-radius) / 2); + } + & pre { + div { + background: none !important; + margin: 0 !important; + } + span { + background: none !important; + } + .linenumber { + min-width: 2.25em !important; + } + } + & code { + padding: 0 !important; + color: var(--tc-code) !important; + white-space: pre-wrap; + @include scrollbar.scroll; + @include scrollbar.scroll__h; + @include scrollbar.scroll--auto-hide; + } + & pre code { + color: var(--tc-surface-normal) !important; + } + & blockquote { + padding-left: var(--sp-extra-tight); + border-left: 4px solid var(--bg-surface-active); + white-space: initial !important; + + & > * { + white-space: pre-wrap; + } + + [dir=rtl] & { + padding: { + left: 0; + right: var(--sp-extra-tight); + } + border: { + left: none; + right: 4px solid var(--bg-surface-active); + } + } + } + & ul, + & ol { + margin: var(--sp-ultra-tight) 0; + padding-left: 24px; + white-space: initial !important; + + & > * { + white-space: pre-wrap; + } + + [dir=rtl] & { + padding: { + left: 0; + right: 24px; + } + } + } +} \ No newline at end of file diff --git a/src/app/molecules/message/TimelineChange.jsx b/src/app/molecules/message/TimelineChange.jsx new file mode 100644 index 0000000..08ab478 --- /dev/null +++ b/src/app/molecules/message/TimelineChange.jsx @@ -0,0 +1,79 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import './TimelineChange.scss'; + +// import Linkify from 'linkifyjs/react'; + +import Text from '../../atoms/text/Text'; +import RawIcon from '../../atoms/system-icons/RawIcon'; + +import JoinArraowIC from '../../../../public/res/ic/outlined/join-arrow.svg'; +import LeaveArraowIC from '../../../../public/res/ic/outlined/leave-arrow.svg'; +import InviteArraowIC from '../../../../public/res/ic/outlined/invite-arrow.svg'; +import InviteCancelArraowIC from '../../../../public/res/ic/outlined/invite-cancel-arrow.svg'; +import UserIC from '../../../../public/res/ic/outlined/user.svg'; +import TickMarkIC from '../../../../public/res/ic/outlined/tick-mark.svg'; + +function TimelineChange({ variant, content, time }) { + let iconSrc; + + switch (variant) { + case 'join': + iconSrc = JoinArraowIC; + break; + case 'leave': + iconSrc = LeaveArraowIC; + break; + case 'invite': + iconSrc = InviteArraowIC; + break; + case 'invite-cancel': + iconSrc = InviteCancelArraowIC; + break; + case 'avatar': + iconSrc = UserIC; + break; + case 'follow': + iconSrc = TickMarkIC; + break; + default: + iconSrc = JoinArraowIC; + break; + } + + return ( +
+
+ +
+
+ + {content} + {/* {content} */} + +
+
+ {time} +
+
+ ); +} + +TimelineChange.defaultProps = { + variant: 'other', +}; + +TimelineChange.propTypes = { + variant: PropTypes.oneOf([ + 'join', 'leave', 'invite', + 'invite-cancel', 'avatar', 'other', + 'follow', + ]), + content: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.node, + ]).isRequired, + time: PropTypes.string.isRequired, +}; + +export default TimelineChange; diff --git a/src/app/molecules/message/TimelineChange.scss b/src/app/molecules/message/TimelineChange.scss new file mode 100644 index 0000000..2aafe94 --- /dev/null +++ b/src/app/molecules/message/TimelineChange.scss @@ -0,0 +1,39 @@ +.timeline-change { + padding: var(--sp-ultra-tight) var(--sp-normal); + padding-right: var(--sp-extra-tight); + display: flex; + align-items: center; + + &:hover { + background-color: var(--bg-surface-hover); + } + + [dir=rtl] & { + padding: { + left: var(--sp-extra-tight); + right: var(--sp-normal); + } + } + + &__avatar-container { + width: var(--av-small); + display: inline-flex; + justify-content: center; + align-items: center; + opacity: 0.38; + .ic-raw { + background-color: var(--tc-surface-low); + } + } + + & .text { + color: var(--tc-surface-low); + } + + &__content { + flex: 1; + min-width: 0; + + margin: 0 var(--sp-tight); + } +} \ No newline at end of file diff --git a/src/app/molecules/people-selector/PeopleSelector.jsx b/src/app/molecules/people-selector/PeopleSelector.jsx new file mode 100644 index 0000000..5fff5c0 --- /dev/null +++ b/src/app/molecules/people-selector/PeopleSelector.jsx @@ -0,0 +1,42 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import './PeopleSelector.scss'; + +import { blurOnBubbling } from '../../atoms/button/script'; + +import Text from '../../atoms/text/Text'; +import Avatar from '../../atoms/avatar/Avatar'; + +function PeopleSelector({ + avatarSrc, name, color, peopleRole, onClick, +}) { + return ( +
+ +
+ ); +} + +PeopleSelector.defaultProps = { + avatarSrc: null, + peopleRole: null, +}; + +PeopleSelector.propTypes = { + avatarSrc: PropTypes.string, + name: PropTypes.string.isRequired, + color: PropTypes.string.isRequired, + peopleRole: PropTypes.string, + onClick: PropTypes.func.isRequired, +}; + +export default PeopleSelector; diff --git a/src/app/molecules/people-selector/PeopleSelector.scss b/src/app/molecules/people-selector/PeopleSelector.scss new file mode 100644 index 0000000..83637b8 --- /dev/null +++ b/src/app/molecules/people-selector/PeopleSelector.scss @@ -0,0 +1,40 @@ +.people-selector { + width: 100%; + padding: var(--sp-extra-tight); + padding-left: var(--sp-normal); + display: flex; + align-items: center; + cursor: pointer; + + [dir=rtl] & { + padding: { + left: var(--sp-extra-tight); + right: var(--sp-normal); + } + } + @media (hover: hover) { + &:hover { + background-color: var(--bg-surface-hover); + } + } + &:focus { + outline: none; + background-color: var(--bg-surface-hover); + } + &:active { + background-color: var(--bg-surface-active); + } + + &__name { + flex: 1; + min-width: 0; + margin: 0 var(--sp-tight); + color: var(--tc-surface-normal); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + &__role { + color: var(--tc-surface-low); + } +} \ No newline at end of file diff --git a/src/app/molecules/popup-window/PopupWindow.jsx b/src/app/molecules/popup-window/PopupWindow.jsx new file mode 100644 index 0000000..2d6026b --- /dev/null +++ b/src/app/molecules/popup-window/PopupWindow.jsx @@ -0,0 +1,123 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import './PopupWindow.scss'; + +import Text from '../../atoms/text/Text'; +import IconButton from '../../atoms/button/IconButton'; +import { MenuItem } from '../../atoms/context-menu/ContextMenu'; +import Header, { TitleWrapper } from '../../atoms/header/Header'; +import ScrollView from '../../atoms/scroll/ScrollView'; +import RawModal from '../../atoms/modal/RawModal'; + +import ChevronLeftIC from '../../../../public/res/ic/outlined/chevron-left.svg'; + +function PWContentSelector({ + selected, variant, iconSrc, + type, onClick, children, +}) { + const pwcsClass = selected ? ' pw-content-selector--selected' : ''; + return ( +
+ + {children} + +
+ ); +} + +PWContentSelector.defaultProps = { + selected: false, + variant: 'surface', + iconSrc: 'none', + type: 'button', +}; + +PWContentSelector.propTypes = { + selected: PropTypes.bool, + variant: PropTypes.oneOf(['surface', 'caution', 'danger']), + iconSrc: PropTypes.string, + type: PropTypes.oneOf(['button', 'submit']), + onClick: PropTypes.func.isRequired, + children: PropTypes.string.isRequired, +}; + +function PopupWindow({ + className, isOpen, title, contentTitle, + drawer, drawerOptions, contentOptions, + onRequestClose, children, +}) { + const haveDrawer = drawer !== null; + + return ( + +
+ {haveDrawer && ( +
+
+ + + {title} + + {drawerOptions} +
+
+ +
+ {drawer} +
+
+
+
+ )} +
+
+ + {contentTitle !== null ? contentTitle : title} + + {contentOptions} +
+
+ +
+ {children} +
+
+
+
+
+
+ ); +} + +PopupWindow.defaultProps = { + className: null, + drawer: null, + contentTitle: null, + drawerOptions: null, + contentOptions: null, + onRequestClose: null, +}; + +PopupWindow.propTypes = { + className: PropTypes.string, + isOpen: PropTypes.bool.isRequired, + title: PropTypes.string.isRequired, + contentTitle: PropTypes.string, + drawer: PropTypes.node, + drawerOptions: PropTypes.node, + contentOptions: PropTypes.node, + onRequestClose: PropTypes.func, + children: PropTypes.node.isRequired, +}; + +export { PopupWindow as default, PWContentSelector }; diff --git a/src/app/molecules/popup-window/PopupWindow.scss b/src/app/molecules/popup-window/PopupWindow.scss new file mode 100644 index 0000000..fe6b72e --- /dev/null +++ b/src/app/molecules/popup-window/PopupWindow.scss @@ -0,0 +1,100 @@ +.pw-model { + --modal-height: 656px; + max-height: var(--modal-height) !important; + height: 100%; +} + +.pw { + --popup-window-drawer-width: 312px; + + width: 100%; + height: 100%; + background-color: var(--bg-surface); + + display: flex; + + &__drawer { + width: var(--popup-window-drawer-width); + background-color: var(--bg-surface-low); + border-right: 1px solid var(--bg-surface-border); + + [dir=rtl] & { + border: { + right: none; + left: 1px solid var(--bg-surface-border); + } + } + } + &__content { + flex: 1; + min-width: 0; + } + + &__drawer, + &__content { + display: flex; + flex-direction: column; + } +} + + +.pw__drawer__content, +.pw__content-container { + padding-top: var(--sp-extra-tight); + padding-bottom: var(--sp-extra-loose); +} +.pw__drawer__content__wrapper, +.pw__content__wrapper { + flex: 1; + min-height: 0; +} + +.pw__drawer { + & .header { + padding-left: var(--sp-extra-tight); + + & .ic-btn-surface:first-child { + margin-right: var(--sp-ultra-tight); + } + + [dir=rtl] & { + padding-right: var(--sp-extra-tight); + & .ic-btn-surface:first-child { + margin-right: 0; + margin-left: var(--sp-ultra-tight); + } + } + } +} + +.pw-content-selector { + &--selected { + border: 1px solid var(--bg-surface-border); + border-width: 1px 0; + background-color: var(--bg-surface); + + & .context-menu__item > button { + &:hover { + background-color: transparent; + } + } + } + + & .context-menu__item > button { + & .text { + color: var(--tc-surface-normal); + } + padding-left: var(--sp-normal); + & .ic-raw { + margin-right: var(--sp-tight); + } + + [dir=rtl] & { + padding-right: var(--sp-normal); + & .ic-raw { + margin-right: 0; + margin-left: var(--sp-tight); + } + } + } +} \ No newline at end of file diff --git a/src/app/molecules/setting-tile/SettingTile.jsx b/src/app/molecules/setting-tile/SettingTile.jsx new file mode 100644 index 0000000..2792e02 --- /dev/null +++ b/src/app/molecules/setting-tile/SettingTile.jsx @@ -0,0 +1,32 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import './SettingTile.scss'; + +import Text from '../../atoms/text/Text'; + +function SettingTile({ title, options, content }) { + return ( +
+
+
+ {title} +
+ {options !== null &&
{options}
} +
+ {content !== null &&
{content}
} +
+ ); +} + +SettingTile.defaultProps = { + options: null, + content: null, +}; + +SettingTile.propTypes = { + title: PropTypes.string.isRequired, + options: PropTypes.node, + content: PropTypes.node, +}; + +export default SettingTile; diff --git a/src/app/molecules/setting-tile/SettingTile.scss b/src/app/molecules/setting-tile/SettingTile.scss new file mode 100644 index 0000000..e3ec1fe --- /dev/null +++ b/src/app/molecules/setting-tile/SettingTile.scss @@ -0,0 +1,16 @@ +.setting-tile { + &__title__wrapper { + display: flex; + align-items: center; + } + &__title { + flex: 1; + min-width: 0; + margin-right: var(--sp-normal); + [dir=rtl] & { + margin-right: 0; + margin-left: var(--sp-normal); + } + } + +} \ No newline at end of file diff --git a/src/app/molecules/sidebar-avatar/SidebarAvatar.jsx b/src/app/molecules/sidebar-avatar/SidebarAvatar.jsx new file mode 100644 index 0000000..47c28a2 --- /dev/null +++ b/src/app/molecules/sidebar-avatar/SidebarAvatar.jsx @@ -0,0 +1,72 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import './SidebarAvatar.scss'; + +import Tippy from '@tippyjs/react'; +import Avatar from '../../atoms/avatar/Avatar'; +import Text from '../../atoms/text/Text'; +import NotificationBadge from '../../atoms/badge/NotificationBadge'; +import { blurOnBubbling } from '../../atoms/button/script'; + +const SidebarAvatar = React.forwardRef(({ + tooltip, text, bgColor, imageSrc, + iconSrc, active, onClick, notifyCount, +}, ref) => { + let activeClass = ''; + if (active) activeClass = ' sidebar-avatar--active'; + return ( + {tooltip}} + className="sidebar-avatar-tippy" + touch="hold" + arrow={false} + placement="right" + maxWidth={200} + delay={[0, 0]} + duration={[100, 0]} + offset={[0, 0]} + > + + + ); +}); +SidebarAvatar.defaultProps = { + text: null, + bgColor: 'transparent', + iconSrc: null, + imageSrc: null, + active: false, + onClick: null, + notifyCount: null, +}; + +SidebarAvatar.propTypes = { + tooltip: PropTypes.string.isRequired, + text: PropTypes.string, + bgColor: PropTypes.string, + imageSrc: PropTypes.string, + iconSrc: PropTypes.string, + active: PropTypes.bool, + onClick: PropTypes.func, + notifyCount: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number, + ]), +}; + +export default SidebarAvatar; diff --git a/src/app/molecules/sidebar-avatar/SidebarAvatar.scss b/src/app/molecules/sidebar-avatar/SidebarAvatar.scss new file mode 100644 index 0000000..6191735 --- /dev/null +++ b/src/app/molecules/sidebar-avatar/SidebarAvatar.scss @@ -0,0 +1,63 @@ + +.sidebar-avatar-tippy { + padding: var(--sp-extra-tight) var(--sp-normal); + background-color: var(--bg-tooltip); + border-radius: var(--bo-radius); + box-shadow: var(--bs-popup); + + .text { + color: var(--tc-tooltip); + } +} + +.sidebar-avatar { + position: relative; + display: flex; + justify-content: center; + align-items: center; + width: 100%; + cursor: pointer; + + & .notification-badge { + position: absolute; + right: var(--sp-extra-tight); + top: calc(-1 * var(--sp-ultra-tight)); + box-shadow: 0 0 0 2px var(--bg-surface-low); + } + &:focus { + outline: none; + } + &:active .avatar-container { + box-shadow: var(--bs-surface-outline); + } + + &:hover::before, + &:focus::before, + &--active::before { + content: ""; + display: block; + position: absolute; + left: 0; + top: 50%; + transform: translateY(-50%); + + width: 3px; + height: 12px; + background-color: var(--ic-surface-normal); + border-radius: 0 4px 4px 0; + transition: height 200ms linear; + + [dir=rtl] & { + right: 0; + border-radius: 4px 0 0 4px; + } + } + &--active:hover::before, + &--active:focus::before, + &--active::before { + height: 28px; + } + &--active .avatar-container { + background-color: var(--bg-surface); + } +} \ No newline at end of file diff --git a/src/app/organisms/channel/Channel.jsx b/src/app/organisms/channel/Channel.jsx new file mode 100644 index 0000000..d980152 --- /dev/null +++ b/src/app/organisms/channel/Channel.jsx @@ -0,0 +1,40 @@ +import React, { useState, useEffect } from 'react'; +import './Channel.scss'; + +import cons from '../../../client/state/cons'; +import navigation from '../../../client/state/navigation'; + +import Welcome from '../welcome/Welcome'; +import ChannelView from './ChannelView'; +import PeopleDrawer from './PeopleDrawer'; + +function Channel() { + const [selectedRoomId, changeSelectedRoomId] = useState(null); + const [isDrawerVisible, toggleDrawerVisiblity] = useState(navigation.isPeopleDrawerVisible); + useEffect(() => { + const handleRoomSelected = (roomId) => { + changeSelectedRoomId(roomId); + }; + const handleDrawerToggling = (visiblity) => { + toggleDrawerVisiblity(visiblity); + }; + navigation.on(cons.events.navigation.ROOM_SELECTED, handleRoomSelected); + navigation.on(cons.events.navigation.PEOPLE_DRAWER_TOGGLED, handleDrawerToggling); + + return () => { + navigation.removeListener(cons.events.navigation.ROOM_SELECTED, handleRoomSelected); + navigation.removeListener(cons.events.navigation.PEOPLE_DRAWER_TOGGLED, handleDrawerToggling); + }; + }, []); + + if (selectedRoomId === null) return ; + + return ( +
+ + { isDrawerVisible && } +
+ ); +} + +export default Channel; diff --git a/src/app/organisms/channel/Channel.scss b/src/app/organisms/channel/Channel.scss new file mode 100644 index 0000000..1d6b6ee --- /dev/null +++ b/src/app/organisms/channel/Channel.scss @@ -0,0 +1,4 @@ +.channel-container { + display: flex; + height: 100%; +} \ No newline at end of file diff --git a/src/app/organisms/channel/ChannelView.jsx b/src/app/organisms/channel/ChannelView.jsx new file mode 100644 index 0000000..4d77f48 --- /dev/null +++ b/src/app/organisms/channel/ChannelView.jsx @@ -0,0 +1,1142 @@ +/* eslint-disable react/prop-types */ +import React, { + useState, useEffect, useLayoutEffect, useRef, +} from 'react'; +import PropTypes from 'prop-types'; +import './ChannelView.scss'; + +import EventEmitter from 'events'; + +import TextareaAutosize from 'react-autosize-textarea'; +import dateFormat from 'dateformat'; +import initMatrix from '../../../client/initMatrix'; +import { getUsername, doesRoomHaveUnread } from '../../../util/matrixUtil'; +import colorMXID from '../../../util/colorMXID'; +import RoomTimeline from '../../../client/state/RoomTimeline'; +import cons from '../../../client/state/cons'; +import { togglePeopleDrawer, openInviteUser } from '../../../client/action/navigation'; +import * as roomActions from '../../../client/action/room'; +import { + bytesToSize, + diffMinutes, + isNotInSameDay, +} from '../../../util/common'; + +import Text from '../../atoms/text/Text'; +import RawIcon from '../../atoms/system-icons/RawIcon'; +import Header, { TitleWrapper } from '../../atoms/header/Header'; +import Avatar from '../../atoms/avatar/Avatar'; +import IconButton from '../../atoms/button/IconButton'; +import ContextMenu, { MenuItem, MenuHeader } from '../../atoms/context-menu/ContextMenu'; +import ScrollView from '../../atoms/scroll/ScrollView'; +import Divider from '../../atoms/divider/Divider'; +import Message, { PlaceholderMessage } from '../../molecules/message/Message'; +import * as Media from '../../molecules/media/Media'; +import TimelineChange from '../../molecules/message/TimelineChange'; +import ChannelIntro from '../../molecules/channel-intro/ChannelIntro'; +import EmojiBoard from '../emoji-board/EmojiBoard'; + +import UserIC from '../../../../public/res/ic/outlined/user.svg'; +import VerticalMenuIC from '../../../../public/res/ic/outlined/vertical-menu.svg'; +import CirclePlusIC from '../../../../public/res/ic/outlined/circle-plus.svg'; +import EmojiIC from '../../../../public/res/ic/outlined/emoji.svg'; +import SendIC from '../../../../public/res/ic/outlined/send.svg'; +import LeaveArrowIC from '../../../../public/res/ic/outlined/leave-arrow.svg'; +import AddUserIC from '../../../../public/res/ic/outlined/add-user.svg'; +import ChevronBottomIC from '../../../../public/res/ic/outlined/chevron-bottom.svg'; +import ShieldIC from '../../../../public/res/ic/outlined/shield.svg'; +import VLCIC from '../../../../public/res/ic/outlined/vlc.svg'; +import VolumeFullIC from '../../../../public/res/ic/outlined/volume-full.svg'; +import FileIC from '../../../../public/res/ic/outlined/file.svg'; + +const MAX_MSG_DIFF_MINUTES = 5; +const viewEvent = new EventEmitter(); + +function getTimelineJSXMessages() { + return { + join(user) { + return ( + <> + {user} + {' joined the channel'} + + ); + }, + leave(user) { + return ( + <> + {user} + {' left the channel'} + + ); + }, + invite(inviter, user) { + return ( + <> + {inviter} + {' invited '} + {user} + + ); + }, + cancelInvite(inviter, user) { + return ( + <> + {inviter} + {' canceled '} + {user} + {'\'s invite'} + + ); + }, + rejectInvite(user) { + return ( + <> + {user} + {' rejected the invitation'} + + ); + }, + kick(actor, user, reason) { + const reasonMsg = (typeof reason === 'string') ? ` for ${reason}` : ''; + return ( + <> + {actor} + {' kicked '} + {user} + {reasonMsg} + + ); + }, + ban(actor, user, reason) { + const reasonMsg = (typeof reason === 'string') ? ` for ${reason}` : ''; + return ( + <> + {actor} + {' banned '} + {user} + {reasonMsg} + + ); + }, + unban(actor, user) { + return ( + <> + {actor} + {' unbanned '} + {user} + + ); + }, + avatarSets(user) { + return ( + <> + {user} + {' set the avatar'} + + ); + }, + avatarChanged(user) { + return ( + <> + {user} + {' changed the avatar'} + + ); + }, + avatarRemoved(user) { + return ( + <> + {user} + {' removed the avatar'} + + ); + }, + nameSets(user, newName) { + return ( + <> + {user} + {' set the display name to '} + {newName} + + ); + }, + nameChanged(user, newName) { + return ( + <> + {user} + {' changed the display name to '} + {newName} + + ); + }, + nameRemoved(user, lastName) { + return ( + <> + {user} + {' removed the display name '} + {lastName} + + ); + }, + }; +} + +function getUsersActionJsx(userIds, actionStr) { + const getUserJSX = (username) => {getUsername(username)}; + if (!Array.isArray(userIds)) return 'Idle'; + if (userIds.length === 0) return 'Idle'; + const MAX_VISIBLE_COUNT = 3; + + const u1Jsx = getUserJSX(userIds[0]); + // eslint-disable-next-line react/jsx-one-expression-per-line + if (userIds.length === 1) return <>{u1Jsx} is {actionStr}; + + const u2Jsx = getUserJSX(userIds[1]); + // eslint-disable-next-line react/jsx-one-expression-per-line + if (userIds.length === 2) return <>{u1Jsx} and {u2Jsx} are {actionStr}; + + const u3Jsx = getUserJSX(userIds[2]); + if (userIds.length === 3) { + // eslint-disable-next-line react/jsx-one-expression-per-line + return <>{u1Jsx}, {u2Jsx} and {u3Jsx} are {actionStr}; + } + + const othersCount = userIds.length - MAX_VISIBLE_COUNT; + // eslint-disable-next-line react/jsx-one-expression-per-line + return <>{u1Jsx}, {u2Jsx}, {u3Jsx} and {othersCount} other are {actionStr}; +} + +function parseReply(rawContent) { + if (rawContent.indexOf('>') !== 0) return null; + let content = rawContent.slice(rawContent.indexOf('@')); + const userId = content.slice(0, content.indexOf('>')); + + content = content.slice(content.indexOf('>') + 2); + const replyContent = content.slice(0, content.indexOf('\n\n')); + content = content.slice(content.indexOf('\n\n') + 2); + + if (userId === '') return null; + + return { + userId, + replyContent, + content, + }; +} +function parseTimelineChange(mEvent) { + const tJSXMsgs = getTimelineJSXMessages(); + const makeReturnObj = (variant, content) => ({ + variant, + content, + }); + const content = mEvent.getContent(); + const prevContent = mEvent.getPrevContent(); + const sender = mEvent.getSender(); + const senderName = getUsername(sender); + const userName = getUsername(mEvent.getStateKey()); + + switch (content.membership) { + case 'invite': return makeReturnObj('invite', tJSXMsgs.invite(senderName, userName)); + case 'ban': return makeReturnObj('leave', tJSXMsgs.ban(senderName, userName, content.reason)); + case 'join': + if (prevContent.membership === 'join') { + if (content.displayname !== prevContent.displayname) { + if (typeof content.displayname === 'undefined') return makeReturnObj('avatar', tJSXMsgs.nameRemoved(sender, prevContent.displayname)); + if (typeof prevContent.displayname === 'undefined') return makeReturnObj('avatar', tJSXMsgs.nameSets(sender, content.displayname)); + return makeReturnObj('avatar', tJSXMsgs.nameChanged(prevContent.displayname, content.displayname)); + } + if (content.avatar_url !== prevContent.avatar_url) { + if (typeof content.avatar_url === 'undefined') return makeReturnObj('avatar', tJSXMsgs.avatarRemoved(content.displayname)); + if (typeof prevContent.avatar_url === 'undefined') return makeReturnObj('avatar', tJSXMsgs.avatarSets(content.displayname)); + return makeReturnObj('avatar', tJSXMsgs.avatarChanged(content.displayname)); + } + return null; + } + return makeReturnObj('join', tJSXMsgs.join(senderName)); + case 'leave': + if (sender === mEvent.getStateKey()) { + switch (prevContent.membership) { + case 'invite': return makeReturnObj('invite-cancel', tJSXMsgs.rejectInvite(senderName)); + default: return makeReturnObj('leave', tJSXMsgs.leave(senderName)); + } + } + switch (prevContent.membership) { + case 'invite': return makeReturnObj('invite-cancel', tJSXMsgs.cancelInvite(senderName, userName)); + case 'ban': return makeReturnObj('other', tJSXMsgs.unban(senderName, userName)); + // sender is not target and made the target leave, + // if not from invite/ban then this is a kick + default: return makeReturnObj('leave', tJSXMsgs.kick(senderName, userName, content.reason)); + } + default: return null; + } +} + +function scrollToBottom(ref) { + const maxScrollTop = ref.current.scrollHeight - ref.current.offsetHeight; + // eslint-disable-next-line no-param-reassign + ref.current.scrollTop = maxScrollTop; +} + +function isAtBottom(ref) { + const { scrollHeight, scrollTop, offsetHeight } = ref.current; + const scrollUptoBottom = scrollTop + offsetHeight; + + // scroll view have to div inside div which contains messages + const lastMessage = ref.current.lastElementChild.lastElementChild.lastElementChild; + const lastChildHeight = lastMessage.offsetHeight; + + // auto scroll to bottom even if user has EXTRA_SPACE left to scroll + const EXTRA_SPACE = 48; + + if (scrollHeight - scrollUptoBottom <= lastChildHeight + EXTRA_SPACE) { + return true; + } + return false; +} + +function autoScrollToBottom(ref) { + if (isAtBottom(ref)) scrollToBottom(ref); +} + +function ChannelViewHeader({ roomId }) { + const mx = initMatrix.matrixClient; + const avatarSrc = mx.getRoom(roomId).getAvatarUrl(mx.baseUrl, 36, 36, 'crop'); + const roomName = mx.getRoom(roomId).name; + const isDM = initMatrix.roomList.directs.has(roomId); + const roomTopic = mx.getRoom(roomId).currentState.getStateEvents('m.room.topic')[0]?.getContent().topic; + + return ( +
+ + + {roomName} + { typeof roomTopic !== 'undefined' &&

{roomTopic}

} +
+ + ( + <> + Options + {/* */} + { + openInviteUser(roomId); toogleMenu(); + }} + > + Invite + + roomActions.leave(roomId, isDM)}>Leave + + )} + render={(toggleMenu) => } + /> +
+ ); +} +ChannelViewHeader.propTypes = { + roomId: PropTypes.string.isRequired, +}; + +let wasAtBottom = true; +function ChannelViewContent({ roomId, roomTimeline, timelineScroll }) { + const [isReachedTimelineEnd, setIsReachedTimelineEnd] = useState(false); + const [onStateUpdate, updateState] = useState(null); + const [onPagination, setOnPagination] = useState(null); + const mx = initMatrix.matrixClient; + + function autoLoadTimeline() { + if (timelineScroll.isScrollable() === true) return; + roomTimeline.paginateBack(); + } + function trySendingReadReceipt() { + const { room, timeline } = roomTimeline; + if (doesRoomHaveUnread(room) && timeline.length !== 0) { + mx.sendReadReceipt(timeline[timeline.length - 1]); + } + } + + function onReachedTop() { + if (roomTimeline.isOngoingPagination || isReachedTimelineEnd) return; + roomTimeline.paginateBack(); + } + function toggleOnReachedBottom(isBottom) { + wasAtBottom = isBottom; + if (!isBottom) return; + trySendingReadReceipt(); + } + + const updatePAG = (canPagMore) => { + if (!canPagMore) { + setIsReachedTimelineEnd(true); + } else { + setOnPagination({}); + autoLoadTimeline(); + } + }; + // force update RoomTimeline on cons.events.roomTimeline.EVENT + const updateRT = () => { + if (wasAtBottom) { + trySendingReadReceipt(); + } + updateState({}); + }; + + useEffect(() => { + setIsReachedTimelineEnd(false); + wasAtBottom = true; + }, [roomId]); + useEffect(() => trySendingReadReceipt(), [roomTimeline]); + + // init room setup completed. + // listen for future. setup stateUpdate listener. + useEffect(() => { + roomTimeline.on(cons.events.roomTimeline.EVENT, updateRT); + roomTimeline.on(cons.events.roomTimeline.PAGINATED, updatePAG); + viewEvent.on('reached-top', onReachedTop); + viewEvent.on('toggle-reached-bottom', toggleOnReachedBottom); + + return () => { + roomTimeline.removeListener(cons.events.roomTimeline.EVENT, updateRT); + roomTimeline.removeListener(cons.events.roomTimeline.PAGINATED, updatePAG); + viewEvent.removeListener('reached-top', onReachedTop); + viewEvent.removeListener('toggle-reached-bottom', toggleOnReachedBottom); + }; + }, [roomTimeline, isReachedTimelineEnd, onPagination]); + + useLayoutEffect(() => { + timelineScroll.reachBottom(); + autoLoadTimeline(); + }, [roomTimeline]); + + useLayoutEffect(() => { + if (onPagination === null) return; + timelineScroll.tryRestoringScroll(); + }, [onPagination]); + + useEffect(() => { + if (onStateUpdate === null) return; + if (wasAtBottom) timelineScroll.reachBottom(); + }, [onStateUpdate]); + + let prevMEvent = null; + function renderMessage(mEvent) { + function isMedia(mE) { + return ( + mE.getContent()?.msgtype === 'm.file' + || mE.getContent()?.msgtype === 'm.image' + || mE.getContent()?.msgtype === 'm.audio' + || mE.getContent()?.msgtype === 'm.video' + ); + } + function genMediaContent(mE) { + const mContent = mE.getContent(); + let mediaMXC = mContent.url; + let thumbnailMXC = mContent?.info?.thumbnail_url; + const isEncryptedFile = typeof mediaMXC === 'undefined'; + if (isEncryptedFile) mediaMXC = mContent.file.url; + + switch (mE.getContent()?.msgtype) { + case 'm.file': + return ( + + ); + case 'm.image': + return ( + + ); + case 'm.audio': + return ( + + ); + case 'm.video': + if (typeof thumbnailMXC === 'undefined') { + thumbnailMXC = mContent.info?.thumbnail_file?.url || null; + } + return ( + + ); + default: + return 'Unable to attach media file!'; + } + } + + if (mEvent.getType() === 'm.room.create') { + const roomTopic = roomTimeline.room.currentState.getStateEvents('m.room.topic')[0]?.getContent().topic; + return ( + + ); + } + if ( + mEvent.getType() !== 'm.room.message' + && mEvent.getType() !== 'm.room.encrypted' + && mEvent.getType() !== 'm.room.member' + ) return false; + if (mEvent.getRelation()?.rel_type === 'm.replace') return false; + + // ignore if message is deleted + if (mEvent.isRedacted()) return false; + + let divider = null; + if (prevMEvent !== null && isNotInSameDay(mEvent.getDate(), prevMEvent.getDate())) { + divider = ; + } + + if (mEvent.getType() !== 'm.room.member') { + const isContentOnly = ( + prevMEvent !== null + && prevMEvent.getType() !== 'm.room.member' + && diffMinutes(mEvent.getDate(), prevMEvent.getDate()) <= MAX_MSG_DIFF_MINUTES + && prevMEvent.getSender() === mEvent.getSender() + ); + + let content = mEvent.getContent().body; + if (typeof content === 'undefined') return null; + let reply = null; + let reactions = null; + let isMarkdown = mEvent.getContent().format === 'org.matrix.custom.html'; + const isReply = typeof mEvent.getWireContent()['m.relates_to']?.['m.in_reply_to'] !== 'undefined'; + const isEdited = roomTimeline.editedTimeline.has(mEvent.getId()); + const haveReactions = roomTimeline.reactionTimeline.has(mEvent.getId()); + + if (isReply) { + const parsedContent = parseReply(content); + + if (parsedContent !== null) { + const username = getUsername(parsedContent.userId); + reply = { + color: colorMXID(parsedContent.userId), + to: username, + content: parsedContent.replyContent, + }; + content = parsedContent.content; + } + } + + if (isEdited) { + const editedList = roomTimeline.editedTimeline.get(mEvent.getId()); + const latestEdited = editedList[editedList.length - 1]; + if (typeof latestEdited.getContent()['m.new_content'] === 'undefined') return null; + const latestEditBody = latestEdited.getContent()['m.new_content'].body; + const parsedEditedContent = parseReply(latestEditBody); + isMarkdown = latestEdited.getContent()['m.new_content'].format === 'org.matrix.custom.html'; + if (parsedEditedContent === null) { + content = latestEditBody; + } else { + content = parsedEditedContent.content; + } + } + + if (haveReactions) { + reactions = []; + roomTimeline.reactionTimeline.get(mEvent.getId()).forEach((rEvent) => { + if (rEvent.getRelation() === null) return; + function alreadyHaveThisReaction(rE) { + for (let i = 0; i < reactions.length; i += 1) { + if (reactions[i].key === rE.getRelation().key) return true; + } + return false; + } + if (alreadyHaveThisReaction(rEvent)) { + for (let i = 0; i < reactions.length; i += 1) { + if (reactions[i].key === rEvent.getRelation().key) { + reactions[i].count += 1; + if (reactions[i].active !== true) { + reactions[i].active = rEvent.getSender() === initMatrix.matrixClient.getUserId(); + } + break; + } + } + } else { + reactions.push({ + id: rEvent.getId(), + key: rEvent.getRelation().key, + count: 1, + active: (rEvent.getSender() === initMatrix.matrixClient.getUserId()), + }); + } + }); + } + + const myMessageEl = ( + + {divider} + { isMedia(mEvent) ? ( + + ) : ( + + )} + + ); + + prevMEvent = mEvent; + return myMessageEl; + } + prevMEvent = mEvent; + const timelineChange = parseTimelineChange(mEvent); + if (timelineChange === null) return null; + return ( + + {divider} + + + ); + } + + const roomTopic = roomTimeline.room.currentState.getStateEvents('m.room.topic')[0]?.getContent().topic; + return ( +
+
+ { + roomTimeline.timeline[0].getType() !== 'm.room.create' && !isReachedTimelineEnd && ( + <> + + + + + ) + } + { + roomTimeline.timeline[0].getType() !== 'm.room.create' && isReachedTimelineEnd && ( + + ) + } + { roomTimeline.timeline.map(renderMessage) } +
+
+ ); +} +ChannelViewContent.propTypes = { + roomId: PropTypes.string.isRequired, + roomTimeline: PropTypes.shape({}).isRequired, + timelineScroll: PropTypes.shape({ + reachBottom: PropTypes.func, + autoReachBottom: PropTypes.func, + tryRestoringScroll: PropTypes.func, + enableSmoothScroll: PropTypes.func, + disableSmoothScroll: PropTypes.func, + isScrollable: PropTypes.func, + }).isRequired, +}; + +function FloatingOptions({ + roomId, roomTimeline, timelineScroll, +}) { + const [reachedBottom, setReachedBottom] = useState(true); + const [typingMembers, setTypingMembers] = useState(new Set()); + const mx = initMatrix.matrixClient; + + function isSomeoneTyping(members) { + const m = members; + m.delete(mx.getUserId()); + if (m.size === 0) return false; + return true; + } + + function getTypingMessage(members) { + const userIds = members; + userIds.delete(mx.getUserId()); + return getUsersActionJsx([...userIds], 'typing...'); + } + + function updateTyping(members) { + setTypingMembers(members); + } + + useEffect(() => { + setReachedBottom(true); + setTypingMembers(new Set()); + viewEvent.on('toggle-reached-bottom', setReachedBottom); + return () => viewEvent.removeListener('toggle-reached-bottom', setReachedBottom); + }, [roomId]); + + useEffect(() => { + roomTimeline.on(cons.events.roomTimeline.TYPING_MEMBERS_UPDATED, updateTyping); + return () => { + roomTimeline?.removeListener(cons.events.roomTimeline.TYPING_MEMBERS_UPDATED, updateTyping); + }; + }, [roomTimeline]); + + return ( + <> +
+
+ {getTypingMessage(typingMembers)} +
+
+ { + timelineScroll.enableSmoothScroll(); + timelineScroll.reachBottom(); + timelineScroll.disableSmoothScroll(); + }} + src={ChevronBottomIC} + tooltip="Scroll to Bottom" + /> +
+ + ); +} +FloatingOptions.propTypes = { + roomId: PropTypes.string.isRequired, + roomTimeline: PropTypes.shape({}).isRequired, + timelineScroll: PropTypes.shape({ + reachBottom: PropTypes.func, + }).isRequired, +}; + +function ChannelViewSticky({ children }) { + return
{children}
; +} +ChannelViewSticky.propTypes = { children: PropTypes.node.isRequired }; + +let isTyping = false; +function ChannelInput({ + roomId, roomTimeline, timelineScroll, +}) { + const [attachment, setAttachment] = useState(null); + + const textAreaRef = useRef(null); + const inputBaseRef = useRef(null); + const uploadInputRef = useRef(null); + const uploadProgressRef = useRef(null); + + const TYPING_TIMEOUT = 5000; + const mx = initMatrix.matrixClient; + const { roomsInput } = initMatrix; + + const sendIsTyping = (isT) => { + mx.sendTyping(roomId, isT, isT ? TYPING_TIMEOUT : undefined); + isTyping = isT; + + if (isT === true) { + setTimeout(() => { + if (isTyping) sendIsTyping(false); + }, TYPING_TIMEOUT); + } + }; + + function uploadingProgress(myRoomId, { loaded, total }) { + if (myRoomId !== roomId) return; + const progressPer = Math.round((loaded * 100) / total); + uploadProgressRef.current.textContent = `Uploading: ${bytesToSize(loaded)}/${bytesToSize(total)} (${progressPer}%)`; + inputBaseRef.current.style.backgroundImage = `linear-gradient(90deg, var(--bg-surface-hover) ${progressPer}%, var(--bg-surface-low) ${progressPer}%)`; + } + function clearAttachment(myRoomId) { + if (roomId !== myRoomId) return; + setAttachment(null); + inputBaseRef.current.style.backgroundImage = 'unset'; + uploadInputRef.current.value = null; + } + + useEffect(() => { + roomsInput.on(cons.events.roomsInput.UPLOAD_PROGRESS_CHANGES, uploadingProgress); + roomsInput.on(cons.events.roomsInput.ATTACHMENT_CANCELED, clearAttachment); + roomsInput.on(cons.events.roomsInput.FILE_UPLOADED, clearAttachment); + if (textAreaRef?.current !== null) { + isTyping = false; + textAreaRef.current.focus(); + textAreaRef.current.value = roomsInput.getMessage(roomId); + setAttachment(roomsInput.getAttachment(roomId)); + } + return () => { + roomsInput.removeListener(cons.events.roomsInput.UPLOAD_PROGRESS_CHANGES, uploadingProgress); + roomsInput.removeListener(cons.events.roomsInput.ATTACHMENT_CANCELED, clearAttachment); + roomsInput.removeListener(cons.events.roomsInput.FILE_UPLOADED, clearAttachment); + if (textAreaRef?.current === null) return; + + const msg = textAreaRef.current.value; + inputBaseRef.current.style.backgroundImage = 'unset'; + if (msg.trim() === '') { + roomsInput.setMessage(roomId, ''); + return; + } + roomsInput.setMessage(roomId, msg); + }; + }, [roomId]); + + async function sendMessage() { + const msgBody = textAreaRef.current.value; + if (roomsInput.isSending(roomId)) return; + if (msgBody.trim() === '' && attachment === null) return; + sendIsTyping(false); + + roomsInput.setMessage(roomId, msgBody); + if (attachment !== null) { + roomsInput.setAttachment(roomId, attachment); + } + textAreaRef.current.disabled = true; + textAreaRef.current.style.cursor = 'not-allowed'; + await roomsInput.sendInput(roomId); + textAreaRef.current.disabled = false; + textAreaRef.current.style.cursor = 'unset'; + textAreaRef.current.focus(); + + textAreaRef.current.value = roomsInput.getMessage(roomId); + timelineScroll.reachBottom(); + viewEvent.emit('message_sent'); + textAreaRef.current.style.height = 'unset'; + } + + function processTyping(msg) { + const isEmptyMsg = msg === ''; + + if (isEmptyMsg && isTyping) { + sendIsTyping(false); + return; + } + if (!isEmptyMsg && !isTyping) { + sendIsTyping(true); + } + } + + function handleMsgTyping(e) { + const msg = e.target.value; + processTyping(msg); + } + + function handleKeyDown(e) { + if (e.keyCode === 13 && e.shiftKey === false) { + e.preventDefault(); + sendMessage(); + } + } + + function addEmoji(emoji) { + textAreaRef.current.value += emoji.unicode; + } + + function handleUploadClick() { + if (attachment === null) uploadInputRef.current.click(); + else { + roomsInput.cancelAttachment(roomId); + } + } + function uploadFileChange(e) { + const file = e.target.files.item(0); + setAttachment(file); + if (file !== null) roomsInput.setAttachment(roomId, file); + } + + function renderInputs() { + return ( + <> +
+ + +
+
+ {roomTimeline.isEncryptedRoom() && } + + + timelineScroll.autoReachBottom()} + onKeyDown={handleKeyDown} + placeholder="Send a message..." + /> + + +
+
+ + )} + render={(toggleMenu) => } + /> + +
+ + ); + } + + function attachFile() { + const fileType = attachment.type.slice(0, attachment.type.indexOf('/')); + return ( +
+
+ {fileType === 'image' && {attachment.name}} + {fileType === 'video' && } + {fileType === 'audio' && } + {fileType !== 'image' && fileType !== 'video' && fileType !== 'audio' && } +
+
+ {attachment.name} + {`size: ${bytesToSize(attachment.size)}`} +
+
+ ); + } + + return ( + <> + { attachment !== null && attachFile() } +
{ e.preventDefault(); }}> + { + roomTimeline.room.isSpaceRoom() + ? Spaces are yet to be implemented + : renderInputs() + } +
+ + ); +} +ChannelInput.propTypes = { + roomId: PropTypes.string.isRequired, + roomTimeline: PropTypes.shape({}).isRequired, + timelineScroll: PropTypes.shape({ + reachBottom: PropTypes.func, + autoReachBottom: PropTypes.func, + tryRestoringScroll: PropTypes.func, + enableSmoothScroll: PropTypes.func, + disableSmoothScroll: PropTypes.func, + }).isRequired, +}; +function ChannelCmdBar({ roomId, roomTimeline }) { + const [followingMembers, setFollowingMembers] = useState([]); + const mx = initMatrix.matrixClient; + + function handleOnMessageSent() { + setFollowingMembers([]); + } + + function updateFollowingMembers() { + const room = mx.getRoom(roomId); + const { timeline } = room; + const userIds = room.getUsersReadUpTo(timeline[timeline.length - 1]); + const myUserId = mx.getUserId(); + setFollowingMembers(userIds.filter((userId) => userId !== myUserId)); + } + + useEffect(() => { + updateFollowingMembers(); + }, [roomId]); + + useEffect(() => { + roomTimeline.on(cons.events.roomTimeline.READ_RECEIPT, updateFollowingMembers); + viewEvent.on('message_sent', handleOnMessageSent); + return () => { + roomTimeline.removeListener(cons.events.roomTimeline.READ_RECEIPT, updateFollowingMembers); + viewEvent.removeListener('message_sent', handleOnMessageSent); + }; + }, [roomTimeline]); + + return ( +
+ { + followingMembers.length !== 0 && ( + + ) + } +
+ ); +} +ChannelCmdBar.propTypes = { + roomId: PropTypes.string.isRequired, + roomTimeline: PropTypes.shape({}).isRequired, +}; + +let lastScrollTop = 0; +let lastScrollHeight = 0; +let isReachedBottom = true; +let isReachedTop = false; +function ChannelView({ roomId }) { + const [roomTimeline, updateRoomTimeline] = useState(null); + const timelineSVRef = useRef(null); + + useEffect(() => { + roomTimeline?.removeInternalListeners(); + updateRoomTimeline(new RoomTimeline(roomId)); + isReachedBottom = true; + isReachedTop = false; + }, [roomId]); + + const timelineScroll = { + reachBottom() { + scrollToBottom(timelineSVRef); + }, + autoReachBottom() { + autoScrollToBottom(timelineSVRef); + }, + tryRestoringScroll() { + const sv = timelineSVRef.current; + const { scrollHeight } = sv; + + if (lastScrollHeight === scrollHeight) return; + + if (lastScrollHeight < scrollHeight) { + sv.scrollTop = lastScrollTop + (scrollHeight - lastScrollHeight); + } else { + timelineScroll.reachBottom(); + } + }, + enableSmoothScroll() { + timelineSVRef.current.style.scrollBehavior = 'smooth'; + }, + disableSmoothScroll() { + timelineSVRef.current.style.scrollBehavior = 'auto'; + }, + isScrollable() { + const oHeight = timelineSVRef.current.offsetHeight; + const sHeight = timelineSVRef.current.scrollHeight; + if (sHeight > oHeight) return true; + return false; + }, + }; + + function onTimelineScroll(e) { + const { scrollTop, scrollHeight, offsetHeight } = e.target; + const scrollBottom = scrollTop + offsetHeight; + lastScrollTop = scrollTop; + lastScrollHeight = scrollHeight; + + const PLACEHOLDER_HEIGHT = 96; + const PLACEHOLDER_COUNT = 3; + + const topPagKeyPoint = PLACEHOLDER_COUNT * PLACEHOLDER_HEIGHT; + const bottomPagKeyPoint = scrollHeight - (offsetHeight / 2); + + if (!isReachedBottom && isAtBottom(timelineSVRef)) { + isReachedBottom = true; + viewEvent.emit('toggle-reached-bottom', true); + } + if (isReachedBottom && !isAtBottom(timelineSVRef)) { + isReachedBottom = false; + viewEvent.emit('toggle-reached-bottom', false); + } + // TOP of timeline + if (scrollTop < topPagKeyPoint && isReachedTop === false) { + isReachedTop = true; + viewEvent.emit('reached-top'); + return; + } + isReachedTop = false; + + // BOTTOM of timeline + if (scrollBottom > bottomPagKeyPoint) { + // TODO: + } + } + + return ( +
+ +
+
+ + {roomTimeline !== null && ( + + )} + + {roomTimeline !== null && ( + + )} +
+ {roomTimeline !== null && ( + + + + + )} +
+
+ ); +} +ChannelView.propTypes = { + roomId: PropTypes.string.isRequired, +}; + +export default ChannelView; diff --git a/src/app/organisms/channel/ChannelView.scss b/src/app/organisms/channel/ChannelView.scss new file mode 100644 index 0000000..9163e61 --- /dev/null +++ b/src/app/organisms/channel/ChannelView.scss @@ -0,0 +1,248 @@ +.channel-view-flexBox { + display: flex; + flex-direction: column; +} +.channel-view-flexItem { + flex: 1; + min-height: 0; + min-width: 0; +} + +.channel-view { + @extend .channel-view-flexItem; + @extend .channel-view-flexBox; + + &__content-wrapper { + @extend .channel-view-flexItem; + @extend .channel-view-flexBox; + } + + &__scrollable { + @extend .channel-view-flexItem; + position: relative; + } + + &__content { + min-height: 100%; + display: flex; + flex-direction: column; + justify-content: flex-end; + + & .timeline__wrapper { + --typing-noti-height: 28px; + min-height: 0; + min-width: 0; + padding-bottom: var(--typing-noti-height); + } + } + + &__typing { + display: flex; + padding: var(--sp-ultra-tight) var(--sp-normal); + background: var(--bg-surface); + transition: transform 200ms ease-in-out; + + & b { + color: var(--tc-surface-high); + } + + &--open { + transform: translateY(-99%); + } + + & .text { + flex: 1; + min-width: 0; + + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + margin: 0 var(--sp-tight); + } + } + + .bouncingLoader { + transform: translateY(2px); + margin: 0 calc(var(--sp-ultra-tight) / 2); + } + .bouncingLoader > div, + .bouncingLoader:before, + .bouncingLoader:after { + display: inline-block; + width: 8px; + height: 8px; + background: var(--tc-surface-high); + border-radius: 50%; + animation: bouncing-loader 0.6s infinite alternate; + } + + .bouncingLoader:before, + .bouncingLoader:after { + content: ""; + } + + .bouncingLoader > div { + margin: 0 4px; + } + + .bouncingLoader > div { + animation-delay: 0.2s; + } + + .bouncingLoader:after { + animation-delay: 0.4s; + } + + @keyframes bouncing-loader { + to { + opacity: 0.1; + transform: translate3d(0, -4px, 0); + } + } + + &__STB { + position: absolute; + right: 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: translateY(100%) scale(0); + [dir=rtl] & { + right: unset; + left: var(--sp-normal); + } + + &--open { + transform: translateY(-28px) scale(1); + } + } + + &__sticky { + min-height: 85px; + position: relative; + background: var(--bg-surface); + border-top: 1px solid var(--bg-surface-border); + } +} + +.channel-input { + padding: var(--sp-extra-tight) calc(var(--sp-normal) - 2px); + display: flex; + min-height: 48px; + + &__space { + min-width: 0; + align-self: center; + margin: auto; + padding: 0 var(--sp-tight); + } + + &__input-container { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + + margin: 0 calc(var(--sp-tight) - 2px); + background-color: var(--bg-surface-low); + box-shadow: var(--bs-surface-border); + border-radius: var(--bo-radius); + + & > .ic-raw { + transform: scale(0.8); + margin-left: var(--sp-extra-tight); + [dir=rtl] & { + margin-left: 0; + margin-right: var(--sp-extra-tight); + } + } + & .scrollbar { + max-height: 50vh; + } + } + + &__textarea-wrapper { + min-height: 40px; + display: flex; + align-items: center; + + & textarea { + resize: none; + width: 100%; + min-width: 0; + min-height: 100%; + padding: var(--sp-ultra-tight) calc(var(--sp-tight) - 2px); + + &::placeholder { + color: var(--tc-surface-low); + } + &:focus { + outline: none; + } + } + } +} + +.channel-cmd-bar { + --cmd-bar-height: 28px; + min-height: var(--cmd-bar-height); + + & .timeline-change { + justify-content: flex-end; + padding: var(--sp-ultra-tight) var(--sp-normal); + + &__content { + margin: 0; + flex: unset; + & > .text { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + & b { + color: var(--tc-surface-normal); + } + } + } + } +} + +.channel-attachment { + --side-spacing: calc(var(--sp-normal) + var(--av-small) + var(--sp-tight)); + display: flex; + align-items: center; + margin-left: var(--side-spacing); + margin-top: var(--sp-extra-tight); + line-height: 0; + [dir=rtl] & { + margin-left: 0; + margin-right: var(--side-spacing); + } + + &__preview > img { + max-height: 40px; + border-radius: var(--bo-radius); + } + &__icon { + padding: var(--sp-extra-tight); + background-color: var(--bg-surface-low); + box-shadow: var(--bs-surface-border); + border-radius: var(--bo-radius); + } + &__info { + flex: 1; + min-width: 0; + margin: 0 var(--sp-tight); + } + + &__option button { + transition: transform 200ms ease-in-out; + transform: translateY(-48px); + & .ic-raw { + transition: transform 200ms ease-in-out; + transform: rotate(45deg); + background-color: var(--bg-caution); + } + } +} \ No newline at end of file diff --git a/src/app/organisms/channel/PeopleDrawer.jsx b/src/app/organisms/channel/PeopleDrawer.jsx new file mode 100644 index 0000000..04aacfc --- /dev/null +++ b/src/app/organisms/channel/PeopleDrawer.jsx @@ -0,0 +1,138 @@ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import './PeopleDrawer.scss'; + +import initMatrix from '../../../client/initMatrix'; +import { getUsername } from '../../../util/matrixUtil'; +import colorMXID from '../../../util/colorMXID'; +import { openInviteUser } from '../../../client/action/navigation'; + +import Text from '../../atoms/text/Text'; +import Header, { TitleWrapper } from '../../atoms/header/Header'; +import IconButton from '../../atoms/button/IconButton'; +import Button from '../../atoms/button/Button'; +import ScrollView from '../../atoms/scroll/ScrollView'; +import Input from '../../atoms/input/Input'; +import PeopleSelector from '../../molecules/people-selector/PeopleSelector'; + +import AddUserIC from '../../../../public/res/ic/outlined/add-user.svg'; + +function getPowerLabel(powerLevel) { + switch (powerLevel) { + case 100: + return 'Admin'; + case 50: + return 'Mod'; + default: + return null; + } +} +function compare(m1, m2) { + let aName = m1.name; + let bName = m2.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; +} +function sortByPowerLevel(m1, m2) { + let pl1 = String(m1.powerLevel); + let pl2 = String(m2.powerLevel); + + if (pl1 === '100') pl1 = '90.9'; + if (pl2 === '100') pl2 = '90.9'; + + if (pl1.toLowerCase() > pl2.toLowerCase()) { + return -1; + } + if (pl1.toLowerCase() < pl2.toLowerCase()) { + return 1; + } + return 0; +} + +function PeopleDrawer({ roomId }) { + const PER_PAGE_MEMBER = 50; + const room = initMatrix.matrixClient.getRoom(roomId); + const totalMemberList = room.getJoinedMembers().sort(compare).sort(sortByPowerLevel); + const [memberList, updateMemberList] = useState([]); + let isRoomChanged = false; + + function loadMorePeople() { + updateMemberList(totalMemberList.slice(0, memberList.length + PER_PAGE_MEMBER)); + } + + useEffect(() => { + updateMemberList(totalMemberList.slice(0, PER_PAGE_MEMBER)); + room.loadMembersIfNeeded().then(() => { + if (isRoomChanged) return; + const newTotalMemberList = room.getJoinedMembers().sort(compare).sort(sortByPowerLevel); + updateMemberList(newTotalMemberList.slice(0, PER_PAGE_MEMBER)); + }); + + return () => { + isRoomChanged = true; + }; + }, [roomId]); + + return ( +
+
+ + + People + {`${room.getJoinedMemberCount()} members`} + + + openInviteUser(roomId)} tooltip="Invite" src={AddUserIC} /> +
+
+
+ +
+ { + memberList.map((member) => ( + alert('Viewing profile is yet to be implemented')} + avatarSrc={member.getAvatarUrl(initMatrix.matrixClient.baseUrl, 24, 24, 'crop')} + name={getUsername(member.userId)} + color={colorMXID(member.userId)} + peopleRole={getPowerLabel(member.powerLevel)} + /> + )) + } +
+ { + memberList.length !== totalMemberList.length && ( + + ) + } +
+
+
+
+
+
e.preventDefault()} className="people-search"> + +
+
+
+
+ ); +} + +PeopleDrawer.propTypes = { + roomId: PropTypes.string.isRequired, +}; + +export default PeopleDrawer; diff --git a/src/app/organisms/channel/PeopleDrawer.scss b/src/app/organisms/channel/PeopleDrawer.scss new file mode 100644 index 0000000..56ac29e --- /dev/null +++ b/src/app/organisms/channel/PeopleDrawer.scss @@ -0,0 +1,75 @@ +.people-drawer-flexBox { + display: flex; + flex-direction: column; +} +.people-drawer-flexItem { + flex: 1; + min-height: 0; + min-width: 0; +} + + +.people-drawer { + @extend .people-drawer-flexBox; + width: var(--people-drawer-width); + background-color: var(--bg-surface-low); + border-left: 1px solid var(--bg-surface-border); + + [dir=rtl] & { + border: { + left: none; + right: 1px solid var(--bg-surface-hover); + } + } + + &__member-count { + color: var(--tc-surface-low); + } + + &__content-wrapper { + @extend .people-drawer-flexItem; + @extend .people-drawer-flexBox; + } + + &__scrollable { + @extend .people-drawer-flexItem; + } + + &__sticky { + display: none; + + & .people-search { + min-height: 48px; + + margin: 0 var(--sp-normal); + + position: relative; + bottom: var(--sp-normal); + + & .input { + height: 48px; + } + } + } +} + +.people-drawer__content { + padding-top: var(--sp-extra-tight); + padding-bottom: calc( var(--sp-extra-tight) + var(--sp-normal)); +} +.people-drawer__load-more { + padding: var(--sp-normal); + padding: { + bottom: 0; + right: var(--sp-extra-tight); + } + + [dir=rtl] & { + padding-right: var(--sp-normal); + padding-left: var(--sp-extra-tight); + } + + & .btn-surface { + width: 100%; + } +} \ No newline at end of file diff --git a/src/app/organisms/create-channel/CreateChannel.jsx b/src/app/organisms/create-channel/CreateChannel.jsx new file mode 100644 index 0000000..c44b536 --- /dev/null +++ b/src/app/organisms/create-channel/CreateChannel.jsx @@ -0,0 +1,165 @@ +import React, { useState, useRef } from 'react'; +import PropTypes from 'prop-types'; +import './CreateChannel.scss'; + +import initMatrix from '../../../client/initMatrix'; +import { isRoomAliasAvailable } from '../../../util/matrixUtil'; +import * as roomActions from '../../../client/action/room'; + +import Text from '../../atoms/text/Text'; +import Button from '../../atoms/button/Button'; +import Toggle from '../../atoms/button/Toggle'; +import IconButton from '../../atoms/button/IconButton'; +import Input from '../../atoms/input/Input'; +import Spinner from '../../atoms/spinner/Spinner'; +import PopupWindow from '../../molecules/popup-window/PopupWindow'; +import SettingTile from '../../molecules/setting-tile/SettingTile'; + +import HashPlusIC from '../../../../public/res/ic/outlined/hash-plus.svg'; +import CrossIC from '../../../../public/res/ic/outlined/cross.svg'; + +function CreateChannel({ isOpen, onRequestClose }) { + const [isPublic, togglePublic] = useState(false); + const [isEncrypted, toggleEncrypted] = useState(true); + const [isValidAddress, updateIsValidAddress] = useState(null); + const [isCreatingRoom, updateIsCreatingRoom] = useState(false); + const [creatingError, updateCreatingError] = useState(null); + + const [titleValue, updateTitleValue] = useState(undefined); + const [topicValue, updateTopicValue] = useState(undefined); + const [addressValue, updateAddressValue] = useState(undefined); + + const addressRef = useRef(null); + const topicRef = useRef(null); + const nameRef = useRef(null); + + const userId = initMatrix.matrixClient.getUserId(); + const hsString = userId.slice(userId.indexOf(':')); + + function resetForm() { + togglePublic(false); + toggleEncrypted(true); + updateIsValidAddress(null); + updateIsCreatingRoom(false); + updateCreatingError(null); + updateTitleValue(undefined); + updateTopicValue(undefined); + updateAddressValue(undefined); + } + + async function createRoom() { + if (isCreatingRoom) return; + updateIsCreatingRoom(true); + updateCreatingError(null); + const name = nameRef.current.value; + let topic = topicRef.current.value; + if (topic.trim() === '') topic = undefined; + let roomAlias; + if (isPublic) { + roomAlias = addressRef?.current?.value; + if (roomAlias.trim() === '') roomAlias = undefined; + } + + try { + await roomActions.create({ + name, topic, isPublic, roomAlias, isEncrypted, + }); + + resetForm(); + onRequestClose(); + } catch (e) { + if (e.message === 'M_UNKNOWN: Invalid characters in room alias') { + updateCreatingError('ERROR: Invalid characters in channel address'); + updateIsValidAddress(false); + } else if (e.message === 'M_ROOM_IN_USE: Room alias already taken') { + updateCreatingError('ERROR: Channel address is already in use'); + updateIsValidAddress(false); + } else updateCreatingError(e.message); + } + updateIsCreatingRoom(false); + } + + function validateAddress(e) { + const myAddress = e.target.value; + updateIsValidAddress(null); + updateAddressValue(e.target.value); + updateCreatingError(null); + + setTimeout(async () => { + if (myAddress !== addressRef.current.value) return; + const roomAlias = addressRef.current.value; + if (roomAlias === '') return; + const roomAddress = `#${roomAlias}${hsString}`; + + if (await isRoomAliasAvailable(roomAddress)) { + updateIsValidAddress(true); + } else { + updateIsValidAddress(false); + } + }, 1000); + } + function handleTitleChange(e) { + if (e.target.value.trim() === '') updateTitleValue(undefined); + updateTitleValue(e.target.value); + } + function handleTopicChange(e) { + if (e.target.value.trim() === '') updateTopicValue(undefined); + updateTopicValue(e.target.value); + } + + return ( + } + onRequestClose={onRequestClose} + > +
+
{ e.preventDefault(); createRoom(); }}> + } + content={Public channel can be joined by anyone.} + /> + {isPublic && ( +
+ Channel address +
+ # + + {hsString} +
+ {isValidAddress === false && {`#${addressValue}${hsString} is already in use`}} +
+ )} + {!isPublic && ( + } + content={You can’t disable this later. Bridges & most bots won’t work yet.} + /> + )} + +
+ + +
+ {isCreatingRoom && ( +
+ + Creating channel... +
+ )} + {typeof creatingError === 'string' && {creatingError}} + +
+
+ ); +} + +CreateChannel.propTypes = { + isOpen: PropTypes.bool.isRequired, + onRequestClose: PropTypes.func.isRequired, +}; + +export default CreateChannel; diff --git a/src/app/organisms/create-channel/CreateChannel.scss b/src/app/organisms/create-channel/CreateChannel.scss new file mode 100644 index 0000000..6d59f65 --- /dev/null +++ b/src/app/organisms/create-channel/CreateChannel.scss @@ -0,0 +1,103 @@ +.create-channel { + margin: 0 var(--sp-normal); + margin-right: var(--sp-extra-tight); + + &__form > * { + margin-top: var(--sp-normal); + &:first-child { + margin-top: var(--sp-extra-tight); + } + } + + &__address { + display: flex; + &__label { + color: var(--tc-surface-low); + margin-bottom: var(--sp-ultra-tight); + } + &__tip { + margin-left: 46px; + margin-top: var(--sp-ultra-tight); + [dir=rtl] & { + margin-left: 0; + margin-right: 46px; + } + } + & .text { + display: flex; + align-items: center; + padding: 0 var(--sp-normal); + border: 1px solid var(--bg-surface-border); + border-radius: var(--bo-radius); + color: var(--tc-surface-low); + } + & *:nth-child(2) { + flex: 1; + min-width: 0; + & .input { + border-radius: 0; + } + } + & .text:first-child { + border-right-width: 0; + border-radius: var(--bo-radius) 0 0 var(--bo-radius); + } + & .text:last-child { + border-left-width: 0; + border-radius: 0 var(--bo-radius) var(--bo-radius) 0; + } + [dir=rtl] & { + & .text:first-child { + border-left-width: 0; + border-right-width: 1px; + border-radius: 0 var(--bo-radius) var(--bo-radius) 0; + } + & .text:last-child { + border-right-width: 0; + border-left-width: 1px; + border-radius: var(--bo-radius) 0 0 var(--bo-radius); + } + } + } + + &__name-wrapper { + display: flex; + align-items: flex-end; + + & .input-container { + flex: 1; + min-width: 0; + margin-right: var(--sp-normal); + [dir=rtl] & { + margin-right: 0; + margin-left: var(--sp-normal); + } + } + & .btn-primary { + padding-top: 11px; + padding-bottom: 11px; + } + } + + &__loading { + display: flex; + justify-content: center; + align-items: center; + & .text { + margin-left: var(--sp-normal); + [dir=rtl] & { + margin-left: 0; + margin-right: var(--sp-normal); + } + } + } + &__error { + text-align: center; + color: var(--bg-danger) !important; + } + + [dir=rtl] & { + margin-right: var(--sp-normal); + margin-left: var(--sp-extra-tight); + } +} \ No newline at end of file diff --git a/src/app/organisms/emoji-board/EmojiBoard.jsx b/src/app/organisms/emoji-board/EmojiBoard.jsx new file mode 100644 index 0000000..e4c2e75 --- /dev/null +++ b/src/app/organisms/emoji-board/EmojiBoard.jsx @@ -0,0 +1,195 @@ +/* eslint-disable jsx-a11y/no-static-element-interactions */ +/* eslint-disable jsx-a11y/click-events-have-key-events */ +import React, { useState, useEffect, useRef } from 'react'; +import PropTypes from 'prop-types'; +import './EmojiBoard.scss'; + +import EventEmitter from 'events'; + +import parse from 'html-react-parser'; +import twemoji from 'twemoji'; +import { emojiGroups, searchEmoji } from './emoji'; + +import Text from '../../atoms/text/Text'; +import RawIcon from '../../atoms/system-icons/RawIcon'; +import IconButton from '../../atoms/button/IconButton'; +import Input from '../../atoms/input/Input'; +import ScrollView from '../../atoms/scroll/ScrollView'; + +import SearchIC from '../../../../public/res/ic/outlined/search.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'; +import BallIC from '../../../../public/res/ic/outlined/ball.svg'; +import PhotoIC from '../../../../public/res/ic/outlined/photo.svg'; +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 viewEvent = new EventEmitter(); + +function EmojiGroup({ name, emojis }) { + function getEmojiBoard() { + const ROW_EMOJIS_COUNT = 7; + const emojiRows = []; + const totalEmojis = emojis.length; + + for (let r = 0; r < totalEmojis; r += ROW_EMOJIS_COUNT) { + const emojiRow = []; + for (let c = r; c < r + ROW_EMOJIS_COUNT; c += 1) { + const emojiIndex = r + c; + if (emojiIndex >= totalEmojis) break; + const emoji = emojis[emojiIndex]; + emojiRow.push( + + { + parse(twemoji.parse( + emoji.unicode, + { + attributes: () => ({ + unicode: emoji.unicode, + shortcodes: emoji.shortcodes?.toString(), + }), + }, + )) + } + , + ); + } + emojiRows.push(
{emojiRow}
); + } + return emojiRows; + } + + return ( +
+ {name} +
{getEmojiBoard()}
+
+ ); +} +EmojiGroup.propTypes = { + name: PropTypes.string.isRequired, + emojis: PropTypes.arrayOf(PropTypes.shape({ + length: PropTypes.number, + unicode: PropTypes.string, + shortcodes: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.arrayOf(PropTypes.string), + ]), + })).isRequired, +}; + +function SearchedEmoji() { + const [searchedEmojis, setSearchedEmojis] = useState([]); + + function handleSearchEmoji(term) { + if (term.trim() === '') { + setSearchedEmojis([]); + return; + } + setSearchedEmojis(searchEmoji(term)); + } + + useEffect(() => { + viewEvent.on('search-emoji', handleSearchEmoji); + return () => { + viewEvent.removeListener('search-emoji', handleSearchEmoji); + }; + }, []); + + return searchedEmojis.length !== 0 && ; +} + +function EmojiBoard({ onSelect }) { + const searchRef = useRef(null); + const scrollEmojisRef = useRef(null); + + function isTargetNotEmoji(target) { + return target.classList.contains('emoji') === false; + } + function getEmojiDataFromTarget(target) { + const unicode = target.getAttribute('unicode'); + let shortcodes = target.getAttribute('shortcodes'); + if (typeof shortcodes === 'undefined') shortcodes = undefined; + else shortcodes = shortcodes.split(','); + return { unicode, shortcodes }; + } + + function selectEmoji(e) { + if (isTargetNotEmoji(e.target)) return; + + const emoji = e.target; + onSelect(getEmojiDataFromTarget(emoji)); + } + + function hoverEmoji(e) { + if (isTargetNotEmoji(e.target)) return; + + const emoji = e.target; + const { shortcodes } = getEmojiDataFromTarget(emoji); + + if (typeof shortcodes === 'undefined') { + searchRef.current.placeholder = 'Search'; + return; + } + if (searchRef.current.placeholder === shortcodes[0]) return; + searchRef.current.setAttribute('placeholder', `:${shortcodes[0]}:`); + } + + function handleSearchChange(e) { + const term = e.target.value; + setTimeout(() => { + if (e.target.value !== term) return; + viewEvent.emit('search-emoji', term); + scrollEmojisRef.current.scrollTop = 0; + }, 500); + } + + function openGroup(groupOrder) { + let tabIndex = groupOrder; + const $emojiContent = scrollEmojisRef.current.firstElementChild; + const groupCount = $emojiContent.childElementCount; + if (groupCount > emojiGroups.length) tabIndex += groupCount - emojiGroups.length; + $emojiContent.children[tabIndex].scrollIntoView(); + } + + return ( +
+
+
+ +
+ + { + emojiGroups.map((group) => ( + + )) + } +
+
+
+
+ + +
+
+
+ openGroup(0)} src={EmojiIC} tooltip="Smileys" tooltipPlacement="right" /> + openGroup(1)} src={DogIC} tooltip="Animals" tooltipPlacement="right" /> + openGroup(2)} src={CupIC} tooltip="Food" tooltipPlacement="right" /> + openGroup(3)} src={BallIC} tooltip="Activity" tooltipPlacement="right" /> + openGroup(4)} src={PhotoIC} tooltip="Travel" tooltipPlacement="right" /> + openGroup(5)} src={BulbIC} tooltip="Objects" tooltipPlacement="right" /> + openGroup(6)} src={PeaceIC} tooltip="Symbols" tooltipPlacement="right" /> + openGroup(7)} src={FlagIC} tooltip="Flags" tooltipPlacement="right" /> +
+
+ ); +} + +EmojiBoard.propTypes = { + onSelect: PropTypes.func.isRequired, +}; + +export default EmojiBoard; diff --git a/src/app/organisms/emoji-board/EmojiBoard.scss b/src/app/organisms/emoji-board/EmojiBoard.scss new file mode 100644 index 0000000..6d43d66 --- /dev/null +++ b/src/app/organisms/emoji-board/EmojiBoard.scss @@ -0,0 +1,89 @@ +.emoji-board-flexBoxV { + display: flex; + flex-direction: column; +} +.emoji-board-flexItem { + flex: 1; + min-height: 0; + min-width: 0; +} + +.emoji-board { + display: flex; + + &__content { + @extend .emoji-board-flexItem; + @extend .emoji-board-flexBoxV; + height: 360px; + } + &__nav { + @extend .emoji-board-flexBoxV; + + padding: 4px 6px; + background-color: var(--bg-surface-low); + border-left: 1px solid var(--bg-surface-border); + [dir=rtl] & { + border-left: none; + border-right: 1px solid var(--bg-surface-border); + } + + & > .ic-btn-surface { + margin: calc(var(--sp-ultra-tight) / 2) 0; + } + } +} + + +.emoji-board__emojis { + @extend .emoji-board-flexItem; +} +.emoji-board__search { + display: flex; + align-items: center; + padding: calc(var(--sp-ultra-tight) / 2) var(--sp-normal); + + & .input-container { + @extend .emoji-board-flexItem; + & .input { + min-width: 100%; + width: 0; + background-color: transparent; + border: none !important; + box-shadow: none !important; + } + } +} + +.emoji-group { + --emoji-padding: 6px; + position: relative; + margin-bottom: var(--sp-normal); + + &__header { + position: sticky; + top: 0; + z-index: 99; + background-color: var(--bg-surface); + + padding: var(--sp-tight) var(--sp-normal); + text-transform: uppercase; + font-weight: 600; + } + & .emoji-set { + margin: 0 calc(var(--sp-normal) - var(--emoji-padding)); + margin-right: calc(var(--sp-extra-tight) - var(--emoji-padding)); + [dir=rtl] & { + margin-right: calc(var(--sp-normal) - var(--emoji-padding)); + margin-left: calc(var(--sp-extra-tight) - var(--emoji-padding)); + } + } + & .emoji { + width: 38px; + padding: var(--emoji-padding); + cursor: pointer; + &:hover { + background-color: var(--bg-surface-hover); + border-radius: var(--bo-radius); + } + } +} \ No newline at end of file diff --git a/src/app/organisms/emoji-board/emoji.js b/src/app/organisms/emoji-board/emoji.js new file mode 100644 index 0000000..e759d59 --- /dev/null +++ b/src/app/organisms/emoji-board/emoji.js @@ -0,0 +1,76 @@ +import emojisData from 'emojibase-data/en/compact.json'; +import shortcodes from 'emojibase-data/en/shortcodes/joypixels.json'; +import Fuse from 'fuse.js'; + +const emojiGroups = [{ + name: 'Smileys & people', + order: 0, + emojis: [], +}, { + name: 'Animals & nature', + order: 1, + emojis: [], +}, { + name: 'Food & drinks', + order: 2, + emojis: [], +}, { + name: 'Activity', + order: 3, + emojis: [], +}, { + name: 'Travel & places', + order: 4, + emojis: [], +}, { + name: 'Objects', + order: 5, + emojis: [], +}, { + name: 'Symbols', + order: 6, + emojis: [], +}, { + name: 'Flags', + order: 7, + emojis: [], +}]; +Object.freeze(emojiGroups); + +function addEmoji(emoji, order) { + emojiGroups[order].emojis.push(emoji); +} +function addToGroup(emoji) { + if (emoji.group === 0 || emoji.group === 1) addEmoji(emoji, 0); + else if (emoji.group === 3) addEmoji(emoji, 1); + else if (emoji.group === 4) addEmoji(emoji, 2); + else if (emoji.group === 6) addEmoji(emoji, 3); + else if (emoji.group === 5) addEmoji(emoji, 4); + else if (emoji.group === 7) addEmoji(emoji, 5); + else if (emoji.group === 8) addEmoji(emoji, 6); + else if (emoji.group === 9) addEmoji(emoji, 7); +} + +const emojis = []; +emojisData.forEach((emoji) => { + const em = { ...emoji, shortcodes: shortcodes[emoji.hexcode] }; + addToGroup(em); + emojis.push(em); +}); + +function searchEmoji(term) { + const options = { + includeScore: true, + keys: ['shortcodes', 'annotation', 'tags'], + threshold: '0.3', + }; + const fuse = new Fuse(emojis, options); + + let result = fuse.search(term); + if (result.length > 20) result = result.slice(0, 20); + return result.map((finding) => finding.item); +} + +export { + emojis, emojiGroups, searchEmoji, +}; diff --git a/src/app/organisms/invite-list/InviteList.jsx b/src/app/organisms/invite-list/InviteList.jsx new file mode 100644 index 0000000..297478e --- /dev/null +++ b/src/app/organisms/invite-list/InviteList.jsx @@ -0,0 +1,135 @@ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import './InviteList.scss'; + +import initMatrix from '../../../client/initMatrix'; +import cons from '../../../client/state/cons'; +import * as roomActions from '../../../client/action/room'; + +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 PopupWindow from '../../molecules/popup-window/PopupWindow'; +import ChannelTile from '../../molecules/channel-tile/ChannelTile'; + +import CrossIC from '../../../../public/res/ic/outlined/cross.svg'; + +function InviteList({ isOpen, onRequestClose }) { + const [procInvite, changeProcInvite] = useState(new Set()); + + function acceptInvite(roomId, isDM) { + procInvite.add(roomId); + changeProcInvite(new Set(Array.from(procInvite))); + roomActions.join(roomId, isDM); + } + function rejectInvite(roomId, isDM) { + procInvite.add(roomId); + changeProcInvite(new Set(Array.from(procInvite))); + roomActions.leave(roomId, isDM); + } + function updateInviteList(roomId) { + if (procInvite.has(roomId)) { + procInvite.delete(roomId); + changeProcInvite(new Set(Array.from(procInvite))); + } else changeProcInvite(new Set(Array.from(procInvite))); + + const rl = initMatrix.roomList; + const totalInvites = rl.inviteDirects.size + rl.inviteRooms.size; + if (totalInvites === 0) onRequestClose(); + } + + useEffect(() => { + initMatrix.roomList.on(cons.events.roomList.INVITELIST_UPDATED, updateInviteList); + + return () => { + initMatrix.roomList.removeListener(cons.events.roomList.INVITELIST_UPDATED, updateInviteList); + }; + }, [procInvite]); + + function renderChannelTile(roomId) { + const myRoom = initMatrix.matrixClient.getRoom(roomId); + const roomName = myRoom.name; + let roomAlias = myRoom.getCanonicalAlias(); + if (roomAlias === null) roomAlias = myRoom.roomId; + return ( + ) + : ( +
+ + +
+ ) + } + /> + ); + } + + return ( + } + onRequestClose={onRequestClose} + > +
+ { initMatrix.roomList.inviteDirects.size !== 0 && ( +
+ Direct Messages +
+ )} + { + Array.from(initMatrix.roomList.inviteDirects).map((roomId) => { + const myRoom = initMatrix.matrixClient.getRoom(roomId); + const roomName = myRoom.name; + return ( + ) + : ( +
+ + +
+ ) + } + /> + ); + }) + } + { initMatrix.roomList.inviteSpaces.size !== 0 && ( +
+ Spaces +
+ )} + { Array.from(initMatrix.roomList.inviteSpaces).map(renderChannelTile) } + + { initMatrix.roomList.inviteRooms.size !== 0 && ( +
+ Channels +
+ )} + { Array.from(initMatrix.roomList.inviteRooms).map(renderChannelTile) } +
+
+ ); +} + +InviteList.propTypes = { + isOpen: PropTypes.bool.isRequired, + onRequestClose: PropTypes.func.isRequired, +}; + +export default InviteList; diff --git a/src/app/organisms/invite-list/InviteList.scss b/src/app/organisms/invite-list/InviteList.scss new file mode 100644 index 0000000..bdb78c4 --- /dev/null +++ b/src/app/organisms/invite-list/InviteList.scss @@ -0,0 +1,39 @@ +.invites-content { + margin: 0 var(--sp-normal); + margin-right: var(--sp-extra-tight); + + &__subheading { + margin-top: var(--sp-extra-loose); + + & .text { + text-transform: uppercase; + font-weight: 600; + } + &:first-child { + margin-top: var(--sp-tight); + } + } + + & .channel-tile { + margin-top: var(--sp-normal); + &__options { + align-self: flex-end; + } + } + & .invite-btn__container .btn-surface { + margin-right: var(--sp-normal); + [dir=rtl] & { + margin: { + right: 0; + left: var(--sp-normal); + } + } + } + + [dir=rtl] & { + margin: { + left: var(--sp-extra-tight); + right: var(--sp-normal); + } + } +} \ No newline at end of file diff --git a/src/app/organisms/invite-user/InviteUser.jsx b/src/app/organisms/invite-user/InviteUser.jsx new file mode 100644 index 0000000..251ffdf --- /dev/null +++ b/src/app/organisms/invite-user/InviteUser.jsx @@ -0,0 +1,269 @@ +import React, { useState, useEffect, useRef } from 'react'; +import PropTypes from 'prop-types'; +import './InviteUser.scss'; + +import initMatrix from '../../../client/initMatrix'; +import cons from '../../../client/state/cons'; +import * as roomActions from '../../../client/action/room'; +import { selectRoom } from '../../../client/action/navigation'; + +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 Input from '../../atoms/input/Input'; +import PopupWindow from '../../molecules/popup-window/PopupWindow'; +import ChannelTile from '../../molecules/channel-tile/ChannelTile'; + +import CrossIC from '../../../../public/res/ic/outlined/cross.svg'; +import UserIC from '../../../../public/res/ic/outlined/user.svg'; + +function InviteUser({ isOpen, roomId, onRequestClose }) { + const [isSearching, updateIsSearching] = useState(false); + const [searchQuery, updateSearchQuery] = useState({}); + const [users, updateUsers] = useState([]); + + const [procUsers, updateProcUsers] = useState(new Set()); // proc stands for processing. + const [procUserError, updateUserProcError] = useState(new Map()); + + const [createdDM, updateCreatedDM] = useState(new Map()); + const [roomIdToUserId, updateRoomIdToUserId] = useState(new Map()); + + const [invitedUserIds, updateInvitedUserIds] = useState(new Set()); + + const usernameRef = useRef(null); + + const mx = initMatrix.matrixClient; + + function getMapCopy(myMap) { + const newMap = new Map(); + myMap.forEach((data, key) => { + newMap.set(key, data); + }); + return newMap; + } + function addUserToProc(userId) { + procUsers.add(userId); + updateProcUsers(new Set(Array.from(procUsers))); + } + function deleteUserFromProc(userId) { + procUsers.delete(userId); + updateProcUsers(new Set(Array.from(procUsers))); + } + + function onDMCreated(newRoomId) { + const myDMPartnerId = roomIdToUserId.get(newRoomId); + if (typeof myDMPartnerId === 'undefined') return; + + createdDM.set(myDMPartnerId, newRoomId); + roomIdToUserId.delete(newRoomId); + + deleteUserFromProc(myDMPartnerId); + updateCreatedDM(getMapCopy(createdDM)); + updateRoomIdToUserId(getMapCopy(roomIdToUserId)); + } + + useEffect(() => () => { + updateIsSearching(false); + updateSearchQuery({}); + updateUsers([]); + updateProcUsers(new Set()); + updateUserProcError(new Map()); + updateCreatedDM(new Map()); + updateRoomIdToUserId(new Map()); + updateInvitedUserIds(new Set()); + }, [isOpen]); + + useEffect(() => { + initMatrix.roomList.on(cons.events.roomList.ROOM_CREATED, onDMCreated); + return () => { + initMatrix.roomList.removeListener(cons.events.roomList.ROOM_CREATED, onDMCreated); + }; + }, [isOpen, procUsers, createdDM, roomIdToUserId]); + + async function searchUser() { + const inputUsername = usernameRef.current.value.trim(); + if (isSearching || inputUsername === '' || inputUsername === searchQuery.username) return; + const isInputUserId = inputUsername[0] === '@' && inputUsername.indexOf(':') > 1; + updateIsSearching(true); + updateSearchQuery({ username: inputUsername }); + + if (isInputUserId) { + try { + const result = await mx.getProfileInfo(inputUsername); + updateUsers([{ + user_id: inputUsername, + display_name: result.displayname, + avatar_url: result.avatar_url, + }]); + } catch (e) { + updateSearchQuery({ error: `${inputUsername} not found!` }); + } + } else { + try { + const result = await mx.searchUserDirectory({ + term: inputUsername, + limit: 20, + }); + if (result.results.length === 0) { + updateSearchQuery({ error: `No matches found for "${inputUsername}"!` }); + updateIsSearching(false); + return; + } + updateUsers(result.results); + } catch (e) { + updateSearchQuery({ error: 'Something went wrong!' }); + } + } + updateIsSearching(false); + } + + async function createDM(userId) { + if (mx.getUserId() === userId) return; + try { + addUserToProc(userId); + procUserError.delete(userId); + updateUserProcError(getMapCopy(procUserError)); + + const result = await roomActions.create({ + isPublic: false, + isEncrypted: true, + isDirect: true, + invite: [userId], + }); + roomIdToUserId.set(result.room_id, userId); + updateRoomIdToUserId(getMapCopy(roomIdToUserId)); + } catch (e) { + deleteUserFromProc(userId); + if (typeof e.message === 'string') procUserError.set(userId, e.message); + else procUserError.set(userId, 'Something went wrong!'); + updateUserProcError(getMapCopy(procUserError)); + } + } + + async function inviteToRoom(userId) { + if (typeof roomId === 'undefined') return; + try { + addUserToProc(userId); + procUserError.delete(userId); + updateUserProcError(getMapCopy(procUserError)); + + await roomActions.invite(roomId, userId); + + invitedUserIds.add(userId); + updateInvitedUserIds(new Set(Array.from(invitedUserIds))); + deleteUserFromProc(userId); + } catch (e) { + deleteUserFromProc(userId); + if (typeof e.message === 'string') procUserError.set(userId, e.message); + else procUserError.set(userId, 'Something went wrong!'); + updateUserProcError(getMapCopy(procUserError)); + } + } + + function renderUserList() { + const renderOptions = (userId) => { + const messageJSX = (message, isPositive) => {message}; + + if (mx.getUserId() === userId) return null; + if (procUsers.has(userId)) { + return ; + } + if (createdDM.has(userId)) { + // eslint-disable-next-line max-len + return ; + } + if (invitedUserIds.has(userId)) { + return messageJSX('Invited', true); + } + if (typeof roomId === 'string') { + const member = mx.getRoom(roomId).getMember(userId); + if (member !== null) { + const userMembership = member.membership; + switch (userMembership) { + case 'join': + return messageJSX('Already joined', true); + case 'invite': + return messageJSX('Already Invited', true); + case 'ban': + return messageJSX('Banned', false); + default: + } + } + } + return (typeof roomId === 'string') + ? + : ; + }; + const renderError = (userId) => { + if (!procUserError.has(userId)) return null; + return {procUserError.get(userId)}; + }; + + return users.map((user) => { + const userId = user.user_id; + const name = typeof user.display_name === 'string' ? user.display_name : userId; + return ( + + ); + }); + } + + return ( + } + onRequestClose={onRequestClose} + > +
+
{ e.preventDefault(); searchUser(); }}> + + +
+
+ { + typeof searchQuery.username !== 'undefined' && isSearching && ( +
+ + {`Searching for user "${searchQuery.username}"...`} +
+ ) + } + { + typeof searchQuery.username !== 'undefined' && !isSearching && ( + {`Search result for user "${searchQuery.username}"`} + ) + } + { + searchQuery.error && {searchQuery.error} + } +
+ { users.length !== 0 && ( +
+ {renderUserList()} +
+ )} +
+
+ ); +} + +InviteUser.defaultProps = { + roomId: undefined, +}; + +InviteUser.propTypes = { + isOpen: PropTypes.bool.isRequired, + roomId: PropTypes.string, + onRequestClose: PropTypes.func.isRequired, +}; + +export default InviteUser; diff --git a/src/app/organisms/invite-user/InviteUser.scss b/src/app/organisms/invite-user/InviteUser.scss new file mode 100644 index 0000000..cfef9a3 --- /dev/null +++ b/src/app/organisms/invite-user/InviteUser.scss @@ -0,0 +1,55 @@ +.invite-user { + margin: 0 var(--sp-normal); + margin-right: var(--sp-extra-tight); + margin-top: var(--sp-extra-tight); + + &__form { + display: flex; + align-items: flex-end; + + & .input-container { + flex: 1; + min-width: 0; + margin-right: var(--sp-normal); + [dir=rtl] & { + margin-right: 0; + margin-left: var(--sp-normal); + } + } + + & .btn-primary { + padding: { + top: 11px; + bottom: 11px; + } + } + } + + &__search-status { + margin-top: var(--sp-extra-loose); + margin-bottom: var(--sp-tight); + & .donut-spinner { + margin: 0 var(--sp-tight); + } + } + &__search-error { + color: var(--bg-danger); + } + &__content { + border-top: 1px solid var(--bg-surface-border); + } + + & .channel-tile { + margin-top: var(--sp-normal); + &__options { + align-self: flex-end; + } + } + + [dir=rtl] & { + margin: { + left: var(--sp-extra-tight); + right: var(--sp-normal); + } + } +} \ No newline at end of file diff --git a/src/app/organisms/navigation/Drawer.jsx b/src/app/organisms/navigation/Drawer.jsx new file mode 100644 index 0000000..bfa1a20 --- /dev/null +++ b/src/app/organisms/navigation/Drawer.jsx @@ -0,0 +1,223 @@ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import './Drawer.scss'; + +import initMatrix from '../../../client/initMatrix'; +import cons from '../../../client/state/cons'; +import { doesRoomHaveUnread } from '../../../util/matrixUtil'; +import { + selectRoom, openPublicChannels, openCreateChannel, openInviteUser, +} from '../../../client/action/navigation'; +import navigation from '../../../client/state/navigation'; + +import Header, { TitleWrapper } from '../../atoms/header/Header'; +import Text from '../../atoms/text/Text'; +import IconButton from '../../atoms/button/IconButton'; +import ScrollView from '../../atoms/scroll/ScrollView'; +import ContextMenu, { MenuItem, MenuHeader } from '../../atoms/context-menu/ContextMenu'; +import ChannelSelector from '../../molecules/channel-selector/ChannelSelector'; + +import PlusIC from '../../../../public/res/ic/outlined/plus.svg'; +// import VerticalMenuIC from '../../../../public/res/ic/outlined/vertical-menu.svg'; +import HashIC from '../../../../public/res/ic/outlined/hash.svg'; +import HashLockIC from '../../../../public/res/ic/outlined/hash-lock.svg'; +import HashPlusIC from '../../../../public/res/ic/outlined/hash-plus.svg'; +import HashSearchIC from '../../../../public/res/ic/outlined/hash-search.svg'; +import SpaceIC from '../../../../public/res/ic/outlined/space.svg'; +import SpaceLockIC from '../../../../public/res/ic/outlined/space-lock.svg'; + +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; +} + +function DrawerHeader({ tabId }) { + return ( +
+ + {(tabId === 'channels' ? 'Home' : 'Direct messages')} + + {(tabId === 'dm') + ? openInviteUser()} tooltip="Start DM" src={PlusIC} size="normal" /> + : ( + ( + <> + Add channel + { hideMenu(); openCreateChannel(); }} + > + Create new channel + + { hideMenu(); openPublicChannels(); }} + > + Add Public channel + + + )} + render={(toggleMenu) => ()} + /> + )} + {/* ''} tooltip="Menu" src={VerticalMenuIC} size="normal" /> */} +
+ ); +} +DrawerHeader.propTypes = { + tabId: PropTypes.string.isRequired, +}; + +function DrawerBradcrumb() { + return ( +
+ +
+ {/* TODO: bradcrumb space paths when spaces become a thing */} +
+
+
+ ); +} + +function renderSelector(room, roomId, isSelected, isDM) { + const mx = initMatrix.matrixClient; + let imageSrc = room.getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 24, 24, 'crop'); + if (typeof imageSrc === 'undefined') imageSrc = null; + + return ( + { + if (room.isSpaceRoom()) { + return (room.getJoinRule() === 'invite' ? SpaceLockIC : SpaceIC); + } + return (room.getJoinRule() === 'invite' ? HashLockIC : HashIC); + })() + } + imageSrc={isDM ? imageSrc : null} + roomId={roomId} + unread={doesRoomHaveUnread(room)} + onClick={() => selectRoom(roomId)} + notificationCount={room.getUnreadNotificationCount('total')} + alert={room.getUnreadNotificationCount('highlight') !== 0} + selected={isSelected} + > + {room.name} + + ); +} + +function Directs({ selectedRoomId }) { + const mx = initMatrix.matrixClient; + const directIds = [...initMatrix.roomList.directs].sort(AtoZ); + + return directIds.map((id) => renderSelector(mx.getRoom(id), id, selectedRoomId === id, true)); +} +Directs.defaultProps = { selectedRoomId: null }; +Directs.propTypes = { selectedRoomId: PropTypes.string }; + +function Home({ selectedRoomId }) { + const mx = initMatrix.matrixClient; + const spaceIds = [...initMatrix.roomList.spaces].sort(AtoZ); + const roomIds = [...initMatrix.roomList.rooms].sort(AtoZ); + + return ( + <> + { spaceIds.length !== 0 && Spaces } + { spaceIds.map((id) => renderSelector(mx.getRoom(id), id, selectedRoomId === id, false)) } + { roomIds.length !== 0 && Channels } + { roomIds.map((id) => renderSelector(mx.getRoom(id), id, selectedRoomId === id, false)) } + + ); +} +Home.defaultProps = { selectedRoomId: null }; +Home.propTypes = { selectedRoomId: PropTypes.string }; + +function Channels({ tabId }) { + const [selectedRoomId, changeSelectedRoomId] = useState(null); + const [, updateState] = useState(); + + const selectHandler = (roomId) => changeSelectedRoomId(roomId); + const handleDataChanges = () => updateState({}); + + const onRoomListChange = () => { + const { spaces, rooms, directs } = initMatrix.roomList; + if (!( + spaces.has(selectedRoomId) + || rooms.has(selectedRoomId) + || directs.has(selectedRoomId)) + ) { + selectRoom(null); + } + }; + + useEffect(() => { + navigation.on(cons.events.navigation.ROOM_SELECTED, selectHandler); + initMatrix.roomList.on(cons.events.roomList.ROOMLIST_UPDATED, handleDataChanges); + + return () => { + navigation.removeListener(cons.events.navigation.ROOM_SELECTED, selectHandler); + initMatrix.roomList.removeListener(cons.events.roomList.ROOMLIST_UPDATED, handleDataChanges); + }; + }, []); + useEffect(() => { + initMatrix.roomList.on(cons.events.roomList.ROOMLIST_UPDATED, onRoomListChange); + + return () => { + initMatrix.roomList.removeListener(cons.events.roomList.ROOMLIST_UPDATED, onRoomListChange); + }; + }, [selectedRoomId]); + + return ( +
+ { + tabId === 'channels' + ? + : + } +
+ ); +} +Channels.propTypes = { + tabId: PropTypes.string.isRequired, +}; + +function Drawer({ tabId }) { + return ( +
+ +
+ +
+ + + +
+
+
+ ); +} + +Drawer.propTypes = { + tabId: PropTypes.string.isRequired, +}; + +export default Drawer; diff --git a/src/app/organisms/navigation/Drawer.scss b/src/app/organisms/navigation/Drawer.scss new file mode 100644 index 0000000..5e45262 --- /dev/null +++ b/src/app/organisms/navigation/Drawer.scss @@ -0,0 +1,48 @@ +.drawer-flexBox { + display: flex; + flex-direction: column; +} +.drawer-flexItem { + flex: 1; + min-height: 0; +} + +.drawer { + @extend .drawer-flexItem; + @extend .drawer-flexBox; + min-width: 0; + border-right: 1px solid var(--bg-surface-border); + + [dir=rtl] & { + border-right: none; + border-left: 1px solid var(--bg-surface-border); + } + + &__content-wrapper { + @extend .drawer-flexItem; + @extend .drawer-flexBox; + } +} + +.breadcrumb__wrapper { + display: none; + height: var(--header-height); +} +.channels__wrapper { + @extend .drawer-flexItem; +} + +.channels-container { + padding-bottom: var(--sp-extra-loose); + + & > .channel-selector__button-wrapper:first-child { + margin-top: var(--sp-extra-tight); + } + + & .cat-header { + margin: var(--sp-normal); + margin-bottom: var(--sp-extra-tight); + text-transform: uppercase; + font-weight: 600; + } +} \ No newline at end of file diff --git a/src/app/organisms/navigation/Navigation.jsx b/src/app/organisms/navigation/Navigation.jsx new file mode 100644 index 0000000..380266d --- /dev/null +++ b/src/app/organisms/navigation/Navigation.jsx @@ -0,0 +1,36 @@ +import React, { useState, useEffect } from 'react'; +import './Navigation.scss'; + +import cons from '../../../client/state/cons'; +import navigation from '../../../client/state/navigation'; +import { handleTabChange } from '../../../client/action/navigation'; + +import SideBar from './SideBar'; +import Drawer from './Drawer'; + +function Navigation() { + const [activeTab, changeActiveTab] = useState(navigation.getActiveTab()); + + function changeTab(tabId) { + handleTabChange(tabId); + } + + useEffect(() => { + const handleTab = () => { + changeActiveTab(navigation.getActiveTab()); + }; + navigation.on(cons.events.navigation.TAB_CHANGED, handleTab); + + return () => { + navigation.removeListener(cons.events.navigation.TAB_CHANGED, handleTab); + }; + }, []); + return ( +
+ + +
+ ); +} + +export default Navigation; diff --git a/src/app/organisms/navigation/Navigation.scss b/src/app/organisms/navigation/Navigation.scss new file mode 100644 index 0000000..4a932c7 --- /dev/null +++ b/src/app/organisms/navigation/Navigation.scss @@ -0,0 +1,7 @@ +.navigation { + width: 100%; + height: 100%; + background-color: var(--bg-surface-low); + + display: flex; +} \ No newline at end of file diff --git a/src/app/organisms/navigation/SideBar.jsx b/src/app/organisms/navigation/SideBar.jsx new file mode 100644 index 0000000..5b86ec8 --- /dev/null +++ b/src/app/organisms/navigation/SideBar.jsx @@ -0,0 +1,118 @@ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import './SideBar.scss'; + +import initMatrix from '../../../client/initMatrix'; +import cons from '../../../client/state/cons'; +import colorMXID from '../../../util/colorMXID'; +import logout from '../../../client/action/logout'; +import { openInviteList, openPublicChannels, openSettings } from '../../../client/action/navigation'; + +import ScrollView from '../../atoms/scroll/ScrollView'; +import SidebarAvatar from '../../molecules/sidebar-avatar/SidebarAvatar'; +import ContextMenu, { MenuItem, MenuHeader, MenuBorder } from '../../atoms/context-menu/ContextMenu'; + +import HomeIC from '../../../../public/res/ic/outlined/home.svg'; +import UserIC from '../../../../public/res/ic/outlined/user.svg'; +import HashSearchIC from '../../../../public/res/ic/outlined/hash-search.svg'; +import InviteIC from '../../../../public/res/ic/outlined/invite.svg'; +import SettingsIC from '../../../../public/res/ic/outlined/settings.svg'; +import PowerIC from '../../../../public/res/ic/outlined/power.svg'; + +function ProfileAvatarMenu() { + const mx = initMatrix.matrixClient; + + return ( + ( + <> + {mx.getUserId()} + {/* ''}>Profile */} + {/* ''}>Notification settings */} + { hideMenu(); openSettings(); }} + > + Settings + + + Logout + + )} + render={(toggleMenu) => ( + + )} + /> + ); +} + +function SideBar({ tabId, changeTab }) { + const totalInviteCount = () => initMatrix.roomList.inviteRooms.size + + initMatrix.roomList.inviteSpaces.size + + initMatrix.roomList.inviteDirects.size; + + const [totalInvites, updateTotalInvites] = useState(totalInviteCount()); + + function onInviteListChange() { + updateTotalInvites(totalInviteCount()); + } + + useEffect(() => { + initMatrix.roomList.on( + cons.events.roomList.INVITELIST_UPDATED, + onInviteListChange, + ); + + return () => { + initMatrix.roomList.removeListener( + cons.events.roomList.INVITELIST_UPDATED, + onInviteListChange, + ); + }; + }, []); + + return ( +
+
+ +
+
+ changeTab('channels')} tooltip="Home" iconSrc={HomeIC} /> + changeTab('dm')} tooltip="People" iconSrc={UserIC} /> + openPublicChannels()} tooltip="Public channels" iconSrc={HashSearchIC} /> +
+
+
+
+ +
+
+
+
+ { totalInvites !== 0 && ( + openInviteList()} + tooltip="Invites" + iconSrc={InviteIC} + /> + )} + +
+
+
+ ); +} + +SideBar.propTypes = { + tabId: PropTypes.string.isRequired, + changeTab: PropTypes.func.isRequired, +}; + +export default SideBar; diff --git a/src/app/organisms/navigation/SideBar.scss b/src/app/organisms/navigation/SideBar.scss new file mode 100644 index 0000000..0f4e677 --- /dev/null +++ b/src/app/organisms/navigation/SideBar.scss @@ -0,0 +1,70 @@ +.sidebar__flexBox { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: center; +} + +.sidebar { + @extend .sidebar__flexBox; + width: var(--navigation-sidebar-width); + height: 100%; + border-right: 1px solid var(--bg-surface-border); + + [dir=rtl] & { + border-right: none; + border-left: 1px solid var(--bg-surface-border); + } + + &__scrollable, + &__sticky { + width: 100%; + } + + &__scrollable { + flex: 1; + min-height: 0px; + } + + &__sticky { + align-items: center; + } +} + +.scrollable-content { + &::after { + content: ""; + display: block; + width: 100%; + height: 8px; + + background: transparent; + // background-image: linear-gradient(to top, var(--bg-surface-low), transparent); + // It produce bug in safari + // To fix it, we have to set the color as a fully transparent version of that exact color. like: + // background-image: linear-gradient(to top, rgb(255, 255, 255), rgba(255, 255, 255, 0)); + // TODO: fix this bug while implementing spaces + position: sticky; + bottom: 0; + left: 0; + } +} + +.featured-container, +.space-container, +.sticky-container { + @extend .sidebar__flexBox; + + padding: var(--sp-ultra-tight) 0; + + & > .sidebar-avatar, + & > .avatar-container { + margin: calc(var(--sp-tight) / 2) 0; + } +} +.sidebar-divider { + margin: auto; + width: 24px; + height: 1px; + background-color: var(--bg-surface-border); +} \ No newline at end of file diff --git a/src/app/organisms/public-channels/PublicChannels.jsx b/src/app/organisms/public-channels/PublicChannels.jsx new file mode 100644 index 0000000..6527798 --- /dev/null +++ b/src/app/organisms/public-channels/PublicChannels.jsx @@ -0,0 +1,199 @@ +import React, { useState, useEffect, useRef } from 'react'; +import PropTypes from 'prop-types'; +import './PublicChannels.scss'; + +import initMatrix from '../../../client/initMatrix'; +import cons from '../../../client/state/cons'; +import { selectRoom } from '../../../client/action/navigation'; +import * as roomActions from '../../../client/action/room'; + +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 Input from '../../atoms/input/Input'; +import PopupWindow from '../../molecules/popup-window/PopupWindow'; +import ChannelTile from '../../molecules/channel-tile/ChannelTile'; + +import CrossIC from '../../../../public/res/ic/outlined/cross.svg'; +import HashSearchIC from '../../../../public/res/ic/outlined/hash-search.svg'; + +const SEARCH_LIMIT = 20; + +function PublicChannels({ isOpen, onRequestClose }) { + const [isSearching, updateIsSearching] = useState(false); + const [isViewMore, updateIsViewMore] = useState(false); + const [publicChannels, updatePublicChannels] = useState([]); + const [nextBatch, updateNextBatch] = useState(undefined); + const [searchQuery, updateSearchQuery] = useState({}); + const [joiningChannels, updateJoiningChannels] = useState(new Set()); + + const channelNameRef = useRef(null); + const hsRef = useRef(null); + const userId = initMatrix.matrixClient.getUserId(); + + async function searchChannels(viewMore) { + let inputHs = hsRef?.current?.value; + let inputChannelName = channelNameRef?.current?.value; + + if (typeof inputHs !== 'string') inputHs = userId.slice(userId.indexOf(':') + 1); + if (typeof inputChannelName !== 'string') inputChannelName = ''; + + if (isSearching) return; + if (viewMore !== true + && inputChannelName === searchQuery.name + && inputHs === searchQuery.homeserver + ) return; + + updateSearchQuery({ + name: inputChannelName, + homeserver: inputHs, + }); + if (isViewMore !== viewMore) updateIsViewMore(viewMore); + updateIsSearching(true); + + try { + const result = await initMatrix.matrixClient.publicRooms({ + server: inputHs, + limit: SEARCH_LIMIT, + since: viewMore ? nextBatch : undefined, + include_all_networks: true, + filter: { + generic_search_term: inputChannelName, + }, + }); + + const totalChannels = viewMore ? publicChannels.concat(result.chunk) : result.chunk; + updatePublicChannels(totalChannels); + updateNextBatch(result.next_batch); + updateIsSearching(false); + updateIsViewMore(false); + } catch (e) { + updatePublicChannels([]); + updateSearchQuery({ error: 'Something went wrong!' }); + updateIsSearching(false); + updateNextBatch(undefined); + updateIsViewMore(false); + } + } + + useEffect(() => { + if (isOpen) searchChannels(); + }, [isOpen]); + + function handleOnRoomAdded(roomId) { + if (joiningChannels.has(roomId)) { + joiningChannels.delete(roomId); + updateJoiningChannels(new Set(Array.from(joiningChannels))); + } + } + useEffect(() => { + initMatrix.roomList.on(cons.events.roomList.ROOM_JOINED, handleOnRoomAdded); + return () => { + initMatrix.roomList.removeListener(cons.events.roomList.ROOM_JOINED, handleOnRoomAdded); + }; + }, [joiningChannels]); + + function handleViewChannel(roomId) { + selectRoom(roomId); + onRequestClose(); + } + + function joinChannel(roomId) { + joiningChannels.add(roomId); + updateJoiningChannels(new Set(Array.from(joiningChannels))); + roomActions.join(roomId, false); + } + + function renderChannelList(channels) { + return channels.map((channel) => { + const alias = typeof channel.canonical_alias === 'string' ? channel.canonical_alias : channel.room_id; + const name = typeof channel.name === 'string' ? channel.name : alias; + const isJoined = initMatrix.roomList.rooms.has(channel.room_id); + return ( + + {isJoined && } + {!isJoined && (joiningChannels.has(channel.room_id) ? : )} + + )} + /> + ); + }); + } + + return ( + } + onRequestClose={onRequestClose} + > +
+
{ e.preventDefault(); searchChannels(); }}> +
+ + +
+ +
+
+ { + typeof searchQuery.name !== 'undefined' && isSearching && ( + searchQuery.name === '' + ? ( +
+ + {`Loading public channels from ${searchQuery.homeserver}...`} +
+ ) + : ( +
+ + {`Searching for "${searchQuery.name}" on ${searchQuery.homeserver}...`} +
+ ) + ) + } + { + typeof searchQuery.name !== 'undefined' && !isSearching && ( + searchQuery.name === '' + ? {`Public channels on ${searchQuery.homeserver}.`} + : {`Search result for "${searchQuery.name}" on ${searchQuery.homeserver}.`} + ) + } + { + searchQuery.error && {searchQuery.error} + } +
+ { publicChannels.length !== 0 && ( +
+ { renderChannelList(publicChannels) } +
+ )} + { publicChannels.length !== 0 && publicChannels.length % SEARCH_LIMIT === 0 && ( +
+ { isViewMore !== true && ( + + )} + { isViewMore && } +
+ )} +
+
+ ); +} + +PublicChannels.propTypes = { + isOpen: PropTypes.bool.isRequired, + onRequestClose: PropTypes.func.isRequired, +}; + +export default PublicChannels; diff --git a/src/app/organisms/public-channels/PublicChannels.scss b/src/app/organisms/public-channels/PublicChannels.scss new file mode 100644 index 0000000..21309ab --- /dev/null +++ b/src/app/organisms/public-channels/PublicChannels.scss @@ -0,0 +1,87 @@ +.public-channels { + margin: 0 var(--sp-normal); + margin-right: var(--sp-extra-tight); + margin-top: var(--sp-extra-tight); + + &__form { + display: flex; + align-items: flex-end; + + & .btn-primary { + padding: { + top: 11px; + bottom: 11px; + } + } + } + &__input-wrapper { + flex: 1; + min-width: 0; + + display: flex; + margin-right: var(--sp-normal); + [dir=rtl] & { + margin-right: 0; + margin-left: var(--sp-normal); + } + + & > div:first-child { + flex: 1; + min-width: 0; + + & .input { + border-radius: var(--bo-radius) 0 0 var(--bo-radius); + [dir=rtl] & { + border-radius: 0 var(--bo-radius) var(--bo-radius) 0; + } + } + } + + & > div:last-child .input { + width: 120px; + border-left-width: 0; + border-radius: 0 var(--bo-radius) var(--bo-radius) 0; + [dir=rtl] & { + border-left-width: 1px; + border-right-width: 0; + border-radius: var(--bo-radius) 0 0 var(--bo-radius); + } + } + } + + &__search-status { + margin-top: var(--sp-extra-loose); + margin-bottom: var(--sp-tight); + & .donut-spinner { + margin: 0 var(--sp-tight); + } + } + &__search-error { + color: var(--bg-danger); + } + &__content { + border-top: 1px solid var(--bg-surface-border); + } + &__view-more { + margin-top: var(--sp-loose); + margin-left: calc(var(--av-normal) + var(--sp-normal)); + [dir=rtl] & { + margin-left: 0; + margin-right: calc(var(--av-normal) + var(--sp-normal)); + } + } + + & .channel-tile { + margin-top: var(--sp-normal); + &__options { + align-self: flex-end; + } + } + + [dir=rtl] & { + margin: { + left: var(--sp-extra-tight); + right: var(--sp-normal); + } + } +} \ No newline at end of file diff --git a/src/app/organisms/pw/Windows.jsx b/src/app/organisms/pw/Windows.jsx new file mode 100644 index 0000000..a6b5b0d --- /dev/null +++ b/src/app/organisms/pw/Windows.jsx @@ -0,0 +1,80 @@ +import React, { useState, useEffect } from 'react'; + +import cons from '../../../client/state/cons'; +import navigation from '../../../client/state/navigation'; + +import InviteList from '../invite-list/InviteList'; +import PublicChannels from '../public-channels/PublicChannels'; +import CreateChannel from '../create-channel/CreateChannel'; +import InviteUser from '../invite-user/InviteUser'; +import Settings from '../settings/Settings'; + +function Windows() { + const [isInviteList, changeInviteList] = useState(false); + const [isPubilcChannels, changePubilcChannels] = useState(false); + const [isCreateChannel, changeCreateChannel] = useState(false); + const [inviteUser, changeInviteUser] = useState({ isOpen: false, roomId: undefined }); + const [settings, changeSettings] = useState(false); + + function openInviteList() { + changeInviteList(true); + } + function openPublicChannels() { + changePubilcChannels(true); + } + function openCreateChannel() { + changeCreateChannel(true); + } + function openInviteUser(roomId) { + changeInviteUser({ + isOpen: true, + roomId, + }); + } + function openSettings() { + changeSettings(true); + } + + useEffect(() => { + navigation.on(cons.events.navigation.INVITE_LIST_OPENED, openInviteList); + navigation.on(cons.events.navigation.PUBLIC_CHANNELS_OPENED, openPublicChannels); + navigation.on(cons.events.navigation.CREATE_CHANNEL_OPENED, openCreateChannel); + 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_CHANNELS_OPENED, openPublicChannels); + navigation.removeListener(cons.events.navigation.CREATE_CHANNEL_OPENED, openCreateChannel); + navigation.removeListener(cons.events.navigation.INVITE_USER_OPENED, openInviteUser); + navigation.removeListener(cons.events.navigation.SETTINGS_OPENED, openSettings); + }; + }, []); + + return ( + <> + changeInviteList(false)} + /> + changePubilcChannels(false)} + /> + changeCreateChannel(false)} + /> + changeInviteUser({ isOpen: false, roomId: undefined })} + /> + changeSettings(false)} + /> + + ); +} + +export default Windows; diff --git a/src/app/organisms/settings/Settings.jsx b/src/app/organisms/settings/Settings.jsx new file mode 100644 index 0000000..40013da --- /dev/null +++ b/src/app/organisms/settings/Settings.jsx @@ -0,0 +1,56 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import './Settings.scss'; + +import settings from '../../../client/state/settings'; + +import Text from '../../atoms/text/Text'; +import IconButton from '../../atoms/button/IconButton'; +import SegmentedControls from '../../atoms/segmented-controls/SegmentedControls'; + +import PopupWindow from '../../molecules/popup-window/PopupWindow'; +import SettingTile from '../../molecules/setting-tile/SettingTile'; + +import CrossIC from '../../../../public/res/ic/outlined/cross.svg'; + +function Settings({ isOpen, onRequestClose }) { + return ( + } + > +
+ settings.setTheme(index)} + /> + )} + /> +
+ + About + + Version: 1.0.0 +
+ + ); +} + +Settings.propTypes = { + isOpen: PropTypes.bool.isRequired, + onRequestClose: PropTypes.func.isRequired, +}; + +export default Settings; diff --git a/src/app/organisms/settings/Settings.scss b/src/app/organisms/settings/Settings.scss new file mode 100644 index 0000000..7414883 --- /dev/null +++ b/src/app/organisms/settings/Settings.scss @@ -0,0 +1,22 @@ +.settings-window { + & .pw__content-container { + height: 100%; + } +} + +.settings-content { + margin: 0 var(--sp-normal); + margin-right: var(--sp-extra-tight); + [dir=rtl] & { + margin-left: var(--sp-extra-tight); + margin-right: var(--sp-normal); + } + + display: flex; + flex-direction: column; + height: 100%; +} + +.settings__about { + text-align: center; +} \ No newline at end of file diff --git a/src/app/organisms/welcome/Welcome.jsx b/src/app/organisms/welcome/Welcome.jsx new file mode 100644 index 0000000..9d9eb7d --- /dev/null +++ b/src/app/organisms/welcome/Welcome.jsx @@ -0,0 +1,20 @@ +import React from 'react'; +import './Welcome.scss'; + +import Text from '../../atoms/text/Text'; + +import CinnySvg from '../../../../public/res/svg/cinny.svg'; + +function Welcome() { + return ( +
+
+ Cinny logo + Welcome to Cinny + Yet another matrix client +
+
+ ); +} + +export default Welcome; diff --git a/src/app/organisms/welcome/Welcome.scss b/src/app/organisms/welcome/Welcome.scss new file mode 100644 index 0000000..0fcf147 --- /dev/null +++ b/src/app/organisms/welcome/Welcome.scss @@ -0,0 +1,20 @@ +.app-welcome { + width: 100%; + height: 100%; + + & > div { + max-width: 600px; + align-items: center; + } + &__logo { + width: 64px; + height: 64px; + } + &__heading { + margin: var(--sp-extra-loose) 0 var(--sp-tight); + color: var(--tc-surface-high); + } + &__subheading { + color: var(--tc-surface-normal); + } +} \ No newline at end of file diff --git a/src/app/pages/App.jsx b/src/app/pages/App.jsx new file mode 100644 index 0000000..0df840d --- /dev/null +++ b/src/app/pages/App.jsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { + BrowserRouter, Switch, Route, Redirect, +} from 'react-router-dom'; + +import { isAuthanticated } from '../../client/state/auth'; + +import Auth from '../templates/auth/Auth'; +import Client from '../templates/client/Client'; + +function App() { + return ( + + + + { isAuthanticated() ? : } + + + { isAuthanticated() ? : } + + + { isAuthanticated() ? : } + + + + ); +} + +export default App; diff --git a/src/app/templates/auth/Auth.jsx b/src/app/templates/auth/Auth.jsx new file mode 100644 index 0000000..2be50fc --- /dev/null +++ b/src/app/templates/auth/Auth.jsx @@ -0,0 +1,335 @@ +import React, { useState, useRef } from 'react'; +import PropTypes from 'prop-types'; +import './Auth.scss'; +import ReCAPTCHA from 'react-google-recaptcha'; + +import { Link } from 'react-router-dom'; +import * as auth from '../../../client/action/auth'; + +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 CinnySvg from '../../../../public/res/svg/cinny.svg'; + +const USERNAME_REGEX = /^[a-z0-9_-]+$/; +const BAD_USERNAME_ERROR = 'Username must contain only lowercase letters, numbers, dashes and underscores.'; + +const PASSWORD_REGEX = /.+/; +const PASSWORD_STRENGHT_REGEX = /^(?=.*\d)(?=.*[A-Z])(?=.*[a-z])(?=.*[^\w\d\s:])([^\s]){8,16}$/; +const BAD_PASSWORD_ERROR = 'Password must contain 1 number, 1 uppercase letters, 1 lowercase letters, 1 non-alpha numeric number, 8-16 characters with no space.'; +const CONFIRM_PASSWORD_ERROR = 'Password don\'t match.'; + +const EMAIL_REGEX = /([a-z0-9]+[_a-z0-9.-][a-z0-9]+)@([a-z0-9-]+(?:.[a-z0-9-]+).[a-z]{2,4})/; +const BAD_EMAIL_ERROR = 'Invalid email address'; + +function isValidInput(value, regex) { + return regex.test(value); +} +function renderErrorMessage(error) { + const $error = document.getElementById('auth_error'); + $error.textContent = error; + $error.style.display = 'block'; +} +function showBadInputError($input, error) { + renderErrorMessage(error); + $input.focus(); + const myInput = $input; + myInput.style.border = '1px solid var(--bg-danger)'; + myInput.style.boxShadow = 'none'; + document.getElementById('auth_submit-btn').disabled = true; +} + +function validateOnChange(e, regex, error) { + if (!isValidInput(e.target.value, regex) && e.target.value) { + showBadInputError(e.target, error); + return; + } + document.getElementById('auth_error').style.display = 'none'; + e.target.style.removeProperty('border'); + e.target.style.removeProperty('box-shadow'); + document.getElementById('auth_submit-btn').disabled = false; +} + +function Auth({ type }) { + const [process, changeProcess] = useState(null); + const usernameRef = useRef(null); + const homeserverRef = useRef(null); + const passwordRef = useRef(null); + const confirmPasswordRef = useRef(null); + const emailRef = useRef(null); + + function register(recaptchaValue, terms, verified) { + auth.register( + usernameRef.current.value, + homeserverRef.current.value, + passwordRef.current.value, + emailRef.current.value, + recaptchaValue, + terms, + verified, + ).then((res) => { + document.getElementById('auth_submit-btn').disabled = false; + if (res.type === 'recaptcha') { + changeProcess({ type: res.type, sitekey: res.public_key }); + return; + } + if (res.type === 'terms') { + changeProcess({ type: res.type, en: res.en }); + } + if (res.type === 'email') { + changeProcess({ type: res.type }); + } + if (res.type === 'done') { + window.location.replace('/'); + } + }).catch((error) => { + changeProcess(null); + renderErrorMessage(error); + document.getElementById('auth_submit-btn').disabled = false; + }); + if (terms) { + changeProcess({ type: 'loading', message: 'Sending email verification link...' }); + } else changeProcess({ type: 'loading', message: 'Registration in progress...' }); + } + + function handleLogin(e) { + e.preventDefault(); + document.getElementById('auth_submit-btn').disabled = true; + document.getElementById('auth_error').style.display = 'none'; + + if (!isValidInput(usernameRef.current.value, USERNAME_REGEX)) { + showBadInputError(usernameRef.current, BAD_USERNAME_ERROR); + return; + } + + auth.login(usernameRef.current.value, homeserverRef.current.value, passwordRef.current.value) + .then(() => { + document.getElementById('auth_submit-btn').disabled = false; + window.location.replace('/'); + }) + .catch((error) => { + changeProcess(null); + renderErrorMessage(error); + document.getElementById('auth_submit-btn').disabled = false; + }); + changeProcess({ type: 'loading', message: 'Login in progress...' }); + } + + function handleRegister(e) { + e.preventDefault(); + document.getElementById('auth_submit-btn').disabled = true; + document.getElementById('auth_error').style.display = 'none'; + + if (!isValidInput(usernameRef.current.value, USERNAME_REGEX)) { + showBadInputError(usernameRef.current, BAD_USERNAME_ERROR); + return; + } + if (!isValidInput(passwordRef.current.value, PASSWORD_STRENGHT_REGEX)) { + showBadInputError(passwordRef.current, BAD_PASSWORD_ERROR); + return; + } + if (passwordRef.current.value !== confirmPasswordRef.current.value) { + showBadInputError(confirmPasswordRef.current, CONFIRM_PASSWORD_ERROR); + return; + } + if (!isValidInput(emailRef.current.value, EMAIL_REGEX)) { + showBadInputError(emailRef.current, BAD_EMAIL_ERROR); + return; + } + register(); + } + + const handleAuth = (type === 'login') ? handleLogin : handleRegister; + return ( + <> + {process?.type === 'loading' && } + {process?.type === 'recaptcha' && { if (typeof v === 'string') register(v); }} />} + {process?.type === 'terms' && } + {process?.type === 'email' && ( + +
+ Verify email +
+ + Please check your email + {' '} + {`(${emailRef.current.value})`} + {' '} + and validate before continuing further. + +
+ +
+
+ )} + +
+
+ { type === 'login' ? 'Login' : 'Register' } +
+ validateOnChange(e, USERNAME_REGEX, BAD_USERNAME_ERROR)} + id="auth_username" + label="Username" + required + /> + +
+ validateOnChange(e, ((type === 'login') ? PASSWORD_REGEX : PASSWORD_STRENGHT_REGEX), BAD_PASSWORD_ERROR)} + id="auth_password" + type="password" + label="Password" + required + /> + {type === 'register' && ( + <> + validateOnChange(e, new RegExp(`^(${passwordRef.current.value})$`), CONFIRM_PASSWORD_ERROR)} + id="auth_confirmPassword" + type="password" + label="Confirm password" + required + /> + validateOnChange(e, EMAIL_REGEX, BAD_EMAIL_ERROR)} + id="auth_email" + type="email" + label="Email" + required + /> + + )} +
+ Error + +
+
+
+ +
+ + {`${(type === 'login' ? 'Don\'t have' : 'Already have')} an account?`} + + { type === 'login' ? ' Register' : ' Login' } + + +
+
+ + ); +} + +Auth.propTypes = { + type: PropTypes.string.isRequired, +}; + +function StaticWrapper({ children }) { + return ( +
+
+
+
+ Cinny logo +
+ Cinny + Yet another matrix client. +
+
+ { children } +
+
+
+ ); +} + +StaticWrapper.propTypes = { + children: PropTypes.node.isRequired, +}; + +function LoadingScreen({ message }) { + return ( + + +
+ {message} +
+
+ ); +} +LoadingScreen.propTypes = { + message: PropTypes.string.isRequired, +}; + +function Recaptcha({ message, sitekey, onChange }) { + return ( + +
+ {message} +
+ +
+ ); +} +Recaptcha.propTypes = { + message: PropTypes.string.isRequired, + sitekey: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, +}; + +function Terms({ url, onSubmit }) { + return ( + +
onSubmit(undefined, true)}> +
+ Agree with terms +
+ In order to complete registration, you need to agree with terms and conditions. +
+ + + {'I accept '} + Terms and Conditions + +
+ +
+ + + ); +} +Terms.propTypes = { + url: PropTypes.string.isRequired, + onSubmit: PropTypes.func.isRequired, +}; + +function ProcessWrapper({ children }) { + return ( +
+ {children} +
+ ); +} +ProcessWrapper.propTypes = { + children: PropTypes.node.isRequired, +}; + +export default Auth; diff --git a/src/app/templates/auth/Auth.scss b/src/app/templates/auth/Auth.scss new file mode 100644 index 0000000..875801d --- /dev/null +++ b/src/app/templates/auth/Auth.scss @@ -0,0 +1,157 @@ +.auth__wrapper { + min-height: 100vh; + padding: var(--sp-loose); + background-color: var(--bg-surface-low); + + background-image: url("https://images.unsplash.com/photo-1562619371-b67725b6fde2?ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&ixlib=rb-1.2.1&auto=format&fit=crop&w=1950&q=80"); + background-size: cover; + background-repeat: no-repeat; + background-position: center; + + .auth-card { + width: 462px; + min-height: 644px; + background-color: var(--bg-surface-low); + border-radius: var(--bo-radius); + box-shadow: var(--bs-popup); + overflow: hidden; + display: flex; + flex-flow: row nowrap; + + &__interactive{ + flex: 1; + min-width: 0; + } + + &__interactive { + padding: calc(var(--sp-normal) + var(--sp-extra-loose)); + padding-bottom: var(--sp-extra-loose); + background-color: var(--bg-surface); + } + + } +} + +.app-ident { + margin-bottom: var(--sp-extra-loose); + + &__logo { + width: 60px; + height: 60px; + } + &__text { + margin-left: calc(var(--sp-loose) + var(--sp-ultra-tight)); + + .text-s1 { + margin-top: var(--sp-tight); + color: var(--tc-surface-normal); + } + + [dir=rtl] & { + margin-left: 0; + margin-right: calc(var(--sp-loose) + var(--sp-ultra-tight)); + } + } +} + +.auth-form { + + & > .text { + margin-bottom: var(--sp-loose); + margin-top: var(--sp-loose); + } + & > .input-container { + margin-top: var(--sp-tight); + } + + .submit-btn__wrapper { + margin-top: var(--sp-extra-loose); + margin-bottom: var(--sp-loose); + align-items: flex-start; + + & > .error-message { + display: none; + flex: 1; + color: var(--tc-danger-normal); + margin-right: var(--sp-normal); + word-break: break; + + [dir=rtl] & { + margin: { + right: 0; + left: var(--sp-normal); + } + } + } + } + + &__wrapper { + height: 100%; + } +} + +.username__wrapper { + display: flex; + align-items: flex-end; + + & > :first-child { + flex: 1; + + .input { + border-radius: var(--bo-radius) 0 0 var(--bo-radius); + + [dir=rtl] & { + border-radius: 0 var(--bo-radius) var(--bo-radius) 0; + } + } + } + & > :last-child { + width: 110px; + + .input { + border-left-width: 0; + background-color: var(--bg-surface); + border-radius: 0 var(--bo-radius) var(--bo-radius) 0; + + [dir=rtl] & { + border-left-width: 1px; + border-right-width: 0; + border-radius: var(--bo-radius) 0 0 var(--bo-radius); + } + } + } +} + +@media (max-width: 462px) { + .auth__wrapper { + padding: 0; + background-image: none; + background-color: var(--bg-surface); + + .auth-card { + border-radius: 0; + box-shadow: none; + + &__interactive { + padding: var(--sp-extra-loose); + } + } + } +} + +.process-wrapper { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + min-height: 100%; + width: 100%; + background-color: var(--bg-surface-low); + opacity: .96; + + position: fixed; + top: 0; + left: 0; + z-index: 999; +} \ No newline at end of file diff --git a/src/app/templates/client/Client.jsx b/src/app/templates/client/Client.jsx new file mode 100644 index 0000000..9c84031 --- /dev/null +++ b/src/app/templates/client/Client.jsx @@ -0,0 +1,47 @@ +import React, { useState, useEffect } from 'react'; +import './Client.scss'; + +import Text from '../../atoms/text/Text'; +import Spinner from '../../atoms/spinner/Spinner'; +import Navigation from '../../organisms/navigation/Navigation'; +import Channel from '../../organisms/channel/Channel'; +import Windows from '../../organisms/pw/Windows'; + +import initMatrix from '../../../client/initMatrix'; + +function Client() { + const [isLoading, changeLoading] = useState(true); + + useEffect(() => { + initMatrix.once('init_loading_finished', () => { + changeLoading(false); + }); + initMatrix.init(); + }, []); + + if (isLoading) { + return ( +
+ + Heating up + +
+ Cinny +
+
+ ); + } + return ( +
+
+ +
+
+ +
+ +
+ ); +} + +export default Client; diff --git a/src/app/templates/client/Client.scss b/src/app/templates/client/Client.scss new file mode 100644 index 0000000..f1d901e --- /dev/null +++ b/src/app/templates/client/Client.scss @@ -0,0 +1,34 @@ +.client-container { + display: flex; + height: 100%; +} + +.navigation__wrapper { + width: var(--navigation-width); +} +.channel__wrapper { + flex: 1; + min-width: 0; + background-color: var(--bg-surface); +} + + +.loading-display { + position: absolute; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} +.loading__message { + margin-top: var(--sp-normal); +} +.loading__appname { + position: absolute; + bottom: var(--sp-normal); +} \ No newline at end of file diff --git a/src/client/action/auth.js b/src/client/action/auth.js new file mode 100644 index 0000000..170fb4b --- /dev/null +++ b/src/client/action/auth.js @@ -0,0 +1,145 @@ +import * as sdk from 'matrix-js-sdk'; +import cons from '../state/cons'; +import { getBaseUrl } from '../../util/matrixUtil'; + +async function login(username, homeserver, password) { + const baseUrl = await getBaseUrl(homeserver); + + if (typeof baseUrl === 'undefined') throw new Error('Homeserver not found'); + + const client = sdk.createClient({ baseUrl }); + + const response = await client.login('m.login.password', { + user: `@${username}:${homeserver}`, + password, + initial_device_display_name: cons.DEVICE_DISPLAY_NAME, + }); + + localStorage.setItem(cons.secretKey.ACCESS_TOKEN, response.access_token); + localStorage.setItem(cons.secretKey.DEVICE_ID, response.device_id); + localStorage.setItem(cons.secretKey.USER_ID, response.user_id); + localStorage.setItem(cons.secretKey.BASE_URL, response.well_known['m.homeserver'].base_url); +} + +async function getAdditionalInfo(baseUrl, content) { + try { + const res = await fetch(`${baseUrl}/_matrix/client/r0/register`, { + method: 'POST', + body: JSON.stringify(content), + headers: { + 'Content-Type': 'application/json; charset=utf-8', + }, + credentials: 'same-origin', + }); + const data = await res.json(); + return data; + } catch (e) { + throw new Error(e); + } +} +async function verifyEmail(baseUrl, content) { + try { + const res = await fetch(`${baseUrl}/_matrix/client/r0/register/email/requestToken `, { + method: 'POST', + body: JSON.stringify(content), + headers: { + 'Content-Type': 'application/json; charset=utf-8', + }, + credentials: 'same-origin', + }); + const data = await res.json(); + return data; + } catch (e) { + throw new Error(e); + } +} + +let session = null; +let clientSecret = null; +let sid = null; +async function register(username, homeserver, password, email, recaptchaValue, terms, verified) { + const baseUrl = await getBaseUrl(homeserver); + + if (typeof baseUrl === 'undefined') throw new Error('Homeserver not found'); + + const client = sdk.createClient({ baseUrl }); + + const isAvailable = await client.isUsernameAvailable(username); + if (!isAvailable) throw new Error('Username not available'); + + if (typeof recaptchaValue === 'string') { + await getAdditionalInfo(baseUrl, { + auth: { + type: 'm.login.recaptcha', + session, + response: recaptchaValue, + }, + }); + } else if (terms === true) { + await getAdditionalInfo(baseUrl, { + auth: { + type: 'm.login.terms', + session, + }, + }); + } else if (verified !== true) { + session = null; + clientSecret = client.generateClientSecret(); + console.log(clientSecret); + const verifyData = await verifyEmail(baseUrl, { + email, + client_secret: clientSecret, + send_attempt: 1, + }); + if (typeof verifyData.error === 'string') { + throw new Error(verifyData.error); + } + sid = verifyData.sid; + } + + const additionalInfo = await getAdditionalInfo(baseUrl, { + auth: { session: (session !== null) ? session : undefined }, + }); + session = additionalInfo.session; + if (typeof additionalInfo.completed === 'undefined' || additionalInfo.completed.length === 0) { + return ({ + type: 'recaptcha', + public_key: additionalInfo.params['m.login.recaptcha'].public_key, + }); + } + if (additionalInfo.completed.find((process) => process === 'm.login.recaptcha') === 'm.login.recaptcha' + && !additionalInfo.completed.find((process) => process === 'm.login.terms')) { + return ({ + type: 'terms', + en: additionalInfo.params['m.login.terms'].policies.privacy_policy.en, + }); + } + if (verified || additionalInfo.completed.find((process) => process === 'm.login.terms') === 'm.login.terms') { + const tpc = { + client_secret: clientSecret, + sid, + }; + const verifyData = await getAdditionalInfo(baseUrl, { + auth: { + session, + type: 'm.login.email.identity', + threepidCreds: tpc, + threepid_creds: tpc, + }, + username, + password, + }); + if (verifyData.errcode === 'M_UNAUTHORIZED') { + return { type: 'email' }; + } + + localStorage.setItem(cons.secretKey.ACCESS_TOKEN, verifyData.access_token); + localStorage.setItem(cons.secretKey.DEVICE_ID, verifyData.device_id); + localStorage.setItem(cons.secretKey.USER_ID, verifyData.user_id); + localStorage.setItem(cons.secretKey.BASE_URL, baseUrl); + return { type: 'done' }; + } + return {}; +} + +export { login, register }; diff --git a/src/client/action/logout.js b/src/client/action/logout.js new file mode 100644 index 0000000..f938699 --- /dev/null +++ b/src/client/action/logout.js @@ -0,0 +1,12 @@ +import initMatrix from '../initMatrix'; + +function logout() { + const mx = initMatrix.matrixClient; + mx.logout().then(() => { + mx.clearStores(); + window.localStorage.clear(); + window.location.reload(); + }); +} + +export default logout; diff --git a/src/client/action/navigation.js b/src/client/action/navigation.js new file mode 100644 index 0000000..1910c9a --- /dev/null +++ b/src/client/action/navigation.js @@ -0,0 +1,64 @@ +import appDispatcher from '../dispatcher'; +import cons from '../state/cons'; + +function handleTabChange(tabId) { + appDispatcher.dispatch({ + type: cons.actions.navigation.CHANGE_TAB, + tabId, + }); +} + +function selectRoom(roomId) { + appDispatcher.dispatch({ + type: cons.actions.navigation.SELECT_ROOM, + roomId, + }); +} + +function togglePeopleDrawer() { + appDispatcher.dispatch({ + type: cons.actions.navigation.TOGGLE_PEOPLE_DRAWER, + }); +} + +function openInviteList() { + appDispatcher.dispatch({ + type: cons.actions.navigation.OPEN_INVITE_LIST, + }); +} + +function openPublicChannels() { + appDispatcher.dispatch({ + type: cons.actions.navigation.OPEN_PUBLIC_CHANNELS, + }); +} + +function openCreateChannel() { + appDispatcher.dispatch({ + type: cons.actions.navigation.OPEN_CREATE_CHANNEL, + }); +} + +function openInviteUser(roomId) { + appDispatcher.dispatch({ + type: cons.actions.navigation.OPEN_INVITE_USER, + roomId, + }); +} + +function openSettings() { + appDispatcher.dispatch({ + type: cons.actions.navigation.OPEN_SETTINGS, + }); +} + +export { + handleTabChange, + selectRoom, + togglePeopleDrawer, + openInviteList, + openPublicChannels, + openCreateChannel, + openInviteUser, + openSettings, +}; diff --git a/src/client/action/room.js b/src/client/action/room.js new file mode 100644 index 0000000..f6a9bab --- /dev/null +++ b/src/client/action/room.js @@ -0,0 +1,189 @@ +import initMatrix from '../initMatrix'; +import appDispatcher from '../dispatcher'; +import cons from '../state/cons'; + +/** + * https://github.com/matrix-org/matrix-react-sdk/blob/1e6c6e9d800890c732d60429449bc280de01a647/src/Rooms.js#L73 + * @param {string} roomId Id of room to add + * @param {string} userId User id to which dm + * @returns {Promise} A promise + */ +function addRoomToMDirect(roomId, userId) { + const mx = initMatrix.matrixClient; + const mDirectsEvent = mx.getAccountData('m.direct'); + let userIdToRoomIds = {}; + + if (typeof mDirectsEvent !== 'undefined') userIdToRoomIds = mDirectsEvent.getContent(); + + // remove it from the lists of any others users + // (it can only be a DM room for one person) + Object.keys(userIdToRoomIds).forEach((thisUserId) => { + const roomIds = userIdToRoomIds[thisUserId]; + + if (thisUserId !== userId) { + const indexOfRoomId = roomIds.indexOf(roomId); + if (indexOfRoomId > -1) { + roomIds.splice(indexOfRoomId, 1); + } + } + }); + + // now add it, if it's not already there + if (userId) { + const roomIds = userIdToRoomIds[userId] || []; + if (roomIds.indexOf(roomId) === -1) { + roomIds.push(roomId); + } + userIdToRoomIds[userId] = roomIds; + } + + return mx.setAccountData('m.direct', userIdToRoomIds); +} + +/** + * Given a room, estimate which of its members is likely to + * be the target if the room were a DM room and return that user. + * https://github.com/matrix-org/matrix-react-sdk/blob/1e6c6e9d800890c732d60429449bc280de01a647/src/Rooms.js#L117 + * + * @param {Object} room Target room + * @param {string} myUserId User ID of the current user + * @returns {string} User ID of the user that the room is probably a DM with + */ +function guessDMRoomTargetId(room, myUserId) { + let oldestMemberTs; + let oldestMember; + + // Pick the joined user who's been here longest (and isn't us), + room.getJoinedMembers().forEach((member) => { + if (member.userId === myUserId) return; + + if (typeof oldestMemberTs === 'undefined' || (member.events.member && member.events.member.getTs() < oldestMemberTs)) { + oldestMember = member; + oldestMemberTs = member.events.member.getTs(); + } + }); + if (oldestMember) return oldestMember.userId; + + // if there are no joined members other than us, use the oldest member + room.currentState.getMembers().forEach((member) => { + if (member.userId === myUserId) return; + + if (typeof oldestMemberTs === 'undefined' || (member.events.member && member.events.member.getTs() < oldestMemberTs)) { + oldestMember = member; + oldestMemberTs = member.events.member.getTs(); + } + }); + + if (typeof oldestMember === 'undefined') return myUserId; + return oldestMember.userId; +} + +/** + * + * @param {string} roomId + * @param {boolean} isDM + */ +function join(roomId, isDM) { + const mx = initMatrix.matrixClient; + mx.joinRoom(roomId) + .then(async () => { + if (isDM) { + const targetUserId = guessDMRoomTargetId(mx.getRoom(roomId), mx.getUserId()); + await addRoomToMDirect(roomId, targetUserId); + } + appDispatcher.dispatch({ + type: cons.actions.room.JOIN, + roomId, + isDM, + }); + }).catch(); +} + +/** + * + * @param {string} roomId + * @param {boolean} isDM + */ +function leave(roomId, isDM) { + const mx = initMatrix.matrixClient; + mx.leave(roomId) + .then(() => { + appDispatcher.dispatch({ + type: cons.actions.room.LEAVE, + roomId, + isDM, + }); + }).catch(); +} + +/** + * Create a room. + * @param {Object} opts + * @param {string} [opts.name] + * @param {string} [opts.topic] + * @param {boolean} [opts.isPublic=false] Sets room visibility to public + * @param {string} [opts.roomAlias] Sets the room address + * @param {boolean} [opts.isEncrypted=false] Makes room encrypted + * @param {boolean} [opts.isDirect=false] Makes room as direct message + * @param {string[]} [opts.invite=[]] An array of userId's to invite + */ +async function create(opts) { + const mx = initMatrix.matrixClient; + const options = { + name: opts.name, + topic: opts.topic, + visibility: opts.isPublic === true ? 'public' : 'private', + room_alias_name: opts.roomAlias, + is_direct: opts.isDirect === true, + invite: opts.invite || [], + initial_state: [], + }; + + if (opts.isPublic !== true && opts.isEncrypted === true) { + options.initial_state.push({ + type: 'm.room.encryption', + state_key: '', + content: { + algorithm: 'm.megolm.v1.aes-sha2', + }, + }); + } + + try { + const result = await mx.createRoom(options); + if (opts.isDirect === true && typeof opts.invite[0] !== 'undefined') { + await addRoomToMDirect(result.room_id, opts.invite[0]); + } + appDispatcher.dispatch({ + type: cons.actions.room.CREATE, + roomId: result.room_id, + isDM: opts.isDirect === true, + }); + return result; + } catch (e) { + const errcodes = ['M_UNKNOWN', 'M_BAD_JSON', 'M_ROOM_IN_USE', 'M_INVALID_ROOM_STATE', 'M_UNSUPPORTED_ROOM_VERSION']; + if (errcodes.find((errcode) => errcode === e.errcode)) { + appDispatcher.dispatch({ + type: cons.actions.room.error.CREATE, + error: e, + }); + throw new Error(e); + } + throw new Error('Something went wrong!'); + } +} + +async function invite(roomId, userId) { + const mx = initMatrix.matrixClient; + + try { + const result = await mx.invite(roomId, userId); + return result; + } catch (e) { + throw new Error(e); + } +} + +export { + join, leave, create, invite, +}; diff --git a/src/client/dispatcher.js b/src/client/dispatcher.js new file mode 100644 index 0000000..12a4872 --- /dev/null +++ b/src/client/dispatcher.js @@ -0,0 +1,4 @@ +import { Dispatcher } from 'flux'; + +const appDispatcher = new Dispatcher(); +export default appDispatcher; diff --git a/src/client/initMatrix.js b/src/client/initMatrix.js new file mode 100644 index 0000000..b409328 --- /dev/null +++ b/src/client/initMatrix.js @@ -0,0 +1,89 @@ +import EventEmitter from 'events'; +import * as sdk from 'matrix-js-sdk'; + +import { secret } from './state/auth'; +import RoomList from './state/RoomList'; +import RoomsInput from './state/RoomsInput'; + +global.Olm = require('olm'); + +class InitMatrix extends EventEmitter { + async init() { + await this.startClient(); + this.setupSync(); + this.listenEvents(); + } + + async startClient() { + const indexedDBStore = new sdk.IndexedDBStore({ + indexedDB: global.indexedDB, + localStorage: global.localStorage, + dbName: 'web-sync-store', + }); + await indexedDBStore.startup(); + + this.matrixClient = sdk.createClient({ + baseUrl: secret.baseUrl, + accessToken: secret.accessToken, + userId: secret.userId, + store: indexedDBStore, + sessionStore: new sdk.WebStorageSessionStore(global.localStorage), + cryptoStore: new sdk.IndexedDBCryptoStore(global.indexedDB, 'crypto-store'), + deviceId: secret.deviceId, + }); + + await this.matrixClient.initCrypto(); + + await this.matrixClient.startClient({ + lazyLoadMembers: true, + }); + this.matrixClient.setGlobalErrorOnUnknownDevices(false); + } + + setupSync() { + const sync = { + NULL: () => { + console.log('NULL state'); + }, + SYNCING: () => { + console.log('SYNCING state'); + }, + PREPARED: (prevState) => { + console.log('PREPARED state'); + console.log('previous state: ', prevState); + // TODO: remove global.initMatrix at end + global.initMatrix = this; + if (prevState === null) { + this.roomList = new RoomList(this.matrixClient); + this.roomsInput = new RoomsInput(this.matrixClient); + this.emit('init_loading_finished'); + } + }, + RECONNECTING: () => { + console.log('RECONNECTING state'); + }, + CATCHUP: () => { + console.log('CATCHUP state'); + }, + ERROR: () => { + console.log('ERROR state'); + }, + STOPPED: () => { + console.log('STOPPED state'); + }, + }; + this.matrixClient.on('sync', (state, prevState) => sync[state](prevState)); + } + + listenEvents() { + this.matrixClient.on('Session.logged_out', () => { + this.matrixClient.clearStores(); + window.localStorage.clear(); + window.location.reload(); + }); + } +} + +const initMatrix = new InitMatrix(); + +export default initMatrix; diff --git a/src/client/state/RoomList.js b/src/client/state/RoomList.js new file mode 100644 index 0000000..c9f3ca5 --- /dev/null +++ b/src/client/state/RoomList.js @@ -0,0 +1,288 @@ +import EventEmitter from 'events'; +import appDispatcher from '../dispatcher'; +import cons from './cons'; + +class RoomList extends EventEmitter { + constructor(matrixClient) { + super(); + this.matrixClient = matrixClient; + this.mDirects = this.getMDirects(); + + this.inviteDirects = new Set(); + this.inviteSpaces = new Set(); + this.inviteRooms = new Set(); + + this.directs = new Set(); + this.spaces = new Set(); + this.rooms = new Set(); + + this.processingRooms = new Map(); + + this._populateRooms(); + this._listenEvents(); + + appDispatcher.register(this.roomActions.bind(this)); + } + + roomActions(action) { + const addRoom = (roomId, isDM) => { + const myRoom = this.matrixClient.getRoom(roomId); + if (myRoom === null) return false; + + if (isDM) this.directs.add(roomId); + else if (myRoom.isSpaceRoom()) this.spaces.add(roomId); + else this.rooms.add(roomId); + return true; + }; + const actions = { + [cons.actions.room.JOIN]: () => { + if (addRoom(action.roomId, action.isDM)) { + setTimeout(() => { + this.emit(cons.events.roomList.ROOM_JOINED, action.roomId); + this.emit(cons.events.roomList.ROOMLIST_UPDATED); + }, 100); + } else { + this.processingRooms.set(action.roomId, { + roomId: action.roomId, + isDM: action.isDM, + task: 'JOIN', + }); + } + }, + [cons.actions.room.CREATE]: () => { + if (addRoom(action.roomId, action.isDM)) { + setTimeout(() => { + this.emit(cons.events.roomList.ROOM_CREATED, action.roomId); + this.emit(cons.events.roomList.ROOM_JOINED, action.roomId); + this.emit(cons.events.roomList.ROOMLIST_UPDATED); + }, 100); + } else { + this.processingRooms.set(action.roomId, { + roomId: action.roomId, + isDM: action.isDM, + task: 'CREATE', + }); + } + }, + }; + actions[action.type]?.(); + } + + getMDirects() { + const mDirectsId = new Set(); + const mDirect = this.matrixClient + .getAccountData('m.direct') + ?.getContent(); + + if (typeof mDirect === 'undefined') return mDirectsId; + + Object.keys(mDirect).forEach((direct) => { + mDirect[direct].forEach((directId) => mDirectsId.add(directId)); + }); + + return mDirectsId; + } + + _populateRooms() { + this.directs.clear(); + this.spaces.clear(); + this.rooms.clear(); + this.inviteDirects.clear(); + this.inviteSpaces.clear(); + this.inviteRooms.clear(); + this.matrixClient.getRooms().forEach((room) => { + const { roomId } = room; + const tombstone = room.currentState.events.get('m.room.tombstone'); + if (typeof tombstone !== 'undefined') { + const repRoomId = tombstone.get('').getContent().replacement_room; + const repRoomMembership = this.matrixClient.getRoom(repRoomId)?.getMyMembership(); + if (repRoomMembership === 'join') return; + } + + if (room.getMyMembership() === 'invite') { + if (this._isDMInvite(room)) this.inviteDirects.add(roomId); + else if (room.isSpaceRoom()) this.inviteSpaces.add(roomId); + else this.inviteRooms.add(roomId); + return; + } + + if (room.getMyMembership() !== 'join') return; + + if (this.mDirects.has(roomId)) this.directs.add(roomId); + else if (room.isSpaceRoom()) this.spaces.add(roomId); + else this.rooms.add(roomId); + }); + } + + _isDMInvite(room) { + const me = room.getMember(this.matrixClient.getUserId()); + const myEventContent = me.events.member.getContent(); + return myEventContent.membership === 'invite' && myEventContent.is_direct; + } + + _listenEvents() { + // Update roomList when m.direct changes + this.matrixClient.on('accountData', (event) => { + if (event.getType() !== 'm.direct') return; + + const latestMDirects = this.getMDirects(); + + latestMDirects.forEach((directId) => { + const myRoom = this.matrixClient.getRoom(directId); + if (this.mDirects.has(directId)) return; + + // Update mDirects + this.mDirects.add(directId); + + if (myRoom === null) return; + + if (this._isDMInvite(myRoom)) return; + + if (myRoom.getMyMembership === 'join' && !this.directs.has(directId)) { + this.directs.add(directId); + } + + // Newly added room. + // at this time my membership can be invite | join + if (myRoom.getMyMembership() === 'join' && this.rooms.has(directId)) { + // found a DM which accidentally gets added to this.rooms + this.rooms.delete(directId); + this.emit(cons.events.roomList.ROOMLIST_UPDATED); + } + }); + }); + + this.matrixClient.on('Room.name', () => { + this.emit(cons.events.roomList.ROOMLIST_UPDATED); + }); + this.matrixClient.on('Room.receipt', (event) => { + if (event.getType() === 'm.receipt') { + const evContent = event.getContent(); + const userId = Object.keys(evContent[Object.keys(evContent)[0]]['m.read'])[0]; + if (userId !== this.matrixClient.getUserId()) return; + this.emit(cons.events.roomList.ROOMLIST_UPDATED); + } + }); + + this.matrixClient.on('RoomState.events', (event) => { + if (event.getType() !== 'm.room.join_rules') return; + + this.emit(cons.events.roomList.ROOMLIST_UPDATED); + }); + + this.matrixClient.on('Room.myMembership', (room, membership, prevMembership) => { + // room => prevMembership = null | invite | join | leave | kick | ban | unban + // room => membership = invite | join | leave | kick | ban | unban + const { roomId } = room; + + if (membership === 'unban') return; + + // When user_reject/sender_undo room invite + if (prevMembership === 'invite') { + if (this.inviteDirects.has(roomId)) this.inviteDirects.delete(roomId); + else if (this.inviteSpaces.has(roomId)) this.inviteSpaces.delete(roomId); + else this.inviteRooms.delete(roomId); + + this.emit(cons.events.roomList.INVITELIST_UPDATED, roomId); + } + + // When user get invited + if (membership === 'invite') { + if (this._isDMInvite(room)) this.inviteDirects.add(roomId); + else if (room.isSpaceRoom()) this.inviteSpaces.add(roomId); + else this.inviteRooms.add(roomId); + + this.emit(cons.events.roomList.INVITELIST_UPDATED, roomId); + return; + } + + // When user join room (first time) or start DM. + if ((prevMembership === null || prevMembership === 'invite') && membership === 'join') { + // when user create room/DM OR accept room/dm invite from this client. + // we will update this.rooms/this.directs with user action + if (this.directs.has(roomId) || this.spaces.has(roomId) || this.rooms.has(roomId)) return; + + if (this.processingRooms.has(roomId)) { + const procRoomInfo = this.processingRooms.get(roomId); + + if (procRoomInfo.isDM) this.directs.add(roomId); + else if (room.isSpaceRoom()) this.spaces.add(roomId); + else this.rooms.add(roomId); + + if (procRoomInfo.task === 'CREATE') this.emit(cons.events.roomList.ROOM_CREATED, roomId); + this.emit(cons.events.roomList.ROOM_JOINED, roomId); + this.emit(cons.events.roomList.ROOMLIST_UPDATED); + + this.processingRooms.delete(roomId); + return; + } + if (room.isSpaceRoom()) { + this.spaces.add(roomId); + + this.emit(cons.events.roomList.ROOM_JOINED, roomId); + this.emit(cons.events.roomList.ROOMLIST_UPDATED); + return; + } + + // below code intented to work when user create room/DM + // OR accept room/dm invite from other client. + // and we have to update our client. (it's ok to have 10sec delay) + + // create a buffer of 10sec and HOPE client.accoundData get updated + // then accoundData event listener will update this.mDirects. + // and we will be able to know if it's a DM. + // ---------- + // less likely situation: + // if we don't get accountData with 10sec then: + // we will temporary add it to this.rooms. + // and in future when accountData get updated + // accountData listener will automatically goona REMOVE it from this.rooms + // and will ADD it to this.directs + // and emit the cons.events.roomList.ROOMLIST_UPDATED to update the UI. + + setTimeout(() => { + if (this.directs.has(roomId) || this.spaces.has(roomId) || this.rooms.has(roomId)) return; + if (this.mDirects.has(roomId)) this.directs.add(roomId); + else this.rooms.add(roomId); + + this.emit(cons.events.roomList.ROOM_JOINED, roomId); + this.emit(cons.events.roomList.ROOMLIST_UPDATED); + }, 10000); + return; + } + + // when room is a DM add/remove it from DM's and return. + if (this.directs.has(roomId)) { + if (membership === 'leave' || membership === 'kick' || membership === 'ban') { + this.directs.delete(roomId); + this.emit(cons.events.roomList.ROOM_LEAVED, roomId); + } + } + if (this.mDirects.has(roomId)) { + if (membership === 'join') { + this.directs.add(roomId); + this.emit(cons.events.roomList.ROOM_JOINED, roomId); + } + this.emit(cons.events.roomList.ROOMLIST_UPDATED); + return; + } + // when room is not a DM add/remove it from rooms. + if (membership === 'leave' || membership === 'kick' || membership === 'ban') { + if (room.isSpaceRoom()) this.spaces.delete(roomId); + else this.rooms.delete(roomId); + this.emit(cons.events.roomList.ROOM_LEAVED, roomId); + } + if (membership === 'join') { + if (room.isSpaceRoom()) this.spaces.add(roomId); + else this.rooms.add(roomId); + this.emit(cons.events.roomList.ROOM_JOINED, roomId); + } + this.emit(cons.events.roomList.ROOMLIST_UPDATED); + }); + + this.matrixClient.on('Room.timeline', () => { + this.emit(cons.events.roomList.ROOMLIST_UPDATED); + }); + } +} +export default RoomList; diff --git a/src/client/state/RoomTimeline.js b/src/client/state/RoomTimeline.js new file mode 100644 index 0000000..edb19c4 --- /dev/null +++ b/src/client/state/RoomTimeline.js @@ -0,0 +1,161 @@ +import EventEmitter from 'events'; +import initMatrix from '../initMatrix'; +import cons from './cons'; + +class RoomTimeline extends EventEmitter { + constructor(roomId) { + super(); + this.matrixClient = initMatrix.matrixClient; + this.roomId = roomId; + this.room = this.matrixClient.getRoom(roomId); + this.timeline = this.room.timeline; + this.editedTimeline = this.getEditedTimeline(); + this.reactionTimeline = this.getReactionTimeline(); + this.isOngoingPagination = false; + this.ongoingDecryptionCount = 0; + this.typingMembers = new Set(); + + this._listenRoomTimeline = (event, room) => { + if (room.roomId !== this.roomId) return; + + if (event.isEncrypted()) { + this.ongoingDecryptionCount += 1; + return; + } + + this.timeline = this.room.timeline; + if (this.isEdited(event)) { + this.addToMap(this.editedTimeline, event); + } + if (this.isReaction(event)) { + this.addToMap(this.reactionTimeline, event); + } + + if (this.ongoingDecryptionCount !== 0) return; + this.emit(cons.events.roomTimeline.EVENT); + }; + + this._listenDecryptEvent = (event) => { + if (event.getRoomId() !== this.roomId) return; + + if (this.ongoingDecryptionCount > 0) this.ongoingDecryptionCount -= 1; + this.timeline = this.room.timeline; + + if (this.ongoingDecryptionCount !== 0) return; + this.emit(cons.events.roomTimeline.EVENT); + }; + + this._listenTypingEvent = (event, member) => { + if (member.roomId !== this.roomId) return; + + const isTyping = member.typing; + if (isTyping) this.typingMembers.add(member.userId); + else this.typingMembers.delete(member.userId); + this.emit(cons.events.roomTimeline.TYPING_MEMBERS_UPDATED, new Set([...this.typingMembers])); + }; + this._listenReciptEvent = (event, room) => { + if (room.roomId !== this.roomId) return; + const receiptContent = event.getContent(); + if (this.timeline.length === 0) return; + const tmlLastEvent = this.timeline[this.timeline.length - 1]; + const lastEventId = tmlLastEvent.getId(); + const lastEventRecipt = receiptContent[lastEventId]; + if (typeof lastEventRecipt === 'undefined') return; + if (lastEventRecipt['m.read']) { + this.emit(cons.events.roomTimeline.READ_RECEIPT); + } + }; + + this.matrixClient.on('Room.timeline', this._listenRoomTimeline); + this.matrixClient.on('Event.decrypted', this._listenDecryptEvent); + this.matrixClient.on('RoomMember.typing', this._listenTypingEvent); + this.matrixClient.on('Room.receipt', this._listenReciptEvent); + + // TODO: remove below line when release + window.selectedRoom = this; + + if (this.isEncryptedRoom()) this.room.decryptAllEvents(); + } + + isEncryptedRoom() { + return this.matrixClient.isRoomEncrypted(this.roomId); + } + + // eslint-disable-next-line class-methods-use-this + isEdited(mEvent) { + return mEvent.getRelation()?.rel_type === 'm.replace'; + } + + // eslint-disable-next-line class-methods-use-this + getRelateToId(mEvent) { + const relation = mEvent.getRelation(); + return relation && relation.event_id; + } + + addToMap(myMap, mEvent) { + const relateToId = this.getRelateToId(mEvent); + if (relateToId === null) return null; + + if (typeof myMap.get(relateToId) === 'undefined') myMap.set(relateToId, []); + myMap.get(relateToId).push(mEvent); + return mEvent; + } + + getEditedTimeline() { + const mReplace = new Map(); + this.timeline.forEach((mEvent) => { + if (this.isEdited(mEvent)) { + this.addToMap(mReplace, mEvent); + } + }); + + return mReplace; + } + + // eslint-disable-next-line class-methods-use-this + isReaction(mEvent) { + return mEvent.getType() === 'm.reaction'; + } + + getReactionTimeline() { + const mReaction = new Map(); + this.timeline.forEach((mEvent) => { + if (this.isReaction(mEvent)) { + this.addToMap(mReaction, mEvent); + } + }); + + return mReaction; + } + + paginateBack() { + if (this.isOngoingPagination) return; + this.isOngoingPagination = true; + + const MSG_LIMIT = 30; + this.matrixClient.scrollback(this.room, MSG_LIMIT).then(async (room) => { + if (room.oldState.paginationToken === null) { + // We have reached start of the timeline + this.isOngoingPagination = false; + if (this.isEncryptedRoom()) await this.room.decryptAllEvents(); + this.emit(cons.events.roomTimeline.PAGINATED, false); + return; + } + this.editedTimeline = this.getEditedTimeline(); + this.reactionTimeline = this.getReactionTimeline(); + + this.isOngoingPagination = false; + if (this.isEncryptedRoom()) await this.room.decryptAllEvents(); + this.emit(cons.events.roomTimeline.PAGINATED, true); + }); + } + + removeInternalListeners() { + this.matrixClient.removeListener('Room.timeline', this._listenRoomTimeline); + this.matrixClient.removeListener('Event.decrypted', this._listenDecryptEvent); + this.matrixClient.removeListener('RoomMember.typing', this._listenTypingEvent); + this.matrixClient.removeListener('Room.receipt', this._listenReciptEvent); + } +} + +export default RoomTimeline; diff --git a/src/client/state/RoomsInput.js b/src/client/state/RoomsInput.js new file mode 100644 index 0000000..eb704a7 --- /dev/null +++ b/src/client/state/RoomsInput.js @@ -0,0 +1,276 @@ +import EventEmitter from 'events'; +import encrypt from 'browser-encrypt-attachment'; +import cons from './cons'; + +function getImageDimension(file) { + return new Promise((resolve) => { + const img = new Image(); + img.onload = async () => { + resolve({ + w: img.width, + h: img.height, + }); + }; + img.src = URL.createObjectURL(file); + }); +} +function loadVideo(videoFile) { + return new Promise((resolve, reject) => { + const video = document.createElement('video'); + video.preload = 'metadata'; + video.playsInline = true; + video.muted = true; + + const reader = new FileReader(); + + reader.onload = (ev) => { + // Wait until we have enough data to thumbnail the first frame. + video.onloadeddata = async () => { + resolve(video); + video.pause(); + }; + video.onerror = (e) => { + reject(e); + }; + + video.src = ev.target.result; + video.load(); + video.play(); + }; + reader.onerror = (e) => { + reject(e); + }; + reader.readAsDataURL(videoFile); + }); +} +function getVideoThumbnail(video, width, height, mimeType) { + return new Promise((resolve) => { + const MAX_WIDTH = 800; + const MAX_HEIGHT = 600; + let targetWidth = width; + let targetHeight = height; + if (targetHeight > MAX_HEIGHT) { + targetWidth = Math.floor(targetWidth * (MAX_HEIGHT / targetHeight)); + targetHeight = MAX_HEIGHT; + } + if (targetWidth > MAX_WIDTH) { + targetHeight = Math.floor(targetHeight * (MAX_WIDTH / targetWidth)); + targetWidth = MAX_WIDTH; + } + + const canvas = document.createElement('canvas'); + canvas.width = targetWidth; + canvas.height = targetHeight; + const context = canvas.getContext('2d'); + context.drawImage(video, 0, 0, targetWidth, targetHeight); + + canvas.toBlob((thumbnail) => { + resolve({ + thumbnail, + info: { + w: targetWidth, + h: targetHeight, + mimetype: thumbnail.type, + size: thumbnail.size, + }, + }); + }, mimeType); + }); +} + +class RoomsInput extends EventEmitter { + constructor(mx) { + super(); + + this.matrixClient = mx; + this.roomIdToInput = new Map(); + } + + cleanEmptyEntry(roomId) { + const input = this.getInput(roomId); + const isEmpty = typeof input.attachment === 'undefined' + && (typeof input.message === 'undefined' || input.message === ''); + if (isEmpty) { + this.roomIdToInput.delete(roomId); + } + } + + getInput(roomId) { + return this.roomIdToInput.get(roomId) || {}; + } + + setMessage(roomId, message) { + const input = this.getInput(roomId); + input.message = message; + this.roomIdToInput.set(roomId, input); + if (message === '') this.cleanEmptyEntry(roomId); + } + + getMessage(roomId) { + const input = this.getInput(roomId); + if (typeof input.message === 'undefined') return ''; + return input.message; + } + + setAttachment(roomId, file) { + const input = this.getInput(roomId); + input.attachment = { + file, + }; + this.roomIdToInput.set(roomId, input); + } + + getAttachment(roomId) { + const input = this.getInput(roomId); + if (typeof input.attachment === 'undefined') return null; + return input.attachment.file; + } + + cancelAttachment(roomId) { + const input = this.getInput(roomId); + if (typeof input.attachment === 'undefined') return; + + const { uploadingPromise } = input.attachment; + + if (uploadingPromise) { + this.matrixClient.cancelUpload(uploadingPromise); + delete input.attachment.uploadingPromise; + } + if (input.message) { + delete input.attachment; + delete input.isSending; + this.roomIdToInput.set(roomId, input); + } else { + this.roomIdToInput.delete(roomId); + } + this.emit(cons.events.roomsInput.ATTACHMENT_CANCELED, roomId); + } + + isSending(roomId) { + return this.roomIdToInput.get(roomId)?.isSending || false; + } + + async sendInput(roomId) { + const input = this.getInput(roomId); + input.isSending = true; + this.roomIdToInput.set(roomId, input); + if (input.attachment) { + await this.sendFile(roomId, input.attachment.file); + } + + if (this.getMessage(roomId).trim() !== '') { + const content = { + body: input.message, + msgtype: 'm.text', + }; + this.matrixClient.sendMessage(roomId, content); + } + + if (this.isSending(roomId)) this.roomIdToInput.delete(roomId); + this.emit(cons.events.roomsInput.MESSAGE_SENT, roomId); + } + + async sendFile(roomId, file) { + const fileType = file.type.slice(0, file.type.indexOf('/')); + const info = { + mimetype: file.type, + size: file.size, + }; + const content = { info }; + let uploadData = null; + + if (fileType === 'image') { + const imgDimension = await getImageDimension(file); + + info.w = imgDimension.w; + info.h = imgDimension.h; + + content.msgtype = 'm.image'; + content.body = file.name || 'Image'; + } else if (fileType === 'video') { + content.msgtype = 'm.video'; + content.body = file.name || 'Video'; + + try { + const video = await loadVideo(file); + info.w = video.videoWidth; + info.h = video.videoHeight; + const thumbnailData = await getVideoThumbnail(video, video.videoWidth, video.videoHeight, 'image/jpeg'); + const thumbnailUploadData = await this.uploadFile(roomId, thumbnailData.thumbnail); + info.thumbnail_info = thumbnailData.info; + if (this.matrixClient.isRoomEncrypted(roomId)) { + info.thumbnail_file = thumbnailUploadData.file; + } else { + info.thumbnail_url = thumbnailUploadData.url; + } + } catch (e) { + this.emit(cons.events.roomsInput.FILE_UPLOAD_CANCELED, roomId); + return; + } + } else if (fileType === 'audio') { + content.msgtype = 'm.audio'; + content.body = file.name || 'Audio'; + } else { + content.msgtype = 'm.file'; + content.body = file.name || 'File'; + } + + try { + uploadData = await this.uploadFile(roomId, file, (data) => { + // data have two properties: data.loaded, data.total + this.emit(cons.events.roomsInput.UPLOAD_PROGRESS_CHANGES, roomId, data); + }); + this.emit(cons.events.roomsInput.FILE_UPLOADED, roomId); + } catch (e) { + this.emit(cons.events.roomsInput.FILE_UPLOAD_CANCELED, roomId); + return; + } + if (this.matrixClient.isRoomEncrypted(roomId)) { + content.file = uploadData.file; + await this.matrixClient.sendMessage(roomId, content); + } else { + content.url = uploadData.url; + await this.matrixClient.sendMessage(roomId, content); + } + } + + async uploadFile(roomId, file, progressHandler) { + const isEncryptedRoom = this.matrixClient.isRoomEncrypted(roomId); + + let encryptInfo = null; + let encryptBlob = null; + + if (isEncryptedRoom) { + const dataBuffer = await file.arrayBuffer(); + if (typeof this.getInput(roomId).attachment === 'undefined') throw new Error('Attachment canceled'); + const encryptedResult = await encrypt.encryptAttachment(dataBuffer); + if (typeof this.getInput(roomId).attachment === 'undefined') throw new Error('Attachment canceled'); + encryptInfo = encryptedResult.info; + encryptBlob = new Blob([encryptedResult.data]); + } + + const uploadingPromise = this.matrixClient.uploadContent(isEncryptedRoom ? encryptBlob : file, { + // don't send filename if room is encrypted. + includeFilename: !isEncryptedRoom, + progressHandler, + }); + + const input = this.getInput(roomId); + input.attachment.uploadingPromise = uploadingPromise; + this.roomIdToInput.set(roomId, input); + + const url = await uploadingPromise; + + delete input.attachment.uploadingPromise; + this.roomIdToInput.set(roomId, input); + + if (isEncryptedRoom) { + encryptInfo.url = url; + if (file.type) encryptInfo.mimetype = file.type; + return { file: encryptInfo }; + } + return { url }; + } +} + +export default RoomsInput; diff --git a/src/client/state/auth.js b/src/client/state/auth.js new file mode 100644 index 0000000..c919f64 --- /dev/null +++ b/src/client/state/auth.js @@ -0,0 +1,19 @@ +import cons from './cons'; + +function getSecret(key) { + return localStorage.getItem(key); +} + +const isAuthanticated = () => getSecret(cons.secretKey.ACCESS_TOKEN) !== null; + +const secret = { + accessToken: getSecret(cons.secretKey.ACCESS_TOKEN), + deviceId: getSecret(cons.secretKey.DEVICE_ID), + userId: getSecret(cons.secretKey.USER_ID), + baseUrl: getSecret(cons.secretKey.BASE_URL), +}; + +export { + isAuthanticated, + secret, +}; diff --git a/src/client/state/cons.js b/src/client/state/cons.js new file mode 100644 index 0000000..9ecd1df --- /dev/null +++ b/src/client/state/cons.js @@ -0,0 +1,65 @@ +const cons = { + secretKey: { + ACCESS_TOKEN: 'cinny_access_token', + DEVICE_ID: 'cinny_device_id', + USER_ID: 'cinny_user_id', + BASE_URL: 'cinny_hs_base_url', + }, + DEVICE_DISPLAY_NAME: 'Cinny Web', + actions: { + navigation: { + CHANGE_TAB: 'CHANGE_TAB', + SELECT_ROOM: 'SELECT_ROOM', + TOGGLE_PEOPLE_DRAWER: 'TOGGLE_PEOPLE_DRAWER', + OPEN_INVITE_LIST: 'OPEN_INVITE_LIST', + OPEN_PUBLIC_CHANNELS: 'OPEN_PUBLIC_CHANNELS', + OPEN_CREATE_CHANNEL: 'OPEN_CREATE_CHANNEL', + OPEN_INVITE_USER: 'OPEN_INVITE_USER', + OPEN_SETTINGS: 'OPEN_SETTINGS', + }, + room: { + JOIN: 'JOIN', + LEAVE: 'LEAVE', + CREATE: 'CREATE', + error: { + CREATE: 'CREATE', + }, + }, + }, + events: { + navigation: { + TAB_CHANGED: 'TAB_CHANGED', + ROOM_SELECTED: 'ROOM_SELECTED', + PEOPLE_DRAWER_TOGGLED: 'PEOPLE_DRAWER_TOGGLED', + INVITE_LIST_OPENED: 'INVITE_LIST_OPENED', + PUBLIC_CHANNELS_OPENED: 'PUBLIC_CHANNELS_OPENED', + CREATE_CHANNEL_OPENED: 'CREATE_CHANNEL_OPENED', + INVITE_USER_OPENED: 'INVITE_USER_OPENED', + SETTINGS_OPENED: 'SETTINGS_OPENED', + }, + roomList: { + ROOMLIST_UPDATED: 'ROOMLIST_UPDATED', + INVITELIST_UPDATED: 'INVITELIST_UPDATED', + ROOM_JOINED: 'ROOM_JOINED', + ROOM_LEAVED: 'ROOM_LEAVED', + ROOM_CREATED: 'ROOM_CREATED', + }, + roomTimeline: { + EVENT: 'EVENT', + PAGINATED: 'PAGINATED', + TYPING_MEMBERS_UPDATED: 'TYPING_MEMBERS_UPDATED', + READ_RECEIPT: 'READ_RECEIPT', + }, + roomsInput: { + MESSAGE_SENT: 'MESSAGE_SENT', + FILE_UPLOADED: 'FILE_UPLOADED', + UPLOAD_PROGRESS_CHANGES: 'UPLOAD_PROGRESS_CHANGES', + FILE_UPLOAD_CANCELED: 'FILE_UPLOAD_CANCELED', + ATTACHMENT_CANCELED: 'ATTACHMENT_CANCELED', + }, + }, +}; + +Object.freeze(cons); + +export default cons; diff --git a/src/client/state/navigation.js b/src/client/state/navigation.js new file mode 100644 index 0000000..e71b7d1 --- /dev/null +++ b/src/client/state/navigation.js @@ -0,0 +1,59 @@ +import EventEmitter from 'events'; +import appDispatcher from '../dispatcher'; +import cons from './cons'; + +class Navigation extends EventEmitter { + constructor() { + super(); + + this.activeTab = 'channels'; + this.selectedRoom = null; + this.isPeopleDrawerVisible = true; + } + + getActiveTab() { + return this.activeTab; + } + + getActiveRoom() { + return this.selectedRoom; + } + + navigate(action) { + const actions = { + [cons.actions.navigation.CHANGE_TAB]: () => { + this.activeTab = action.tabId; + this.emit(cons.events.navigation.TAB_CHANGED, this.activeTab); + }, + [cons.actions.navigation.SELECT_ROOM]: () => { + this.selectedRoom = action.roomId; + this.emit(cons.events.navigation.ROOM_SELECTED, this.selectedRoom); + }, + [cons.actions.navigation.TOGGLE_PEOPLE_DRAWER]: () => { + this.isPeopleDrawerVisible = !this.isPeopleDrawerVisible; + this.emit(cons.events.navigation.PEOPLE_DRAWER_TOGGLED, this.isPeopleDrawerVisible); + }, + [cons.actions.navigation.OPEN_INVITE_LIST]: () => { + this.emit(cons.events.navigation.INVITE_LIST_OPENED); + }, + [cons.actions.navigation.OPEN_PUBLIC_CHANNELS]: () => { + this.emit(cons.events.navigation.PUBLIC_CHANNELS_OPENED); + }, + [cons.actions.navigation.OPEN_CREATE_CHANNEL]: () => { + this.emit(cons.events.navigation.CREATE_CHANNEL_OPENED); + }, + [cons.actions.navigation.OPEN_INVITE_USER]: () => { + this.emit(cons.events.navigation.INVITE_USER_OPENED, action.roomId); + }, + [cons.actions.navigation.OPEN_SETTINGS]: () => { + this.emit(cons.events.navigation.SETTINGS_OPENED); + }, + }; + actions[action.type]?.(); + } +} + +const navigation = new Navigation(); +appDispatcher.register(navigation.navigate.bind(navigation)); + +export default navigation; diff --git a/src/client/state/settings.js b/src/client/state/settings.js new file mode 100644 index 0000000..1b9dfc2 --- /dev/null +++ b/src/client/state/settings.js @@ -0,0 +1,36 @@ +class Settings { + constructor() { + this.themes = ['', 'silver-theme', 'dark-theme', 'butter-theme']; + this.themeIndex = this.getThemeIndex(); + } + + getThemeIndex() { + if (typeof this.themeIndex === 'number') return this.themeIndex; + + let settings = localStorage.getItem('settings'); + if (settings === null) return 0; + settings = JSON.parse(settings); + if (typeof settings.themeIndex === 'undefined') return 0; + // eslint-disable-next-line radix + return parseInt(settings.themeIndex); + } + + getThemeName() { + return this.themes[this.themeIndex]; + } + + setTheme(themeIndex) { + const appBody = document.getElementById('appBody'); + this.themes.forEach((themeName) => { + if (themeName === '') return; + appBody.classList.remove(themeName); + }); + if (this.themes[themeIndex] !== '') appBody.classList.add(this.themes[themeIndex]); + localStorage.setItem('settings', JSON.stringify({ themeIndex })); + this.themeIndex = themeIndex; + } +} + +const settings = new Settings(); + +export default settings; diff --git a/src/index.jsx b/src/index.jsx new file mode 100644 index 0000000..4dd4f8e --- /dev/null +++ b/src/index.jsx @@ -0,0 +1,14 @@ +import React from 'react'; +import ReactDom from 'react-dom'; +import './index.scss'; + +import settings from './client/state/settings'; + +import App from './app/pages/App'; + +settings.setTheme(settings.getThemeIndex()); + +ReactDom.render( + , + document.getElementById('root'), +); diff --git a/src/index.scss b/src/index.scss new file mode 100644 index 0000000..e9835ed --- /dev/null +++ b/src/index.scss @@ -0,0 +1,318 @@ +@import url('https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,300;0,400;0,500;0,700;1,300;1,400;1,500;1,700&display=swap'); + +:root { + + /* background color | --bg-[background type]: value */ + --bg-surface: #FFFFFF; + --bg-surface-low: #F6F6F6; + --bg-surface-hover: rgba(0, 0, 0, 3%); + --bg-surface-active: rgba(0, 0, 0, 5%); + --bg-surface-border: rgba(0, 0, 0, 6%); + + --bg-primary: rgb(83, 110, 234); + --bg-primary-hover: rgba(83, 110, 234, 80%); + --bg-primary-active: rgba(83, 110, 234, 70%); + --bg-primary-border: rgba(83, 110, 234, 38%); + + --bg-positive: #45B83B; + + --bg-caution: rgb(255, 179, 0); + --bg-caution-hover: rgba(255, 179, 0, 8%); + --bg-caution-active: rgba(255, 179, 0, 15%); + --bg-caution-border: rgba(255, 179, 0, 40%); + + --bg-danger: rgb(240, 71, 71); + --bg-danger-hover: rgba(240, 71, 71, 5%); + --bg-danger-active: rgba(240, 71, 71, 10%); + --bg-danger-border: rgba(240, 71, 71, 20%); + + --bg-tooltip: #353535; + + /* text color | --tc-[background type]-[priority]: value */ + --tc-surface-high: #000000; + --tc-surface-normal: rgba(0, 0, 0, 68%); + --tc-surface-low: rgba(0, 0, 0, 38%); + + --tc-primary-high: #ffffff; + --tc-primary-normal: rgba(255, 255, 255, 68%); + --tc-primary-low: rgba(255, 255, 255, 40%); + + --tc-caution-high: var(--bg-caution); + --tc-caution-normal: rgb(255, 179, 0, 80%); + --tc-caution-low: rgb(255, 179, 0, 60%); + + --tc-danger-high: var(--bg-danger); + --tc-danger-normal: rgba(240, 71, 71, 88%); + --tc-danger-low: rgba(240, 71, 71, 60%); + + --tc-code: #e62498; + + --tc-tooltip: white; + + + /* system icons | --ic-[background type]-[priority]: value */ + --ic-surface-normal: #626262; + --ic-primary-normal: #ffffff; + --ic-caution-normal: rgba(255, 179, 0, 80%); + --ic-danger-normal: rgba(240, 71, 71, 0.7); + + + /* system icon size | -ic-[size]: value */ + --ic-large: 38px; + --ic-normal: 24px; + --ic-small: 20px; + --ic-extra-small: 18px; + + /* avatar size */ + --av-large: 80px; + --av-normal: 42px; + --av-small: 36px; + --av-extra-small: 24px; + + + /* shadow and overlay */ + --bg-overlay: rgba(0, 0, 0, 20%); + + --bs-popup: 0 0 16px rgba(0, 0, 0, 10%); + + --bs-surface-border: inset 0 0 0 1px var(--bg-surface-border); + --bs-surface-outline: 0 0 0 2px var(--bg-surface-border); + + --bs-primary-border: inset 0 0 0 1px var(--bg-primary-border); + --bs-primary-outline: 0 0 0 2px var(--bg-primary-border); + + --bs-caution-border: inset 0 0 0 1px var(--bg-caution-border); + --bs-caution-outline: 0 0 0 2px var(--bg-caution-border); + + --bs-danger-border: inset 0 0 0 1px var(--bg-danger-border); + --bs-danger-outline: 0 0 0 2px var(--bg-danger-border); + + + /* border */ + --bo-radius: 8px; + + + /* font syles */ + --fs-h1: 36px; + --ls-h1: -1.5px; + --lh-h1: 38px; + + --fs-h2: 24px; + --ls-h2: -0.5px; + --lh-h2: 30px; + + --fs-s1: 18px; + --ls-s1: -0.2px; + --lh-s1: 24px; + + --fs-b1: 16px; + --ls-b1: 0.1px; + --lh-b1: 24px; + + --fs-b2: 14px; + --ls-b2: 0.2px; + --lh-b2: 20px; + + --fs-b3: 12px; + --ls-b3: 0px; + --lh-b3: 16px; + + + /* spacing | --sp-[space]: value */ + --sp-none: 0px; + --sp-ultra-tight: 4px; + --sp-extra-tight: 8px; + --sp-tight: 12px; + --sp-normal: 16px; + --sp-loose: 20px; + --sp-extra-loose: 32px; + + + /* other */ + --border-width: 1px; + --header-height: 54px; + --navigation-sidebar-width: calc(64px + var(--border-width)); + --navigation-drawer-width: calc(280px + var(--border-width)); + --navigation-width: calc(var(--navigation-sidebar-width) + var(--navigation-drawer-width)); + --people-drawer-width: calc(268px - var(--border-width)); + // large size nav drawer & people drawer width => 326px, 312px + // medium size nav drawer & people drawer width => 280, 268 + + --font-family: 'Roboto', 'Supreme', sans-serif; +} + +.silver-theme { + /* background color | --bg-[background type]: value */ + --bg-surface: hsl(0, 0%, 95%); + --bg-surface-low: hsl(0, 0%, 91%); +} + +.dark-theme, +.butter-theme { + /* background color | --bg-[background type]: value */ + --bg-surface: hsl(208, 8%, 20%); + --bg-surface-low: hsl(208, 8%, 16%); + --bg-surface-hover: rgba(255, 255, 255, 3%); + --bg-surface-active: rgba(255, 255, 255, 5%); + --bg-surface-border: rgba(0, 0, 0, 20%); + + --bg-primary: rgb(59, 119, 191); + --bg-primary-hover: rgba(59, 119, 191, 80%); + --bg-primary-active: rgba(59, 119, 191, 70%); + --bg-primary-border: rgba(59, 119, 191, 38%); + + --bg-tooltip: #000; + + /* text color | --tc-[background type]-[priority]: value */ + --tc-surface-high: rgba(255, 255, 255, 94%); + --tc-surface-normal: rgba(255, 255, 255, 74%); + --tc-surface-low: rgba(255, 255, 255, 38%); + + --tc-primary-high: #ffffff; + --tc-primary-normal: rgba(255, 255, 255, 0.68); + --tc-primary-low: rgba(255, 255, 255, 0.4); + + --tc-code: #e565b1; + + /* system icons | --ic-[background type]-[priority]: value */ + --ic-surface-normal: rgba(255, 255, 255, 68%); + --ic-primary-normal: #ffffff; + + /* shadow and overlay */ + --bg-overlay: rgba(0, 0, 0, 50%); + + --bs-popup: 0 0 16px rgba(0, 0, 0, 25%); + + --bs-surface-border: inset 0 0 0 1px var(--bg-surface-border); + --bs-surface-outline: 0 0 0 2px var(--bg-surface-border); + + --bs-primary-border: inset 0 0 0 1px var(--bg-primary-border); + --bs-primary-outline: 0 0 0 2px var(--bg-primary-border); + + --font-family: 'Supreme', 'Roboto', sans-serif; +} + +.butter-theme { + /* background color | --bg-[background type]: value */ + --bg-surface: hsl(64, 6%, 14%); + --bg-surface-low: hsl(64, 6%, 10%); + + + /* text color | --tc-[background type]-[priority]: value */ + --tc-surface-high: rgb(255, 251, 222, 94%); + --tc-surface-normal: rgba(255, 251, 222, 74%); + --tc-surface-low: rgba(255, 251, 222, 38%); + + + /* system icons | --ic-[background type]-[priority]: value */ + --ic-surface-normal: rgb(255 251 222 / 68%); +} + +html { + height: 100%; +} + +body { + margin: 0; + padding: 0; + height: 100%; + font-family: var(--font-family); + font-size: 16px; + background-color: var(--bg-surface-low); +} +#root { + width: 100%; + height: 100%; +} + +*, *::before, *::after { + box-sizing: border-box; + -webkit-tap-highlight-color: rgba(0,0,0,0); + -webkit-tap-highlight-color: transparent; +} +a { + color: var(--bg-primary); + text-decoration: none; +} +b { + font-weight: 500; +} +label { + margin: 0; + padding: 0; +} +button, +textarea { + margin: 0; + padding: 0; + background-color: transparent; + font-family: inherit; + font-size: inherit; + font-weight: inherit; + line-height: inherit; + letter-spacing: inherit; + border: none; +} +button { + max-width: 100%; + text-transform: none; + text-align: inherit; + overflow: visible; + -webkit-appearance: button; +} +textarea { + color: inherit; + word-spacing: inherit; +} +.noselect { + -webkit-touch-callout: none; /* iOS Safari */ + -webkit-user-select: none; /* Safari */ + -khtml-user-select: none; /* Konqueror HTML */ + -moz-user-select: none; /* Old versions of Firefox */ + -ms-user-select: none; /* Internet Explorer/Edge */ + user-select: none; /* Non-prefixed version, currently + supported by Chrome, Edge, Opera and Firefox */ +} + +.flex { + display: flex; +} +.flex-v { + display: flex; + flex-direction: column; +} + +.flex--center, +.flex--spaceBetween-center, +.flex--end-center { + @extend .flex; + justify-content: center; + align-items: center; +} +.flex--spaceBetween, +.flex--spaceBetween-center { + @extend .flex; + justify-content: space-between; +} +.flex--end, +.flex--end-center { + @extend .flex; + justify-content: flex-end; +} +.inline-flex--center { + @extend .flex--center; + display: inline-flex +} +.flex--center-baseline { + @extend .flex--center; + align-items: baseline; +} + +.flex-v--center { + @extend .flex-v; + justify-content: center; +} +.flex-v--end { + @extend .flex-v; + justify-content: flex-end; +} \ No newline at end of file diff --git a/src/util/colorMXID.js b/src/util/colorMXID.js new file mode 100644 index 0000000..54eec64 --- /dev/null +++ b/src/util/colorMXID.js @@ -0,0 +1,23 @@ +// https://github.com/cloudrac3r/cadencegq/blob/master/pug/mxid.pug + +const colors = ['#368bd6', '#ac3ba8', '#03b381', '#e64f7a', '#ff812d', '#2dc2c5', '#5c56f5', '#74d12c']; +function hashCode(str) { + let hash = 0; + let i; + let chr; + if (str.length === 0) { + return hash; + } + for (i = 0; i < str.length; i += 1) { + chr = str.charCodeAt(i); + // eslint-disable-next-line no-bitwise + hash = ((hash << 5) - hash) + chr; + // eslint-disable-next-line no-bitwise + hash |= 0; + } + return Math.abs(hash); +} +export default function colorMXID(userId) { + const colorNumber = hashCode(userId) % 8; + return colors[colorNumber]; +} diff --git a/src/util/common.js b/src/util/common.js new file mode 100644 index 0000000..78bb349 --- /dev/null +++ b/src/util/common.js @@ -0,0 +1,21 @@ +export function bytesToSize(bytes) { + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + if (bytes === 0) return 'n/a'; + const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)), 10); + if (i === 0) return `${bytes} ${sizes[i]}`; + return `${(bytes / (1024 ** i)).toFixed(1)} ${sizes[i]}`; +} + +export function diffMinutes(dt2, dt1) { + let diff = (dt2.getTime() - dt1.getTime()) / 1000; + diff /= 60; + return Math.abs(Math.round(diff)); +} + +export function isNotInSameDay(dt2, dt1) { + return ( + dt2.getDay() !== dt1.getDay() + || dt2.getMonth() !== dt1.getMonth() + || dt2.getYear() !== dt1.getYear() + ); +} diff --git a/src/util/matrixUtil.js b/src/util/matrixUtil.js new file mode 100644 index 0000000..75de842 --- /dev/null +++ b/src/util/matrixUtil.js @@ -0,0 +1,67 @@ +import initMatrix from '../client/initMatrix'; + +const WELL_KNOWN_URI = '/.well-known/matrix/client'; + +async function getBaseUrl(homeserver) { + const serverDiscoveryUrl = `https://${homeserver}${WELL_KNOWN_URI}`; + try { + const result = await fetch(serverDiscoveryUrl, { method: 'GET' }); + const data = await result.json(); + + return data?.['m.homeserver']?.base_url; + } catch (e) { + throw new Error('Homeserver not found'); + } +} + +function getUsername(userId) { + const mx = initMatrix.matrixClient; + const user = mx.getUser(userId); + if (user === null) return userId; + let username = user.displayName; + if (typeof username === 'undefined') { + username = userId; + } + return username; +} + +async function isRoomAliasAvailable(alias) { + try { + const myUserId = initMatrix.matrixClient.getUserId(); + const myServer = myUserId.slice(myUserId.indexOf(':') + 1); + const result = await initMatrix.matrixClient.resolveRoomAlias(alias); + const aliasIsRegisteredOnMyServer = typeof result.servers.find((server) => server === myServer) === 'string'; + + if (aliasIsRegisteredOnMyServer) return false; + return true; + } catch (e) { + if (e.errcode === 'M_NOT_FOUND') return true; + if (e.errcode === 'M_INVALID_PARAM') throw new Error(e); + return false; + } +} + +function doesRoomHaveUnread(room) { + const userId = initMatrix.matrixClient.getUserId(); + const readUpToId = room.getEventReadUpTo(userId); + + if (room.timeline.length + && room.timeline[room.timeline.length - 1].sender + && room.timeline[room.timeline.length - 1].sender.userId === userId + && room.timeline[room.timeline.length - 1].getType() !== 'm.room.member') { + return false; + } + + for (let i = room.timeline.length - 1; i >= 0; i -= 1) { + const event = room.timeline[i]; + + if (event.getId() === readUpToId) return false; + return true; + } + return true; +} + +export { + getBaseUrl, getUsername, + isRoomAliasAvailable, doesRoomHaveUnread, +}; diff --git a/webpack.common.js b/webpack.common.js new file mode 100644 index 0000000..b1bcbbc --- /dev/null +++ b/webpack.common.js @@ -0,0 +1,69 @@ +const HtmlWebpackPlugin = require('html-webpack-plugin'); +const FaviconsWebpackPlugin = require('favicons-webpack-plugin'); + +module.exports = { + entry: { + polyfill: 'babel-polyfill', + main: './src/index.jsx' + }, + resolve: { + extensions: ['.js', '.jsx'], + fallback: { + 'crypto': require.resolve('crypto-browserify'), + 'path': require.resolve('path-browserify'), + 'fs': require.resolve('browserify-fs'), + 'stream': require.resolve('stream-browserify'), + 'util': require.resolve('util/'), + } + }, + node: { + global: true, + }, + module: { + rules: [ + { + test: /\.jsx?$/, + exclude: /node_modules/, + use: { + loader: 'babel-loader', + options: { + presets: ['@babel/preset-env', '@babel/preset-react'], + }, + }, + }, + { + test: /\.html$/, + use: ['html-loader'], + }, + { + test: /\.(svg|png|jpe?g|gif|otf|ttf)$/, + use: { + loader: 'file-loader', + options: { + name: '[name].[hash].[ext]', + outputPath: 'assets', + }, + }, + }, + ], + }, + plugins: [ + new HtmlWebpackPlugin({ template: './public/index.html' }), + new FaviconsWebpackPlugin({ + logo: './public/res/svg/cinny.svg', + mode: 'webapp', + devMode: 'light', + favicons: { + appName: 'Cinny', + appDescription: 'A matrix client', + developerName: 'ajbura, 1997kB', + developerURL: null, + icons: { + coast: false, + yandex: false, + appleStartup: false, + } + } + }) + ], +}; diff --git a/webpack.dev.js b/webpack.dev.js new file mode 100644 index 0000000..2cfa2df --- /dev/null +++ b/webpack.dev.js @@ -0,0 +1,27 @@ +const path = require('path'); +const common = require('./webpack.common'); +const { merge } = require('webpack-merge'); + +module.exports = merge(common, { + mode: 'development', + output: { + path: path.resolve(__dirname, 'dist'), + filename: '[name].bundle.js', + publicPath: '/', + }, + devServer: { + historyApiFallback: true, + }, + module: { + rules: [ + { + test: /\.s?css$/, + use: [ + 'style-loader', + 'css-loader', + 'sass-loader', + ], + }, + ], + }, +}); diff --git a/webpack.prod.js b/webpack.prod.js new file mode 100644 index 0000000..eea1eb8 --- /dev/null +++ b/webpack.prod.js @@ -0,0 +1,39 @@ +const path = require('path'); +const common = require('./webpack.common'); +const { merge } = require('webpack-merge'); +const MiniCssExtractPlugin = require('mini-css-extract-plugin'); +const CssMinimizerPlugin = require('css-minimizer-webpack-plugin'); +const { CleanWebpackPlugin } = require('clean-webpack-plugin'); + +module.exports = merge(common, { + mode: 'production', + output: { + path: path.resolve(__dirname, 'dist'), + filename: '[name].[contenthash].bundle.js', + }, + optimization: { + minimize: true, + minimizer: [ + '...', + new CssMinimizerPlugin(), + ], + }, + module: { + rules: [ + { + test: /\.s?css$/, + use: [ + MiniCssExtractPlugin.loader, + 'css-loader', + 'sass-loader', + ], + }, + ], + }, + plugins: [ + new CleanWebpackPlugin(), + new MiniCssExtractPlugin({ + filename: '[name].[contenthash].bundle.css', + }), + ], +});