--- /dev/null
+webpack.common.js
+webpack.dev.js
+webpack.prod.js
+experiment
+node_modules
\ No newline at end of file
--- /dev/null
+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,
+ },
+};
--- /dev/null
+liberapay: kfiven
\ No newline at end of file
--- /dev/null
+---
+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
--- /dev/null
+---
+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.
--- /dev/null
+<!-- Please read https://github.com/ajbura/cinny/CONTRIBUTING.md before submitting your pull request -->
+
+# 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
--- /dev/null
+# Reporting a Vulnerability
+
+**If you've found a security vulnerability, please report it to cinnyapp@gmail.com**
\ No newline at end of file
--- /dev/null
+experiment
+package-lock.json
+dist
+node_modules
+devAssets
\ No newline at end of file
--- /dev/null
+<!-- omit in toc -->
+# 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)
+
+<!-- omit in toc -->
+## 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 <!-- omit in toc -->
+> 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
+
+<!-- omit in toc -->
+#### 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?
+
+<!-- omit in toc -->
+#### 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 <cinnyapp@gmail.com>.
+
+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.
+
+<!-- omit in toc -->
+#### 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.
+
+<!-- omit in toc -->
+#### 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
--- /dev/null
+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
--- /dev/null
+# Cinny
+
+## Table of Contents
+
+- [About](#about)
+- [Getting Started](https://cinny.in)
+- [Contributing](./CONTRIBUTING.md)
+
+## About <a name = "about"></a>
+
+Cinny is a [matrix](https://matrix.org) client focusing primarily on simple, elegant and secure interface.
--- /dev/null
+{
+ "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"
+ }
+}
--- /dev/null
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <link href="https://api.fontshare.com/css?f[]=supreme@300,301,400,401,500,501,700,701&display=swap" rel="stylesheet">
+ <title>Cinny</title>
+ <meta name="name" content="Cinny">
+ <meta name="description" content="Yet another matrix client. Where you can enjoy the conversation using simple, elegant and secure interface protected by e2ee with the power of open source.">
+ <meta name="keywords" content="cinny, cinnyapp, cinnychat, matrix, matrix client, matrix.org, element">
+
+ <meta property="og:title" content="Cinny">
+ <meta property="og:url" content="https://cinny.in">
+ <meta property="og:image" content="https://cinny.in/assets/favicon-48x48.png">
+ <meta property="og:description" content="Yet another matrix client. Where you can enjoy the conversation using simple, elegant and secure interface protected by e2ee with the power of open source.">
+ <meta name="theme-color" content="#000000">
+</head>
+<body id="appBody">
+ <div id="root"></div>
+</body>
+</html>
\ No newline at end of file
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<g>
+ <path d="M14,11c2.2,0,4-1.8,4-4c0-2.2-1.8-4-4-4s-4,1.8-4,4C10,9.2,11.8,11,14,11z M14,5c1.1,0,2,0.9,2,2c0,1.1-0.9,2-2,2
+ s-2-0.9-2-2C12,5.9,12.9,5,14,5z"/>
+ <path d="M16,13h-4c-3.3,0-6,2.7-6,6v2h16v-2C22,15.7,19.3,13,16,13z M8,19c0-2.2,1.8-4,4-4h4c2.2,0,4,1.8,4,4H8z"/>
+ <polygon points="8,9 5,9 5,6 3,6 3,9 0,9 0,11 3,11 3,14 5,14 5,11 8,11 "/>
+</g>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<path d="M19.5,5.4c-0.5-0.5-1-1-1.5-1.4c-1.7-1.3-3.8-2-6-2S7.7,2.8,6,4C5.5,4.4,5,4.9,4.5,5.4C2.9,7.2,2,9.5,2,12
+ c0,2.5,0.9,4.8,2.5,6.6c0.5,0.5,1,1,1.5,1.4c1.7,1.3,3.8,2,6,2s4.3-0.8,6-2c0.5-0.4,1-0.9,1.5-1.4c1.6-1.8,2.5-4.1,2.5-6.6
+ C22,9.5,21.1,7.2,19.5,5.4z M4,12c0-2,0.8-3.9,2-5.3C7.2,8.1,8,10,8,12c0,2-0.8,3.9-2,5.3C4.8,15.9,4,14,4,12z M12,20
+ c-1.7,0-3.2-0.5-4.5-1.4C9.1,16.8,10,14.5,10,12c0-2.5-0.9-4.8-2.5-6.6C8.8,4.5,10.3,4,12,4s3.2,0.5,4.5,1.4C14.9,7.2,14,9.5,14,12
+ c0,2.5,0.9,4.8,2.5,6.6C15.2,19.5,13.7,20,12,20z M18,17.3c-1.2-1.4-2-3.3-2-5.3c0-2,0.8-3.9,2-5.3c1.2,1.4,2,3.3,2,5.3
+ C20,14,19.2,15.9,18,17.3z"/>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<g>
+ <path d="M12,4c2.8,0,5,2.2,5,5v4v0.8l0.6,0.6l0.6,0.6H5.8l0.6-0.6L7,13.8V13V9C7,6.2,9.2,4,12,4 M12,2C8.1,2,5,5.1,5,9v4l-2,2v2h18
+ v-2l-2-2V9C19,5.1,15.9,2,12,2L12,2z"/>
+ <path d="M9,19c0,1.7,1.3,3,3,3s3-1.3,3-3H9z"/>
+</g>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<path d="M12,2c-0.3,0-0.6,0-0.9,0.1c-3.5,0.4-6.4,3.2-7,6.7c-0.5,3.1,0.8,6,3.1,7.7C7.7,16.8,8,17.4,8,18v4h8v-4
+ c0-0.6,0.3-1.2,0.8-1.6c1.9-1.5,3.2-3.8,3.2-6.4C20,5.6,16.4,2,12,2z M15.6,14.8c-1,0.7-1.6,1.9-1.6,3.2v2h-1.3v-8.1
+ c0.7-0.3,1.3-1,1.3-1.9c0-1.1-0.9-2-2-2s-2,0.9-2,2c0,0.8,0.5,1.6,1.3,1.9V20H10v-2c0-1.2-0.6-2.4-1.6-3.2C6.6,13.4,5.7,11.3,6.1,9
+ c0.4-2.6,2.6-4.7,5.2-5c0.2,0,0.5,0,0.7,0c3.3,0,6,2.7,6,6C18,11.9,17.1,13.7,15.6,14.8z"/>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<g>
+ <polygon points="4.2,9.2 5.6,7.8 12,14.2 18.4,7.8 19.8,9.2 12,17 "/>
+</g>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<g>
+ <polygon points="14.8,4.2 16.2,5.6 9.8,12 16.2,18.4 14.8,19.8 7,12 "/>
+</g>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<g>
+ <polygon points="9.2,4.2 7.8,5.6 14.2,12 7.8,18.4 9.2,19.8 17,12 "/>
+</g>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<g>
+ <polygon points="4.2,14.8 5.6,16.2 12,9.8 18.4,16.2 19.8,14.8 12,7 "/>
+</g>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<g>
+ <path d="M12,2C6.5,2,2,6.5,2,12s4.5,10,10,10s10-4.5,10-10S17.5,2,12,2z M12,20c-4.4,0-8-3.6-8-8s3.6-8,8-8s8,3.6,8,8
+ S16.4,20,12,20z"/>
+ <polygon points="13,7 11,7 11,11 7,11 7,13 11,13 11,17 13,17 13,13 17,13 17,11 13,11 "/>
+</g>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<g>
+ <path d="M12,2C6.5,2,2,6.5,2,12s4.5,10,10,10s10-4.5,10-10S17.5,2,12,2z M12,20c-4.4,0-8-3.6-8-8s3.6-8,8-8s8,3.6,8,8
+ S16.4,20,12,20z"/>
+ <path d="M13,11.3h-2c-0.7,0-1.3-0.6-1.3-1.3s0.6-1.3,1.3-1.3h4V7.3h-2.3V6h-1.5v1.3H11c-1.5,0-2.8,1.2-2.8,2.8s1.2,2.8,2.8,2.8h2
+ c0.7,0,1.3,0.6,1.3,1.3s-0.6,1.3-1.3,1.3H9v1.5h2.3V18h1.5v-1.3H13c1.5,0,2.8-1.2,2.8-2.8S14.5,11.3,13,11.3z"/>
+</g>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<polygon points="17.7,16.2 13.4,12 17.7,7.8 16.2,6.3 12,10.6 7.8,6.3 6.3,7.8 10.6,12 6.3,16.2 7.8,17.7 12,13.4 16.2,17.7 "/>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<path d="M23,12c0-2.4-1.7-4.4-4-4.9V5c0-1.1-0.9-2-2-2H5C3.9,3,3,3.9,3,5v10c0,3.3,2.7,6,6,6h4c2.6,0,4.9-1.7,5.7-4.1
+ C21.1,16.6,23,14.5,23,12z M17,5v2H5V5H17z M13,19H9c-2.2,0-4-1.8-4-4V9h12v6C17,17.2,15.2,19,13,19z M19,14.8V9.2
+ c1.2,0.4,2,1.5,2,2.8S20.2,14.4,19,14.8z"/>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<g>
+ <circle cx="10" cy="11" r="1"/>
+ <circle cx="14" cy="11" r="1"/>
+ <path d="M20.3,16.2C20,14.7,19.6,13,19,11.3c0.6-2.1,1.6-6.7,0.4-8.4c-0.3-0.5-0.8-0.7-1.4-0.7c-1.2,0-2.8,1.3-3.7,2.3
+ C13.6,4.2,12.8,4,12,4s-1.6,0.2-2.3,0.6c-1-1-2.5-2.3-3.7-2.3c-0.6,0-1.1,0.2-1.4,0.7C3.4,4.6,4.4,9.2,5,11.3
+ c-0.6,1.7-1,3.4-1.3,4.9C3.2,18.7,5.1,21,7.6,21h8.7C18.9,21,20.8,18.7,20.3,16.2z M5.8,3.8C5.9,3.8,5.9,3.8,6,3.8
+ c0.5,0,1.5,0.8,2.5,1.7C7.5,6.3,6.7,7.6,6,9C5.6,6.8,5.3,4.5,5.8,3.8z M17.9,18.3c-0.4,0.5-1,0.7-1.6,0.7H7.6
+ c-0.6,0-1.2-0.3-1.6-0.7c-0.2-0.3-0.6-0.8-0.4-1.6C7,10,9.4,6,12,6s5,4,6.3,10.6C18.5,17.4,18.1,18,17.9,18.3z M18,9
+ c-0.7-1.4-1.5-2.6-2.5-3.5c0.9-0.9,2-1.7,2.5-1.7c0.1,0,0.1,0,0.2,0.1C18.7,4.5,18.5,6.6,18,9z"/>
+ <path d="M12.6,14h-1.2c-0.8,0-1.4,0.6-1.4,1.4v0c0,0.4,0.1,0.7,0.4,1l0.9,0.9c0.4,0.4,1,0.4,1.4,0l0.9-0.9c0.3-0.3,0.4-0.6,0.4-1v0
+ C14,14.6,13.4,14,12.6,14z"/>
+</g>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<g>
+ <g>
+ <path d="M19,21H5c-1.1,0-2-0.9-2-2v-3h2v3h14v-3h2v3C21,20.1,20.1,21,19,21z"/>
+ </g>
+ <polygon points="15.3,11.3 13,13.6 13,3 11,3 11,13.6 8.7,11.3 7.3,12.7 12,17.4 16.7,12.7 "/>
+</g>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<g>
+ <path d="M12,2C6.5,2,2,6.5,2,12s4.5,10,10,10s10-4.5,10-10S17.5,2,12,2z M12,20c-4.4,0-8-3.6-8-8s3.6-8,8-8s8,3.6,8,8
+ S16.4,20,12,20z"/>
+ <circle cx="9.5" cy="8.5" r="1.5"/>
+ <circle cx="14.5" cy="8.5" r="1.5"/>
+ <path d="M16,12c0,2.2-1.8,4-4,4s-4-1.8-4-4H6c0,3.3,2.7,6,6,6s6-2.7,6-6H16z"/>
+</g>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<g>
+ <path d="M12,2C6.5,2,2,6.5,2,12s4.5,10,10,10s10-4.5,10-10S17.5,2,12,2z M12,20c-4.4,0-8-3.6-8-8s3.6-8,8-8s8,3.6,8,8
+ S16.4,20,12,20z"/>
+ <path d="M9,12l3,6l3-6l-3-6L9,12z M13,12c0,0.6-0.4,1-1,1c-0.6,0-1-0.4-1-1c0-0.6,0.4-1,1-1C12.6,11,13,11.4,13,12z"/>
+</g>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<g>
+ <polygon points="14,3 14,5 17.8,5 12.9,9.9 14.3,11.3 19,6.6 19,10.2 21,10.2 21,3 "/>
+ <path d="M3,10.2h2V5h5V3H5C3.9,3,3,3.9,3,5V10.2z"/>
+ <path d="M5,14.2H3V19c0,1.1,0.9,2,2,2h5v-2H5V14.2z"/>
+ <path d="M19,19h-5v2h5c1.1,0,2-0.9,2-2v-4.8h-2V19z"/>
+</g>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<path d="M13.8,2H6C4.9,2,4,2.9,4,4v16c0,1.1,0.9,2,2,2h12c1.1,0,2-0.9,2-2V8.2L13.8,2z M18,20H6V4h7v3c0,1.1,0.9,2,2,2h3V20z"/>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<path d="M18.5,5l-2.1,2.8L15.5,9l0.9,1.2l2.1,2.8H9H7v2v4H5V5H18.5 M21,3H5C3.9,3,3,3.9,3,5v14c0,1.1,0.9,2,2,2h2c1.1,0,2-0.9,2-2
+ v-4h12v-2l-3-4l3-4V3L21,3z"/>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<g>
+ <polygon points="16,12 14,12 14,14 10,14 10,10 12,10 12,8 10,8 10,3 8,3 8,8 3,8 3,10 8,10 8,14 3,14 3,16 8,16 8,21 10,21 10,16
+ 14,16 14,21 16,21 16,16 21,16 21,14 16,14 "/>
+ <path d="M21,4V3c0-1.7-1.3-3-3-3s-3,1.3-3,3v1c-0.6,0-1,0.4-1,1v4c0,0.6,0.4,1,1,1h6c0.6,0,1-0.4,1-1V5C22,4.4,21.6,4,21,4z
+ M19.5,4h-3V3c0-0.8,0.7-1.5,1.5-1.5s1.5,0.7,1.5,1.5V4z"/>
+</g>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<g>
+ <polygon points="16,12 14,12 14,14 10,14 10,10 12,10 12,8 10,8 10,3 8,3 8,8 3,8 3,10 8,10 8,14 3,14 3,16 8,16 8,21 10,21 10,16
+ 14,16 14,21 16,21 16,16 21,16 21,14 16,14 "/>
+ <g>
+ <path d="M19,0c-2.8,0-5,2.2-5,5s2.2,5,5,5s5-2.2,5-5S21.8,0,19,0z M22,5.8h-2.3V8h-1.5V5.8H16V4.3h2.3V2h1.5v2.3H22V5.8z"/>
+ </g>
+</g>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<g>
+ <polygon points="16.1,12 14.1,12 14.1,14 10.1,14 10.1,10 12.1,10 12.1,8 10.1,8 10.1,3 8.1,3 8.1,8 3.1,8 3.1,10 8.1,10 8.1,14
+ 3.1,14 3.1,16 8.1,16 8.1,21 10.1,21 10.1,16 14.1,16 14.1,21 16.1,21 16.1,16 21.1,16 21.1,14 16.1,14 "/>
+ <path d="M24,9l-2.7-2.7c0.5-0.7,0.8-1.5,0.8-2.3c0-2.2-1.8-4-4-4s-4,1.8-4,4s1.8,4,4,4c0.8,0,1.5-0.2,2.2-0.6l2.7,2.7L24,9z
+ M18.1,6.5c-1.4,0-2.5-1.1-2.5-2.5s1.1-2.5,2.5-2.5s2.5,1.1,2.5,2.5S19.5,6.5,18.1,6.5z"/>
+</g>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<g>
+ <polygon points="16,12 14,12 14,14 10,14 10,10 12,10 12,8 10,8 10,3 8,3 8,8 3,8 3,10 8,10 8,14 3,14 3,16 8,16 8,21 10,21 10,16
+ 14,16 14,21 16,21 16,16 21,16 21,14 16,14 "/>
+ <path d="M18,0l-4,2v2c0,1.9,0.9,3.7,2.4,4.8L18,10l1.6-1.2C21.1,7.7,22,5.9,22,4V2L18,0z"/>
+</g>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<path d="M21,10V8h-5V3h-2v5h-4V3H8v5H3v2h5v4H3v2h5v5h2v-5h4v5h2v-5h5v-2h-5v-4H21z M14,14h-4v-4h4V14z"/>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<path d="M16.6,5.1c0.9,0,1.8,0.4,2.5,1c0.7,0.7,1,1.5,1,2.5c0,0.9-0.4,1.8-1,2.5l-0.7,0.7L12,18.2l-7.1-7.1c-0.7-0.7-1-1.5-1-2.5
+ c0-0.9,0.4-1.8,1-2.5c0.7-0.7,1.5-1,2.5-1s1.8,0.4,2.5,1l0.7,0.7L12,8.2l1.4-1.4l0.7-0.7C14.8,5.5,15.7,5.1,16.6,5.1 M16.6,3.1
+ c-1.4,0-2.8,0.5-3.9,1.6L12,5.4l-0.7-0.7c-1.1-1.1-2.5-1.6-3.9-1.6C6,3.1,4.6,3.6,3.5,4.7c-2.2,2.1-2.2,5.6,0,7.8L12,21l7.8-7.8
+ l0.7-0.7c1.1-1.1,1.6-2.5,1.6-3.9c0-1.4-0.5-2.8-1.6-3.9C19.4,3.6,18,3.1,16.6,3.1L16.6,3.1z"/>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<g>
+ <path d="M12,3L2,12h3v7c0,1.1,0.9,2,2,2h10c1.1,0,2-0.9,2-2v-7h3L12,3z M17,12v7H7v-7v-1.8l5-4.5l5,4.5V12z"/>
+ <circle cx="12" cy="14" r="2"/>
+</g>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<path d="M19,12V5c0-1.1-0.9-2-2-2H7C5.9,3,5,3.9,5,5v7c-1.1,0-2,0.9-2,2v5c0,1.1,0.9,2,2,2h14c1.1,0,2-0.9,2-2v-5
+ C21,12.9,20.1,12,19,12z M7,5h10v7h-3v1c0,1.1-0.9,2-2,2c-1.1,0-2-0.9-2-2v-1H7V5z M19,19H5v-5h3.1c0.4,1.7,2,3,3.9,3
+ c1.9,0,3.4-1.3,3.9-3H19V19z"/>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<g>
+ <path d="M16.3,7.3l-1.4,1.4l2.3,2.3H8.2c-0.8,0-1.6-0.3-2.1-0.9C5.6,9.6,5.2,8.8,5.2,8s0.3-1.6,0.9-2.1l1.2-1.2L5.9,3.3L4.7,4.5
+ c-1.9,1.9-1.9,5.1,0,7.1C5.7,12.5,6.9,13,8.2,13h8.9l-2.3,2.3l1.4,1.4L21,12L16.3,7.3z"/>
+ <polygon points="9,17.3 6.8,17.3 6.8,15 5.3,15 5.3,17.3 3,17.3 3,18.8 5.3,18.8 5.3,21 6.8,21 6.8,18.8 9,18.8 "/>
+</g>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<g>
+ <path d="M16.3,7.3l-1.4,1.4l2.3,2.3H8.2c-0.8,0-1.6-0.3-2.1-0.9C5.6,9.6,5.2,8.8,5.2,8s0.3-1.6,0.9-2.1l1.2-1.2L5.9,3.3L4.7,4.5
+ c-1.9,1.9-1.9,5.1,0,7.1C5.7,12.5,6.9,13,8.2,13h8.9l-2.3,2.3l1.4,1.4L21,12L16.3,7.3z"/>
+ <polygon points="8.7,19.6 7.1,18 8.7,16.4 7.6,15.4 6,17 4.4,15.4 3.3,16.4 4.9,18 3.3,19.6 4.4,20.7 6,19.1 7.6,20.7 "/>
+</g>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<g>
+ <polygon points="19.8,16 18.3,16 18.3,18.3 16,18.3 16,19.8 18.3,19.8 18.3,22 19.8,22 19.8,19.8 22,19.8 22,18.3 19.8,18.3 "/>
+ <path d="M20,4H4C2.9,4,2,4.9,2,6v12c0,1.1,0.9,2,2,2h10v-2H4V9.9l8,5.3l8-5.3V14h2V6C22,4.9,21.1,4,20,4z M20,7.5l-8,5.3L4,7.5V6
+ h16V7.5z"/>
+</g>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<path d="M4.6,4.5l1.2-1.2l1.4,1.4L6,5.9C4.8,7,4.8,9,6,10.1C6.6,10.7,7.3,11,8.1,11H17l-2.3-2.3l1.4-1.4l4.7,4.7l-4.7,4.7l-1.4-1.4
+ L17,13H8.1c-1.3,0-2.6-0.5-3.5-1.5C2.6,9.6,2.6,6.4,4.6,4.5z"/>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<path d="M19.4,4.5l-1.2-1.2l-1.4,1.4L18,5.9c1.2,1.2,1.2,3.1,0,4.2c-0.6,0.6-1.3,0.9-2.1,0.9H7l2.3-2.3L7.8,7.3L3.1,12l4.7,4.7
+ l1.4-1.4L7,13h8.9c1.3,0,2.6-0.5,3.5-1.5C21.4,9.6,21.4,6.4,19.4,4.5z"/>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<g>
+ <path d="M18,10h-1V7.2c0-2.6-1.9-4.9-4.5-5.2C9.5,1.7,7,4.1,7,7v3H6c-1.1,0-2,0.9-2,2v8c0,1.1,0.9,2,2,2h12c1.1,0,2-0.9,2-2v-8
+ C20,10.9,19.1,10,18,10z M9,7c0-1.7,1.3-3,3-3c1.7,0,3,1.3,3,3v3H9V7z M18,20H6v-8h12V20z"/>
+ <circle cx="12" cy="16" r="2"/>
+</g>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<g>
+ <g>
+ <rect x="9" y="8" width="2" height="8"/>
+ </g>
+ <g>
+ <rect x="13" y="8" width="2" height="8"/>
+ </g>
+ <path d="M12,4c4.4,0,8,3.6,8,8s-3.6,8-8,8s-8-3.6-8-8S7.6,4,12,4 M12,2C6.5,2,2,6.5,2,12s4.5,10,10,10s10-4.5,10-10S17.5,2,12,2
+ L12,2z"/>
+</g>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<path d="M12,2C6.5,2,2,6.5,2,12s4.5,10,10,10s10-4.5,10-10S17.5,2,12,2z M20,12c0,1.6-0.5,3.1-1.3,4.3L13,10.6V4.1
+ C16.9,4.6,20,7.9,20,12z M11,19.9c-1.7-0.2-3.2-1-4.4-2.1l4.4-4.4V19.9z M13,13.4l4.4,4.4c-1.2,1.1-2.7,1.9-4.4,2.1V13.4z M11,4.1
+ v6.5l-5.7,5.7C4.5,15.1,4,13.6,4,12C4,7.9,7.1,4.6,11,4.1z"/>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<g>
+ <path d="M19,3H5C3.9,3,3,3.9,3,5v14c0,1.1,0.9,2,2,2h14c1.1,0,2-0.9,2-2V5C21,3.9,20.1,3,19,3z M19,5v12.7l-5-5.8l-2,2L8.9,9.6
+ l-3.9,7V5H19z M18.2,19H5.4l3.7-6.6l2.8,3.8l2-2L18.2,19z"/>
+ <circle cx="14.5" cy="8.5" r="1.5"/>
+</g>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<g>
+ <path d="M12,4c4.4,0,8,3.6,8,8s-3.6,8-8,8s-8-3.6-8-8S7.6,4,12,4 M12,2C6.5,2,2,6.5,2,12s4.5,10,10,10s10-4.5,10-10S17.5,2,12,2
+ L12,2z"/>
+ <polygon points="10,8 10,16 16,12 "/>
+</g>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<polygon points="19,11 13,11 13,5 11,5 11,11 5,11 5,13 11,13 11,19 13,19 13,13 19,13 "/>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<g>
+ <path d="M17.6,5l-1.3,1.6C18,7.8,19,9.8,19,12c0,3.9-3.1,7-7,7s-7-3.1-7-7c0-2.2,1-4.2,2.6-5.5L6.4,5C4.3,6.6,3,9.2,3,12
+ c0,5,4,9,9,9c5,0,9-4,9-9C21,9.2,19.7,6.6,17.6,5z"/>
+ <rect x="11" y="2" width="2" height="10"/>
+</g>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<path d="M21,13v-2H9c-1.1,0-2,0.9-2,2v4.6l-2.3-2.3l-1.4,1.4L8,21.4l4.7-4.7l-1.4-1.4L9,17.6V13H21z"/>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<path d="M21.7,20.3l-5.4-5.4c1.1-1.4,1.7-3.1,1.7-4.9c0-4.4-3.6-8-8-8s-8,3.6-8,8s3.6,8,8,8c1.8,0,3.5-0.6,4.9-1.7l5.4,5.4
+ L21.7,20.3z M10,16c-3.3,0-6-2.7-6-6s2.7-6,6-6s6,2.7,6,6S13.3,16,10,16z"/>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<path d="M4,5.2L17.5,12L4,18.8v-4.3l1.6-0.5l5.7-1.9l-5.7-1.9L4,9.6V5.2 M2,2v11v-2l3,1l-3,1v9l20-10L2,2L2,2z"/>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<g>
+ <path d="M13,4v0.6v1.3l1.2,0.5c0.5,0.2,1,0.5,1.4,0.8l1.1,0.8l1.2-0.7l0.5-0.3l1,1.7l-0.5,0.3l-1.2,0.7l0.2,1.3
+ c0,0.3,0.1,0.6,0.1,0.8s0,0.5-0.1,0.8l-0.2,1.3l1.2,0.7l0.5,0.3l-1,1.7l-0.5-0.3l-1.2-0.7l-1.1,0.8c-0.4,0.3-0.9,0.6-1.4,0.8
+ L13,18.1v1.3V20h-2v-0.6v-1.3l-1.2-0.5c-0.5-0.2-1-0.5-1.4-0.8l-1.1-0.8l-1.2,0.7l-0.5,0.3l-1-1.7l0.5-0.3l1.2-0.7l-0.2-1.3
+ C6,12.5,6,12.2,6,12s0-0.5,0.1-0.8l0.2-1.3L5.1,9.2L4.6,8.9l1-1.7l0.5,0.3l1.2,0.7l1.1-0.8c0.4-0.3,0.9-0.6,1.4-0.8L11,5.9V4.6V4
+ H13 M13,2h-2C9.9,2,9,2.9,9,4v0.6C8.3,4.9,7.7,5.2,7.1,5.7L6.6,5.4c-0.3-0.2-0.7-0.3-1-0.3c-0.7,0-1.4,0.4-1.7,1l-1,1.7
+ c-0.6,1-0.2,2.2,0.7,2.7l0.5,0.3C4,11.3,4,11.6,4,12s0,0.7,0.1,1.1l-0.5,0.3c-1,0.6-1.3,1.8-0.7,2.7l1,1.7c0.4,0.6,1,1,1.7,1
+ c0.3,0,0.7-0.1,1-0.3l0.5-0.3c0.6,0.5,1.2,0.8,1.9,1.1V20c0,1.1,0.9,2,2,2h2c1.1,0,2-0.9,2-2v-0.6c0.7-0.3,1.3-0.7,1.9-1.1l0.5,0.3
+ c0.3,0.2,0.7,0.3,1,0.3c0.7,0,1.4-0.4,1.7-1l1-1.7c0.6-1,0.2-2.2-0.7-2.7l-0.5-0.3C20,12.7,20,12.4,20,12s0-0.7-0.1-1.1l0.5-0.3
+ c1-0.6,1.3-1.8,0.7-2.7l-1-1.7c-0.4-0.6-1-1-1.7-1c-0.3,0-0.7,0.1-1,0.3l-0.5,0.3c-0.6-0.5-1.2-0.8-1.9-1.1V4C15,2.9,14.1,2,13,2
+ L13,2z"/>
+ <g>
+ <path d="M12,16c-2.2,0-4-1.8-4-4s1.8-4,4-4s4,1.8,4,4S14.2,16,12,16z M12,10c-1.1,0-2,0.9-2,2s0.9,2,2,2s2-0.9,2-2S13.1,10,12,10z
+ "/>
+ </g>
+</g>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<g>
+ <path d="M12,2L3,6v7c0,5,4,9,9,9c5,0,9-4,9-9V6L12,2z M19,13c0,3.9-3.1,7-7,7s-7-3.1-7-7V7.3l7-3.1l7,3.1V13z"/>
+ <path d="M10,11c0,0.7,0.4,1.4,1,1.7V16h2v-3.3c0.6-0.3,1-1,1-1.7c0-1.1-0.9-2-2-2S10,9.9,10,11z"/>
+</g>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<g>
+ <path d="M21,4V3c0-1.7-1.3-3-3-3s-3,1.3-3,3v1c-0.6,0-1,0.4-1,1v4c0,0.6,0.4,1,1,1h6c0.6,0,1-0.4,1-1V5C22,4.4,21.5,4,21,4z
+ M19.5,4h-3V3c0-0.8,0.7-1.5,1.5-1.5s1.5,0.7,1.5,1.5V4z"/>
+ <path d="M15.4,13.7L14,14l-0.2,1.4c-0.4,2.8-1.3,4.2-1.7,4.6c-0.4-0.3-1.3-1.8-1.7-4.6L10,14l-1.4-0.2C5.8,13.3,4.4,12.4,4,12
+ c0.3-0.4,1.8-1.3,4.6-1.7L10,10l0.2-1.4c0.4-2.8,1.3-4.2,1.7-4.6V2c-1.7,0-3.1,2.6-3.7,6.3C4.6,8.9,2,10.3,2,12s2.6,3.1,6.3,3.7
+ c0.6,3.7,2,6.3,3.7,6.3s3.1-2.6,3.7-6.3c3.7-0.6,6.3-2,6.3-3.7h-2.1C19.6,12.4,18.2,13.3,15.4,13.7z"/>
+</g>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<path d="M12,4.1c0.4,0.3,1.3,1.8,1.7,4.6L14,10l1.4,0.2c2.8,0.4,4.2,1.3,4.6,1.7c-0.3,0.4-1.8,1.3-4.6,1.7L14,14l-0.2,1.4
+ c-0.4,2.8-1.3,4.2-1.7,4.6c-0.4-0.3-1.3-1.8-1.7-4.6L10,14l-1.4-0.2c-2.8-0.4-4.2-1.3-4.6-1.7c0.3-0.4,1.8-1.3,4.6-1.7L10,10
+ l0.2-1.4C10.7,5.8,11.6,4.4,12,4.1 M12,2c-1.7,0-3.1,2.6-3.7,6.3C4.6,8.9,2,10.3,2,12s2.6,3.1,6.3,3.7c0.6,3.7,2,6.3,3.7,6.3
+ s3.1-2.6,3.7-6.3c3.7-0.6,6.3-2,6.3-3.7s-2.6-3.1-6.3-3.7C15.1,4.6,13.7,2,12,2L12,2z"/>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<g>
+ <path d="M12,9c1.7,0,3,1.3,3,3s-1.3,3-3,3s-3-1.3-3-3S10.3,9,12,9 M12,7c-2.8,0-5,2.2-5,5s2.2,5,5,5s5-2.2,5-5S14.8,7,12,7L12,7z"
+ />
+ <g>
+ <rect x="11" y="2" width="2" height="3"/>
+ </g>
+ <g>
+ <rect x="19" y="11" width="3" height="2"/>
+ </g>
+ <g>
+ <rect x="11" y="19" width="2" height="3"/>
+ </g>
+ <g>
+ <rect x="2" y="11" width="3" height="2"/>
+ </g>
+ <g>
+ <rect x="17" y="4.5" transform="matrix(0.7071 0.7071 -0.7071 0.7071 9.5104 -10.9811)" width="2" height="3"/>
+ </g>
+ <g>
+ <rect x="16.5" y="17" transform="matrix(0.7071 0.7071 -0.7071 0.7071 18.0105 -7.4602)" width="3" height="2"/>
+ </g>
+ <g>
+ <rect x="5" y="16.5" transform="matrix(0.7071 0.7071 -0.7071 0.7071 14.4896 1.0399)" width="2" height="3"/>
+ </g>
+ <g>
+ <rect x="4.5" y="5" transform="matrix(0.7071 0.7071 -0.7071 0.7071 5.9895 -2.4809)" width="3" height="2"/>
+ </g>
+</g>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<g>
+ <rect x="13.6" y="6" transform="matrix(0.7071 0.7071 -0.7071 0.7071 10.9064 -7.6107)" width="2" height="6.7"/>
+ <rect x="1.8" y="12.9" transform="matrix(-0.7071 -0.7071 0.7071 -0.7071 0.1967 27.7885)" width="8.1" height="2"/>
+ <polygon points="21.3,6.3 13,14.6 8.7,10.3 7.3,11.7 13,17.4 22.7,7.7 "/>
+</g>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<g>
+ <path d="M12,11c2.2,0,4-1.8,4-4s-1.8-4-4-4S8,4.8,8,7S9.8,11,12,11z M12,5c1.1,0,2,0.9,2,2s-0.9,2-2,2s-2-0.9-2-2S10.9,5,12,5z"/>
+ <path d="M14,13h-4c-3.3,0-6,2.7-6,6v2h16v-2C20,15.7,17.3,13,14,13z M6,19c0-2.2,1.8-4,4-4h4c2.2,0,4,1.8,4,4H6z"/>
+</g>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<g>
+ <circle cx="12" cy="5" r="2"/>
+ <circle cx="12" cy="12" r="2"/>
+ <circle cx="12" cy="19" r="2"/>
+</g>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<path d="M19.4,20L14,2h-4L4.6,20H2v2h2h16h2v-2H19.4z M15.8,15H8.2l0.9-3h5.8L15.8,15z M11.5,4h1l1.8,6H9.7L11.5,4z M6.7,20l0.9-3
+ h8.8l0.9,3H6.7z"/>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<g>
+ <path d="M9,7.8v8.3L6.8,14H4v-4h2.8L9,7.8 M11,3L6,8H4c-1.1,0-2,0.9-2,2v4c0,1.1,0.9,2,2,2h2l5,5V3L11,3z"/>
+ <path d="M13,3v2c3.9,0,7,3.1,7,7s-3.1,7-7,7v2c5,0,9-4,9-9S18,3,13,3z"/>
+ <g>
+ <path d="M13,17v-2c1.7,0,3-1.3,3-3s-1.3-3-3-3V7c2.8,0,5,2.2,5,5S15.8,17,13,17z"/>
+ </g>
+</g>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
+<g>
+ <path d="M2,10v4c0,1.1,0.9,2,2,2h2l5,5V3L6,8H4C2.9,8,2,8.9,2,10z M9,7.8v8.3L6.8,14H4v-4h2.8L9,7.8z"/>
+ <polygon points="20.7,9.7 19.3,8.3 17,10.6 14.7,8.3 13.3,9.7 15.6,12 13.3,14.3 14.7,15.7 17,13.4 19.3,15.7 20.7,14.3 18.4,12
+ "/>
+</g>
+</svg>
--- /dev/null
+<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In -->
+<svg version="1.1"
+ xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:a="http://ns.adobe.com/AdobeSVGViewerExtensions/3.0/"
+ x="0px" y="0px" width="18px" height="18px" viewBox="0 0 18 18" enable-background="new 0 0 18 18" xml:space="preserve">
+<defs>
+</defs>
+<g>
+ <g>
+ <circle fill="#FFFFFF" cx="9" cy="9" r="8.5"/>
+ </g>
+ <g>
+ <path d="M9,0C4,0,0,4,0,9c0,5,4,9,9,9c5,0,9-4,9-9C18,4,14,0,9,0z M1.2,10.8l3.5-2.3c0-0.1,0-0.2,0-0.3c0-1.8,1.3-3.2,3.1-3.4
+ c0.1,0,0.2,0,0.4,0c1.2,0,2.3,0.6,2.9,1.6c0.3-0.1,0.6-0.1,0.9-0.1c0.4,0,0.8,0,1.2,0.1c0.7,0.2,1.4,0.5,2,0.9
+ C14.6,7.1,14,7,13.3,7c-1.2,0-2.2,0.4-2.9,1.4c-0.7,0.9-1.1,2-1.1,3.2c0,1.5-0.4,2.9-1.3,4.2c-0.3,0.4-0.5,0.7-0.8,1
+ C4.2,16.1,1.9,13.8,1.2,10.8z"/>
+ <circle cx="9.5" cy="6.4" r="0.5"/>
+ </g>
+</g>
+</svg>
\ No newline at end of file
--- /dev/null
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 13.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 14576) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ width="789.322px" height="336.807px" viewBox="0 0 789.322 336.807" enable-background="new 0 0 789.322 336.807"
+ xml:space="preserve">
+<path d="M8.876,7.71v321.386h23.13v7.711H0V0h32.006v7.71H8.876z"/>
+<path d="M97.989,109.594v16.264h0.463c4.338-6.191,9.563-10.998,15.684-14.406c6.117-3.402,13.129-5.11,21.027-5.11
+ c7.588,0,14.521,1.475,20.793,4.415c6.274,2.945,11.038,8.131,14.291,15.567c3.56-5.265,8.4-9.913,14.521-13.94
+ c6.117-4.025,13.358-6.042,21.724-6.042c6.351,0,12.234,0.776,17.66,2.325c5.418,1.549,10.065,4.027,13.938,7.434
+ c3.869,3.41,6.889,7.863,9.062,13.357c2.167,5.504,3.253,12.122,3.253,19.869v80.385h-32.993v-68.074
+ c0-4.025-0.154-7.82-0.465-11.385c-0.313-3.56-1.161-6.656-2.555-9.293c-1.395-2.631-3.45-4.724-6.157-6.274
+ c-2.711-1.543-6.391-2.322-11.037-2.322s-8.403,0.896-11.269,2.671c-2.868,1.784-5.112,4.109-6.737,6.971
+ c-1.626,2.869-2.711,6.12-3.252,9.762c-0.545,3.638-0.814,7.318-0.814,11.035v66.91h-32.991v-67.375c0-3.562-0.081-7.087-0.23-10.57
+ c-0.158-3.487-0.814-6.7-1.978-9.645c-1.162-2.94-3.099-5.304-5.809-7.088c-2.711-1.775-6.699-2.671-11.965-2.671
+ c-1.551,0-3.603,0.349-6.156,1.048c-2.556,0.697-5.036,2.016-7.435,3.949c-2.404,1.938-4.454,4.726-6.158,8.363
+ c-1.705,3.642-2.556,8.402-2.556,14.287v69.701h-32.99V109.594H97.989z"/>
+<path d="M271.545,127.254c3.405-5.113,7.744-9.215,13.012-12.316c5.264-3.097,11.186-5.303,17.771-6.621
+ c6.582-1.315,13.205-1.976,19.865-1.976c6.042,0,12.158,0.428,18.354,1.277c6.195,0.855,11.85,2.522,16.962,4.997
+ c5.111,2.477,9.292,5.926,12.546,10.338c3.253,4.414,4.879,10.262,4.879,17.543v62.494c0,5.428,0.31,10.611,0.931,15.567
+ c0.615,4.959,1.701,8.676,3.251,11.153h-33.455c-0.621-1.86-1.126-3.755-1.511-5.693c-0.39-1.933-0.661-3.908-0.813-5.923
+ c-5.267,5.422-11.465,9.217-18.585,11.386c-7.127,2.163-14.407,3.251-21.842,3.251c-5.733,0-11.077-0.698-16.033-2.09
+ c-4.958-1.395-9.293-3.562-13.01-6.51c-3.718-2.938-6.622-6.656-8.713-11.147s-3.138-9.84-3.138-16.033
+ c0-6.813,1.199-12.43,3.604-16.84c2.399-4.417,5.495-7.939,9.295-10.575c3.793-2.632,8.129-4.607,13.01-5.923
+ c4.878-1.315,9.795-2.358,14.752-3.137c4.957-0.772,9.835-1.393,14.638-1.857c4.801-0.466,9.062-1.164,12.779-2.093
+ c3.718-0.929,6.658-2.282,8.829-4.065c2.165-1.781,3.172-4.375,3.02-7.785c0-3.56-0.58-6.389-1.742-8.479
+ c-1.161-2.09-2.711-3.719-4.646-4.88c-1.937-1.161-4.183-1.936-6.737-2.325c-2.557-0.382-5.309-0.58-8.248-0.58
+ c-6.506,0-11.617,1.395-15.335,4.183c-3.716,2.788-5.889,7.437-6.506,13.94h-32.991C266.2,138.793,268.133,132.362,271.545,127.254z
+ M336.714,173.837c-2.09,0.696-4.337,1.275-6.736,1.741c-2.402,0.465-4.918,0.853-7.551,1.161c-2.635,0.313-5.268,0.698-7.899,1.163
+ c-2.48,0.461-4.919,1.086-7.317,1.857c-2.404,0.779-4.495,1.822-6.274,3.138c-1.784,1.317-3.216,2.985-4.3,4.994
+ c-1.085,2.014-1.626,4.571-1.626,7.668c0,2.94,0.541,5.422,1.626,7.431c1.084,2.017,2.558,3.604,4.416,4.765
+ s4.025,1.976,6.507,2.438c2.475,0.466,5.031,0.698,7.665,0.698c6.505,0,11.537-1.082,15.103-3.253
+ c3.561-2.166,6.192-4.762,7.899-7.785c1.702-3.019,2.749-6.072,3.137-9.174c0.384-3.097,0.58-5.576,0.58-7.434V170.93
+ C340.548,172.172,338.806,173.139,336.714,173.837z"/>
+<path d="M461.826,109.594v22.072h-24.161v59.479c0,5.573,0.928,9.292,2.788,11.149c1.856,1.859,5.576,2.788,11.152,2.788
+ c1.859,0,3.638-0.076,5.343-0.232c1.703-0.152,3.33-0.388,4.878-0.696v25.557c-2.788,0.465-5.887,0.773-9.293,0.931
+ c-3.407,0.149-6.737,0.23-9.99,0.23c-5.111,0-9.953-0.35-14.521-1.048c-4.571-0.695-8.597-2.047-12.081-4.063
+ c-3.486-2.011-6.236-4.88-8.248-8.597c-2.016-3.714-3.021-8.595-3.021-14.639v-70.859h-19.98v-22.072h19.98V73.582h32.992v36.012
+ H461.826z"/>
+<path d="M508.989,109.594v22.306h0.465c1.546-3.72,3.636-7.163,6.272-10.341c2.634-3.172,5.652-5.885,9.06-8.131
+ c3.405-2.242,7.047-3.985,10.923-5.228c3.868-1.237,7.898-1.859,12.081-1.859c2.168,0,4.566,0.39,7.202,1.163v30.67
+ c-1.551-0.312-3.41-0.584-5.576-0.814c-2.17-0.233-4.26-0.35-6.274-0.35c-6.041,0-11.152,1.01-15.332,3.021
+ c-4.182,2.014-7.55,4.761-10.107,8.247c-2.555,3.487-4.379,7.55-5.462,12.198c-1.083,4.645-1.625,9.682-1.625,15.102v54.133h-32.991
+ V109.594H508.989z"/>
+<path d="M568.931,91.006V63.823h32.994v27.183H568.931z M601.925,109.594v120.117h-32.994V109.594H601.925z"/>
+<path d="M619.116,109.594h37.637l21.144,31.365l20.911-31.365h36.476l-39.496,56.226l44.377,63.892h-37.64l-25.093-37.87
+ l-25.094,37.87H615.4l43.213-63.193L619.116,109.594z"/>
+<path d="M780.444,329.096V7.71h-23.13V0h32.008v336.807h-32.008v-7.711H780.444z"/>
+</svg>
\ No newline at end of file
--- /dev/null
+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 (
+ <div className={`avatar-container avatar-container__${size} noselect`}>
+ {
+ image !== null
+ ? <img src={image} onError={() => updateImage(null)} alt="avatar" />
+ : (
+ <span
+ style={{ backgroundColor: iconSrc === null ? bgColor : 'transparent' }}
+ className={`avatar__border${iconSrc !== null ? ' avatar__bordered' : ''} inline-flex--center`}
+ >
+ {
+ iconSrc !== null
+ ? <RawIcon size={size} src={iconSrc} />
+ : text !== null && <Text variant={textSize}>{text}</Text>
+ }
+ </span>
+ )
+ }
+ </div>
+ );
+}
+
+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;
--- /dev/null
+.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
--- /dev/null
+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 (
+ <div className={`notification-badge${notificationClass}`}>
+ <Text variant="b3">{children}</Text>
+ </div>
+ );
+}
+
+NotificationBadge.defaultProps = {
+ alert: false,
+};
+
+NotificationBadge.propTypes = {
+ alert: PropTypes.bool,
+ children: PropTypes.oneOfType([
+ PropTypes.string,
+ PropTypes.number,
+ ]).isRequired,
+};
+
+export default NotificationBadge;
--- /dev/null
+.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
--- /dev/null
+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
+ id={id === '' ? undefined : id}
+ className={`btn-${variant} ${iconClass} noselect`}
+ onMouseUp={(e) => blurOnBubbling(e, `.btn-${variant}`)}
+ onClick={onClick}
+ type={type === 'button' ? 'button' : 'submit'}
+ disabled={disabled}
+ >
+ {iconSrc !== null && <RawIcon size="small" src={iconSrc} />}
+ <Text variant="b1">{ children }</Text>
+ </button>
+ );
+}
+
+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;
--- /dev/null
+@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
--- /dev/null
+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) => (
+ <Tippy
+ content={<Text variant="b2">{tooltip}</Text>}
+ className="ic-btn-tippy"
+ touch="hold"
+ arrow={false}
+ maxWidth={250}
+ placement={tooltipPlacement}
+ delay={[0, 0]}
+ duration={[100, 0]}
+ >
+ <button
+ ref={ref}
+ className={`ic-btn-${variant}`}
+ onMouseUp={(e) => blurOnBubbling(e, `.ic-btn-${variant}`)}
+ onClick={onClick}
+ type={type === 'button' ? 'button' : 'submit'}
+ >
+ <RawIcon size={size} src={src} />
+ </button>
+ </Tippy>
+));
+
+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;
--- /dev/null
+@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
--- /dev/null
+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
+ <button
+ onClick={() => onToggle(!isActive)}
+ className={`toggle${isActive ? ' toggle--active' : ''}`}
+ type="button"
+ />
+ );
+}
+
+Toggle.defaultProps = {
+ isActive: false,
+};
+
+Toggle.propTypes = {
+ isActive: PropTypes.bool,
+ onToggle: PropTypes.func.isRequired,
+};
+
+export default Toggle;
--- /dev/null
+.toggle {
+ width: 44px;
+ height: 24px;
+ padding: 0 var(--sp-ultra-tight);
+ display: flex;
+ align-items: center;
+ border-radius: var(--bo-radius);
+ box-shadow: var(--bs-surface-border);
+ cursor: pointer;
+ background-color: var(--bg-surface-low);
+
+ transition: background 200ms ease-in-out;
+
+ &::before {
+ content: '';
+ display: inline-block;
+ width: 16px;
+ height: 16px;
+ background-color: var(--tc-surface-low);
+ border-radius: calc(var(--bo-radius) / 2);
+ transition: transform 200ms ease-in-out,
+ opacity 200ms ease-in-out;
+ opacity: 0.6;
+ }
+
+ &--active {
+ background-color: var(--bg-positive);
+
+ &::before {
+ background-color: white;
+ transform: translateX(calc(125%));
+ opacity: 1;
+
+ [dir=rtl] & {
+ transform: translateX(calc(-125%));
+ }
+ }
+ }
+}
\ No newline at end of file
--- /dev/null
+
+@mixin hover($color) {
+ @media (hover: hover) {
+ &:hover {
+ background-color: $color;
+ }
+ }
+}
+@mixin focus($outline) {
+ &:focus {
+ outline: none;
+ box-shadow: $outline;
+ }
+}
+@mixin active($color) {
+ &:active {
+ background-color: $color !important;
+ }
+}
+@mixin disabled {
+ &:disabled {
+ opacity: 0.4;
+ cursor: no-drop;
+ }
+}
\ No newline at end of file
--- /dev/null
+/**
+ * blur [selector] element in bubbling path.
+ * @param {Event} e Event
+ * @param {string} selector element selector for Element.matches([selector])
+ * @return {boolean} if blured return true, else return false with warning in console
+ */
+
+function blurOnBubbling(e, selector) {
+ const bubblingPath = e.nativeEvent.composedPath();
+
+ for (let elIndex = 0; elIndex < bubblingPath.length; elIndex += 1) {
+ if (bubblingPath[elIndex] === document) {
+ console.warn(blurOnBubbling, 'blurOnBubbling: not found selector in bubbling path');
+ break;
+ }
+ if (bubblingPath[elIndex].matches(selector)) {
+ setTimeout(() => bubblingPath[elIndex].blur(), 50);
+ return true;
+ }
+ }
+ return false;
+}
+export { blurOnBubbling };
--- /dev/null
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+import './ContextMenu.scss';
+
+import Tippy from '@tippyjs/react';
+import 'tippy.js/animations/scale-extreme.css';
+
+import Text from '../text/Text';
+import Button from '../button/Button';
+import ScrollView from '../scroll/ScrollView';
+
+function ContextMenu({
+ content, placement, maxWidth, render,
+}) {
+ const [isVisible, setVisibility] = useState(false);
+ const showMenu = () => setVisibility(true);
+ const hideMenu = () => setVisibility(false);
+
+ return (
+ <Tippy
+ animation="scale-extreme"
+ className="context-menu"
+ visible={isVisible}
+ onClickOutside={hideMenu}
+ content={<ScrollView invisible>{typeof content === 'function' ? content(hideMenu) : content}</ScrollView>}
+ placement={placement}
+ interactive
+ arrow={false}
+ maxWidth={maxWidth}
+ >
+ {render(isVisible ? hideMenu : showMenu)}
+ </Tippy>
+ );
+}
+
+ContextMenu.defaultProps = {
+ maxWidth: 'unset',
+ placement: 'right',
+};
+
+ContextMenu.propTypes = {
+ content: PropTypes.oneOfType([
+ PropTypes.node,
+ PropTypes.func,
+ ]).isRequired,
+ placement: PropTypes.oneOf(['top', 'right', 'bottom', 'left']),
+ maxWidth: PropTypes.oneOfType([
+ PropTypes.string,
+ PropTypes.number,
+ ]),
+ render: PropTypes.func.isRequired,
+};
+
+function MenuHeader({ children }) {
+ return (
+ <div className="context-menu__header">
+ <Text variant="b3">{ children }</Text>
+ </div>
+ );
+}
+
+MenuHeader.propTypes = {
+ children: PropTypes.string.isRequired,
+};
+
+function MenuItem({
+ variant, iconSrc, type, onClick, children,
+}) {
+ return (
+ <div className="context-menu__item">
+ <Button
+ variant={variant}
+ iconSrc={iconSrc}
+ type={type}
+ onClick={onClick}
+ >
+ { children }
+ </Button>
+ </div>
+ );
+}
+
+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 <div style={{ borderBottom: '1px solid var(--bg-surface-border)' }}> </div>;
+}
+
+export {
+ ContextMenu as default, MenuHeader, MenuItem, MenuBorder,
+};
--- /dev/null
+.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
--- /dev/null
+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 (
+ <div className={`divider${dividerClass}`}>
+ {text !== false && <Text className="divider__text" variant="b3">{text}</Text>}
+ </div>
+ );
+}
+
+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;
--- /dev/null
+.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
--- /dev/null
+import React from 'react';
+import PropTypes from 'prop-types';
+import './Header.scss';
+
+function Header({ children }) {
+ return (
+ <div className="header">
+ {children}
+ </div>
+ );
+}
+
+Header.propTypes = {
+ children: PropTypes.node.isRequired,
+};
+
+function TitleWrapper({ children }) {
+ return (
+ <div className="header__title-wrapper">
+ {children}
+ </div>
+ );
+}
+
+TitleWrapper.propTypes = {
+ children: PropTypes.node.isRequired,
+};
+
+export { Header as default, TitleWrapper };
--- /dev/null
+.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
--- /dev/null
+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 (
+ <div className="input-container">
+ { label !== '' && <label className="input__label text-b2" htmlFor={id}>{label}</label> }
+ { resizable
+ ? (
+ <TextareaAutosize
+ style={{ minHeight: `${minHeight}px` }}
+ id={id}
+ className={`input input--resizable${state !== 'normal' ? ` input--${state}` : ''}`}
+ ref={forwardRef}
+ type={type}
+ placeholder={placeholder}
+ required={required}
+ defaultValue={value}
+ autoComplete="off"
+ onChange={onChange}
+ onResize={onResize}
+ />
+ ) : (
+ <input
+ ref={forwardRef}
+ id={id}
+ className={`input ${state !== 'normal' ? ` input--${state}` : ''}`}
+ type={type}
+ placeholder={placeholder}
+ required={required}
+ defaultValue={value}
+ autoComplete="off"
+ onChange={onChange}
+ />
+ )}
+ </div>
+ );
+}
+
+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;
--- /dev/null
+.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
--- /dev/null
+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 (
+ <Modal
+ className={`${modalClass}raw-modal`}
+ overlayClassName={`${modalOverlayClass}raw-modal__overlay`}
+ isOpen={isOpen}
+ onAfterOpen={onAfterOpen}
+ onAfterClose={onAfterClose}
+ onRequestClose={onRequestClose}
+ shouldCloseOnEsc={closeFromOutside}
+ shouldCloseOnOverlayClick={closeFromOutside}
+ shouldReturnFocusAfterClose={false}
+ closeTimeoutMS={300}
+ >
+ {children}
+ </Modal>
+ );
+}
+
+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;
--- /dev/null
+.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
--- /dev/null
+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 (
+ <div onScroll={onScroll} ref={ref} className={`scrollbar${scrollbarClasses}`}>
+ {children}
+ </div>
+ );
+});
+
+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;
--- /dev/null
+@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
--- /dev/null
+.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
--- /dev/null
+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 (
+ <div className="segmented-controls">
+ {
+ segments.map((segment, index) => (
+ <button
+ key={Math.random().toString(20).substr(2, 6)}
+ className={`segment-btn${select === index ? ' segment-btn--active' : ''}`}
+ type="button"
+ onClick={() => selectSegment(index)}
+ onMouseUp={(e) => blurOnBubbling(e, '.segment-btn')}
+ >
+ <div className="segment-btn__base">
+ {segment.iconSrc && <RawIcon size="small" src={segment.iconSrc} />}
+ {segment.text && <Text variant="b2">{segment.text}</Text>}
+ </div>
+ </button>
+ ))
+ }
+ </div>
+ );
+}
+
+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;
--- /dev/null
+@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
--- /dev/null
+import React from 'react';
+import PropTypes from 'prop-types';
+import './Spinner.scss';
+
+function Spinner({ size }) {
+ return (
+ <div className={`donut-spinner donut-spinner--${size}`}> </div>
+ );
+}
+
+Spinner.defaultProps = {
+ size: 'normal',
+};
+
+Spinner.propTypes = {
+ size: PropTypes.oneOf(['normal', 'small']),
+};
+
+export default Spinner;
--- /dev/null
+.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
--- /dev/null
+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 <span className={`ic-raw ic-raw-${size}`} style={style}> </span>;
+}
+
+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;
--- /dev/null
+@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
--- /dev/null
+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 <h1 id={id === '' ? undefined : id} className={`${cName}text text-h1`}>{ children }</h1>;
+ if (variant === 'h2') return <h2 id={id === '' ? undefined : id} className={`${cName}text text-h2`}>{ children }</h2>;
+ if (variant === 's1') return <h4 id={id === '' ? undefined : id} className={`${cName}text text-s1`}>{ children }</h4>;
+ return <p id={id === '' ? undefined : id} className={`${cName}text text-${variant}`}>{ children }</p>;
+}
+
+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;
--- /dev/null
+@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
--- /dev/null
+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 <Linkify options={{ target: { url: '_blank' } }}>{content}</Linkify>;
+}
+
+function ChannelIntro({
+ avatarSrc, name, heading, desc, time,
+}) {
+ return (
+ <div className="channel-intro">
+ <Avatar imageSrc={avatarSrc} text={name.slice(0, 1)} bgColor={colorMXID(name)} size="large" />
+ <div className="channel-intro__content">
+ <Text className="channel-intro__name" variant="h1">{heading}</Text>
+ <Text className="channel-intro__desc" variant="b1">{linkifyContent(desc)}</Text>
+ { time !== null && <Text className="channel-intro__time" variant="b3">{time}</Text>}
+ </div>
+ </div>
+ );
+}
+
+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;
--- /dev/null
+.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
--- /dev/null
+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 (
+ <button
+ className={`channel-selector__button-wrapper${selected ? ' channel-selector--selected' : ''}`}
+ type="button"
+ onClick={onClick}
+ onMouseUp={(e) => blurOnBubbling(e, '.channel-selector__button-wrapper')}
+ >
+ <div className="channel-selector">
+ <div className="channel-selector__icon flex--center">
+ <Avatar
+ text={children.slice(0, 1)}
+ bgColor={colorMXID(roomId)}
+ imageSrc={imageSrc}
+ iconSrc={iconSrc}
+ size="extra-small"
+ />
+ </div>
+ <div className="channel-selector__text-container">
+ <Text variant="b1">{children}</Text>
+ </div>
+ <div className="channel-selector__badge-container">
+ {
+ notificationCount !== 0
+ ? unread && (
+ <NotificationBadge alert={alert}>
+ {notificationCount}
+ </NotificationBadge>
+ )
+ : unread && <div className="channel-selector--unread" />
+ }
+ </div>
+ </div>
+ </button>
+ );
+}
+
+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;
--- /dev/null
+.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
--- /dev/null
+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 <Linkify options={{ target: { url: '_blank' } }}>{content}</Linkify>;
+}
+
+function ChannelTile({
+ avatarSrc, name, id,
+ inviterName, memberCount, desc, options,
+}) {
+ return (
+ <div className="channel-tile">
+ <div className="channel-tile__avatar">
+ <Avatar
+ imageSrc={avatarSrc}
+ bgColor={colorMXID(id)}
+ text={name.slice(0, 1)}
+ />
+ </div>
+ <div className="channel-tile__content">
+ <Text variant="s1">{name}</Text>
+ <Text variant="b3">
+ {
+ inviterName !== null
+ ? `Invited by ${inviterName} to ${id}${memberCount === null ? '' : ` • ${memberCount} members`}`
+ : id + (memberCount === null ? '' : ` • ${memberCount} members`)
+ }
+ </Text>
+ {
+ desc !== null && (typeof desc === 'string')
+ ? <Text className="channel-tile__content__desc" variant="b2">{linkifyContent(desc)}</Text>
+ : desc
+ }
+ </div>
+ { options !== null && (
+ <div className="channel-tile__options">
+ {options}
+ </div>
+ )}
+ </div>
+ );
+}
+
+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;
--- /dev/null
+.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
--- /dev/null
+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 (
+ <div className="file-header">
+ <Text className="file-name" variant="b3">{name}</Text>
+ { link !== null && (
+ <>
+ {
+ external && (
+ <IconButton
+ size="extra-small"
+ tooltip="Open in new tab"
+ src={ExternalSVG}
+ onClick={() => window.open(url || link)}
+ />
+ )
+ }
+ <a href={url || link} download={name} target="_blank" rel="noreferrer">
+ <IconButton
+ size="extra-small"
+ tooltip="Download"
+ src={DownloadSVG}
+ onClick={handleDownload}
+ />
+ </a>
+ </>
+ )}
+ </div>
+ );
+}
+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 (
+ <div className="file-container">
+ <FileHeader name={name} link={link} file={file} type={type} />
+ </div>
+ );
+}
+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 (
+ <div className="file-container">
+ <FileHeader name={name} link={url || link} type={type} external />
+ <div style={{ height: width !== null ? getNativeHeight(width, height) : 'unset' }} className="image-container">
+ { url !== null && <img src={url || link} alt={name} />}
+ </div>
+ </div>
+ );
+}
+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 (
+ <div className="file-container">
+ <FileHeader name={name} link={file !== null ? url : url || link} type={type} external />
+ <div className="audio-container">
+ { url === null && isLoading && <Spinner size="small" /> }
+ { url === null && !isLoading && <IconButton onClick={handlePlayAudio} tooltip="Play audio" src={PlaySVG} />}
+ { url !== null && (
+ /* eslint-disable-next-line jsx-a11y/media-has-caption */
+ <audio autoPlay controls>
+ <source src={url} type={getBlobSafeMimeType(type)} />
+ </audio>
+ )}
+ </div>
+ </div>
+ );
+}
+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 (
+ <div className="file-container">
+ <FileHeader name={name} link={file !== null ? url : url || link} type={type} external />
+ <div
+ style={{
+ height: width !== null ? getNativeHeight(width, height) : 'unset',
+ backgroundImage: thumbUrl === null ? 'none' : `url(${thumbUrl}`,
+ }}
+ className="video-container"
+ >
+ { url === null && isLoading && <Spinner size="small" /> }
+ { url === null && !isLoading && <IconButton onClick={handlePlayVideo} tooltip="Play video" src={PlaySVG} />}
+ { url !== null && (
+ /* eslint-disable-next-line jsx-a11y/media-has-caption */
+ <video autoPlay controls poster={thumbUrl}>
+ <source src={url} type={getBlobSafeMimeType(type)} />
+ </video>
+ )}
+ </div>
+ </div>
+ );
+}
+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,
+};
--- /dev/null
+.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
--- /dev/null
+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 ? (
+ <SyntaxHighlighter
+ style={coy}
+ language={match[1]}
+ PreTag="div"
+ showLineNumbers
+ >
+ {String(children).replace(/\n$/, '')}
+ </SyntaxHighlighter>
+ ) : (
+ <code className={className}>{String(children)}</code>
+ );
+ },
+};
+
+function linkifyContent(content) {
+ return <Linkify options={{ target: { url: '_blank' } }}>{content}</Linkify>;
+}
+function genMarkdown(content) {
+ return <ReactMarkdown remarkPlugins={[gfm]} components={components} linkTarget="_blank">{content}</ReactMarkdown>;
+}
+
+function PlaceholderMessage() {
+ return (
+ <div className="ph-msg">
+ <div className="ph-msg__avatar-container">
+ <div className="ph-msg__avatar" />
+ </div>
+ <div className="ph-msg__main-container">
+ <div className="ph-msg__header" />
+ <div className="ph-msg__content">
+ <div />
+ <div />
+ <div />
+ <div />
+ </div>
+ </div>
+ </div>
+ );
+}
+
+function Message({
+ color, avatarSrc, name, content,
+ time, markdown, contentOnly, reply,
+ edited, reactions,
+}) {
+ const msgClass = contentOnly ? 'message--content-only' : 'message--full';
+ return (
+ <div className={`message ${msgClass}`}>
+ <div className="message__avatar-container">
+ {!contentOnly && <Avatar imageSrc={avatarSrc} text={name.slice(0, 1)} bgColor={color} size="small" />}
+ </div>
+ <div className="message__main-container">
+ { !contentOnly && (
+ <div className="message__header">
+ <div style={{ color }} className="message__profile">
+ <Text variant="b1">{name}</Text>
+ </div>
+ <div className="message__time">
+ <Text variant="b3">{time}</Text>
+ </div>
+ </div>
+ )}
+ <div className="message__content">
+ { reply !== null && (
+ <div className="message__reply-content">
+ <Text variant="b2">
+ <RawIcon color={reply.color} size="extra-small" src={ReplyArrowIC} />
+ <span style={{ color: reply.color }}>{reply.to}</span>
+ <>{` ${reply.content}`}</>
+ </Text>
+ </div>
+ )}
+ <div className="text text-b1">
+ { markdown ? genMarkdown(content) : linkifyContent(content) }
+ </div>
+ { edited && <Text className="message__edited" variant="b3">(edited)</Text>}
+ { reactions && (
+ <div className="message__reactions text text-b3 noselect">
+ {
+ reactions.map((reaction) => (
+ <button key={reaction.id} onClick={() => alert('Sending reactions is yet to be implemented.')} type="button" className={`msg__reaction${reaction.active ? ' msg__reaction--active' : ''}`}>
+ {`${reaction.key} ${reaction.count}`}
+ </button>
+ ))
+ }
+ </div>
+ )}
+ </div>
+ </div>
+ </div>
+ );
+}
+
+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 };
--- /dev/null
+@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
--- /dev/null
+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 (
+ <div className="timeline-change">
+ <div className="timeline-change__avatar-container">
+ <RawIcon src={iconSrc} size="extra-small" />
+ </div>
+ <div className="timeline-change__content">
+ <Text variant="b2">
+ {content}
+ {/* <Linkify options={{ target: { url: '_blank' } }}>{content}</Linkify> */}
+ </Text>
+ </div>
+ <div className="timeline-change__time">
+ <Text variant="b3">{time}</Text>
+ </div>
+ </div>
+ );
+}
+
+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;
--- /dev/null
+.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
--- /dev/null
+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 (
+ <div className="people-selector__container">
+ <button
+ className="people-selector"
+ onMouseUp={(e) => blurOnBubbling(e, '.people-selector')}
+ onClick={onClick}
+ type="button"
+ >
+ <Avatar imageSrc={avatarSrc} text={name.slice(0, 1)} bgColor={color} size="extra-small" />
+ <Text className="people-selector__name" variant="b1">{name}</Text>
+ {peopleRole !== null && <Text className="people-selector__role" variant="b3">{peopleRole}</Text>}
+ </button>
+ </div>
+ );
+}
+
+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;
--- /dev/null
+.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
--- /dev/null
+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 (
+ <div className={`pw-content-selector${pwcsClass}`}>
+ <MenuItem
+ variant={variant}
+ iconSrc={iconSrc}
+ type={type}
+ onClick={onClick}
+ >
+ {children}
+ </MenuItem>
+ </div>
+ );
+}
+
+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 (
+ <RawModal
+ className={`${className === null ? '' : `${className} `}pw-model`}
+ isOpen={isOpen}
+ onRequestClose={onRequestClose}
+ size={haveDrawer ? 'large' : 'medium'}
+ >
+ <div className="pw">
+ {haveDrawer && (
+ <div className="pw__drawer">
+ <Header>
+ <IconButton size="small" src={ChevronLeftIC} onClick={onRequestClose} tooltip="Back" />
+ <TitleWrapper>
+ <Text variant="s1">{title}</Text>
+ </TitleWrapper>
+ {drawerOptions}
+ </Header>
+ <div className="pw__drawer__content__wrapper">
+ <ScrollView invisible>
+ <div className="pw__drawer__content">
+ {drawer}
+ </div>
+ </ScrollView>
+ </div>
+ </div>
+ )}
+ <div className="pw__content">
+ <Header>
+ <TitleWrapper>
+ <Text variant="h2">{contentTitle !== null ? contentTitle : title}</Text>
+ </TitleWrapper>
+ {contentOptions}
+ </Header>
+ <div className="pw__content__wrapper">
+ <ScrollView autoHide>
+ <div className="pw__content-container">
+ {children}
+ </div>
+ </ScrollView>
+ </div>
+ </div>
+ </div>
+ </RawModal>
+ );
+}
+
+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 };
--- /dev/null
+.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
--- /dev/null
+import React from 'react';
+import PropTypes from 'prop-types';
+import './SettingTile.scss';
+
+import Text from '../../atoms/text/Text';
+
+function SettingTile({ title, options, content }) {
+ return (
+ <div className="setting-tile">
+ <div className="setting-tile__title__wrapper">
+ <div className="setting-tile__title">
+ <Text variant="b1">{title}</Text>
+ </div>
+ {options !== null && <div className="setting-tile__options">{options}</div>}
+ </div>
+ {content !== null && <div className="setting-tile__content">{content}</div>}
+ </div>
+ );
+}
+
+SettingTile.defaultProps = {
+ options: null,
+ content: null,
+};
+
+SettingTile.propTypes = {
+ title: PropTypes.string.isRequired,
+ options: PropTypes.node,
+ content: PropTypes.node,
+};
+
+export default SettingTile;
--- /dev/null
+.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
--- /dev/null
+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 (
+ <Tippy
+ content={<Text variant="b1">{tooltip}</Text>}
+ className="sidebar-avatar-tippy"
+ touch="hold"
+ arrow={false}
+ placement="right"
+ maxWidth={200}
+ delay={[0, 0]}
+ duration={[100, 0]}
+ offset={[0, 0]}
+ >
+ <button
+ ref={ref}
+ className={`sidebar-avatar${activeClass}`}
+ type="button"
+ onMouseUp={(e) => blurOnBubbling(e, '.sidebar-avatar')}
+ onClick={onClick}
+ >
+ <Avatar
+ text={text}
+ bgColor={bgColor}
+ imageSrc={imageSrc}
+ iconSrc={iconSrc}
+ size="normal"
+ />
+ { notifyCount !== null && <NotificationBadge alert>{notifyCount}</NotificationBadge> }
+ </button>
+ </Tippy>
+ );
+});
+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;
--- /dev/null
+
+.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
--- /dev/null
+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 <Welcome />;
+
+ return (
+ <div className="channel-container">
+ <ChannelView roomId={selectedRoomId} />
+ { isDrawerVisible && <PeopleDrawer roomId={selectedRoomId} />}
+ </div>
+ );
+}
+
+export default Channel;
--- /dev/null
+.channel-container {
+ display: flex;
+ height: 100%;
+}
\ No newline at end of file
--- /dev/null
+/* 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 (
+ <>
+ <b>{user}</b>
+ {' joined the channel'}
+ </>
+ );
+ },
+ leave(user) {
+ return (
+ <>
+ <b>{user}</b>
+ {' left the channel'}
+ </>
+ );
+ },
+ invite(inviter, user) {
+ return (
+ <>
+ <b>{inviter}</b>
+ {' invited '}
+ <b>{user}</b>
+ </>
+ );
+ },
+ cancelInvite(inviter, user) {
+ return (
+ <>
+ <b>{inviter}</b>
+ {' canceled '}
+ <b>{user}</b>
+ {'\'s invite'}
+ </>
+ );
+ },
+ rejectInvite(user) {
+ return (
+ <>
+ <b>{user}</b>
+ {' rejected the invitation'}
+ </>
+ );
+ },
+ kick(actor, user, reason) {
+ const reasonMsg = (typeof reason === 'string') ? ` for ${reason}` : '';
+ return (
+ <>
+ <b>{actor}</b>
+ {' kicked '}
+ <b>{user}</b>
+ {reasonMsg}
+ </>
+ );
+ },
+ ban(actor, user, reason) {
+ const reasonMsg = (typeof reason === 'string') ? ` for ${reason}` : '';
+ return (
+ <>
+ <b>{actor}</b>
+ {' banned '}
+ <b>{user}</b>
+ {reasonMsg}
+ </>
+ );
+ },
+ unban(actor, user) {
+ return (
+ <>
+ <b>{actor}</b>
+ {' unbanned '}
+ <b>{user}</b>
+ </>
+ );
+ },
+ avatarSets(user) {
+ return (
+ <>
+ <b>{user}</b>
+ {' set the avatar'}
+ </>
+ );
+ },
+ avatarChanged(user) {
+ return (
+ <>
+ <b>{user}</b>
+ {' changed the avatar'}
+ </>
+ );
+ },
+ avatarRemoved(user) {
+ return (
+ <>
+ <b>{user}</b>
+ {' removed the avatar'}
+ </>
+ );
+ },
+ nameSets(user, newName) {
+ return (
+ <>
+ <b>{user}</b>
+ {' set the display name to '}
+ <b>{newName}</b>
+ </>
+ );
+ },
+ nameChanged(user, newName) {
+ return (
+ <>
+ <b>{user}</b>
+ {' changed the display name to '}
+ <b>{newName}</b>
+ </>
+ );
+ },
+ nameRemoved(user, lastName) {
+ return (
+ <>
+ <b>{user}</b>
+ {' removed the display name '}
+ <b>{lastName}</b>
+ </>
+ );
+ },
+ };
+}
+
+function getUsersActionJsx(userIds, actionStr) {
+ const getUserJSX = (username) => <b>{getUsername(username)}</b>;
+ 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 (
+ <Header>
+ <Avatar imageSrc={avatarSrc} text={roomName.slice(0, 1)} bgColor={colorMXID(roomName)} size="small" />
+ <TitleWrapper>
+ <Text variant="h2">{roomName}</Text>
+ { typeof roomTopic !== 'undefined' && <p title={roomTopic} className="text text-b3">{roomTopic}</p>}
+ </TitleWrapper>
+ <IconButton onClick={togglePeopleDrawer} tooltip="People" src={UserIC} />
+ <ContextMenu
+ placement="bottom"
+ content={(toogleMenu) => (
+ <>
+ <MenuHeader>Options</MenuHeader>
+ {/* <MenuBorder /> */}
+ <MenuItem
+ iconSrc={AddUserIC}
+ onClick={() => {
+ openInviteUser(roomId); toogleMenu();
+ }}
+ >
+ Invite
+ </MenuItem>
+ <MenuItem iconSrc={LeaveArrowIC} variant="danger" onClick={() => roomActions.leave(roomId, isDM)}>Leave</MenuItem>
+ </>
+ )}
+ render={(toggleMenu) => <IconButton onClick={toggleMenu} tooltip="Options" src={VerticalMenuIC} />}
+ />
+ </Header>
+ );
+}
+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 (
+ <Media.File
+ name={mContent.body}
+ link={mx.mxcUrlToHttp(mediaMXC)}
+ file={mContent.file}
+ type={mContent.info.mimetype}
+ />
+ );
+ case 'm.image':
+ return (
+ <Media.Image
+ name={mContent.body}
+ width={mContent.info.w || null}
+ height={mContent.info.h || null}
+ link={mx.mxcUrlToHttp(mediaMXC)}
+ file={isEncryptedFile ? mContent.file : null}
+ type={mContent.info.mimetype}
+ />
+ );
+ case 'm.audio':
+ return (
+ <Media.Audio
+ name={mContent.body}
+ link={mx.mxcUrlToHttp(mediaMXC)}
+ type={mContent.info.mimetype}
+ file={mContent.file}
+ />
+ );
+ case 'm.video':
+ if (typeof thumbnailMXC === 'undefined') {
+ thumbnailMXC = mContent.info?.thumbnail_file?.url || null;
+ }
+ return (
+ <Media.Video
+ name={mContent.body}
+ link={mx.mxcUrlToHttp(mediaMXC)}
+ thumbnail={thumbnailMXC === null ? null : mx.mxcUrlToHttp(thumbnailMXC)}
+ thumbnailFile={isEncryptedFile ? mContent.info.thumbnail_file : null}
+ thumbnailType={mContent.info.thumbnail_info?.mimetype || null}
+ width={mContent.info.w || null}
+ height={mContent.info.h || null}
+ file={isEncryptedFile ? mContent.file : null}
+ type={mContent.info.mimetype}
+ />
+ );
+ 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 (
+ <ChannelIntro
+ key={mEvent.getId()}
+ avatarSrc={roomTimeline.room.getAvatarUrl(initMatrix.matrixClient.baseUrl, 80, 80, 'crop')}
+ name={roomTimeline.room.name}
+ heading={`Welcome to ${roomTimeline.room.name}`}
+ desc={`This is the beginning of ${roomTimeline.room.name} channel.${typeof roomTopic !== 'undefined' ? (` Topic: ${roomTopic}`) : ''}`}
+ time={`Created at ${dateFormat(mEvent.getDate(), 'dd mmmm yyyy, hh:MM TT')}`}
+ />
+ );
+ }
+ 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 = <Divider key={`divider-${mEvent.getId()}`} text={`${dateFormat(mEvent.getDate(), 'mmmm dd, yyyy')}`} />;
+ }
+
+ 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 = (
+ <React.Fragment key={`box-${mEvent.getId()}`}>
+ {divider}
+ { isMedia(mEvent) ? (
+ <Message
+ key={mEvent.getId()}
+ contentOnly={isContentOnly}
+ markdown={isMarkdown}
+ avatarSrc={mEvent.sender.getAvatarUrl(initMatrix.matrixClient.baseUrl, 36, 36, 'crop')}
+ color={colorMXID(mEvent.sender.userId)}
+ name={getUsername(mEvent.sender.userId)}
+ content={genMediaContent(mEvent)}
+ reply={reply}
+ time={`${dateFormat(mEvent.getDate(), 'hh:MM TT')}`}
+ edited={isEdited}
+ reactions={reactions}
+ />
+ ) : (
+ <Message
+ key={mEvent.getId()}
+ contentOnly={isContentOnly}
+ markdown={isMarkdown}
+ avatarSrc={mEvent.sender.getAvatarUrl(initMatrix.matrixClient.baseUrl, 36, 36, 'crop')}
+ color={colorMXID(mEvent.sender.userId)}
+ name={getUsername(mEvent.sender.userId)}
+ content={content}
+ reply={reply}
+ time={`${dateFormat(mEvent.getDate(), 'hh:MM TT')}`}
+ edited={isEdited}
+ reactions={reactions}
+ />
+ )}
+ </React.Fragment>
+ );
+
+ prevMEvent = mEvent;
+ return myMessageEl;
+ }
+ prevMEvent = mEvent;
+ const timelineChange = parseTimelineChange(mEvent);
+ if (timelineChange === null) return null;
+ return (
+ <React.Fragment key={`box-${mEvent.getId()}`}>
+ {divider}
+ <TimelineChange
+ key={mEvent.getId()}
+ variant={timelineChange.variant}
+ content={timelineChange.content}
+ time={`${dateFormat(mEvent.getDate(), 'hh:MM TT')}`}
+ />
+ </React.Fragment>
+ );
+ }
+
+ const roomTopic = roomTimeline.room.currentState.getStateEvents('m.room.topic')[0]?.getContent().topic;
+ return (
+ <div className="channel-view__content">
+ <div className="timeline__wrapper">
+ {
+ roomTimeline.timeline[0].getType() !== 'm.room.create' && !isReachedTimelineEnd && (
+ <>
+ <PlaceholderMessage key={Math.random().toString(20).substr(2, 6)} />
+ <PlaceholderMessage key={Math.random().toString(20).substr(2, 6)} />
+ <PlaceholderMessage key={Math.random().toString(20).substr(2, 6)} />
+ </>
+ )
+ }
+ {
+ roomTimeline.timeline[0].getType() !== 'm.room.create' && isReachedTimelineEnd && (
+ <ChannelIntro
+ key={Math.random().toString(20).substr(2, 6)}
+ avatarSrc={roomTimeline.room.getAvatarUrl(initMatrix.matrixClient.baseUrl, 80, 80, 'crop')}
+ name={roomTimeline.room.name}
+ heading={`Welcome to ${roomTimeline.room.name}`}
+ desc={`This is the beginning of ${roomTimeline.room.name} channel.${typeof roomTopic !== 'undefined' ? (` Topic: ${roomTopic}`) : ''}`}
+ />
+ )
+ }
+ { roomTimeline.timeline.map(renderMessage) }
+ </div>
+ </div>
+ );
+}
+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 (
+ <>
+ <div className={`channel-view__typing${isSomeoneTyping(typingMembers) ? ' channel-view__typing--open' : ''}`}>
+ <div className="bouncingLoader"><div /></div>
+ <Text variant="b2">{getTypingMessage(typingMembers)}</Text>
+ </div>
+ <div className={`channel-view__STB${reachedBottom ? '' : ' channel-view__STB--open'}`}>
+ <IconButton
+ onClick={() => {
+ timelineScroll.enableSmoothScroll();
+ timelineScroll.reachBottom();
+ timelineScroll.disableSmoothScroll();
+ }}
+ src={ChevronBottomIC}
+ tooltip="Scroll to Bottom"
+ />
+ </div>
+ </>
+ );
+}
+FloatingOptions.propTypes = {
+ roomId: PropTypes.string.isRequired,
+ roomTimeline: PropTypes.shape({}).isRequired,
+ timelineScroll: PropTypes.shape({
+ reachBottom: PropTypes.func,
+ }).isRequired,
+};
+
+function ChannelViewSticky({ children }) {
+ return <div className="channel-view__sticky">{children}</div>;
+}
+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 (
+ <>
+ <div className={`channel-input__option-container${attachment === null ? '' : ' channel-attachment__option'}`}>
+ <input onChange={uploadFileChange} style={{ display: 'none' }} ref={uploadInputRef} type="file" />
+ <IconButton onClick={handleUploadClick} tooltip={attachment === null ? 'Upload' : 'Cancel'} src={CirclePlusIC} />
+ </div>
+ <div ref={inputBaseRef} className="channel-input__input-container">
+ {roomTimeline.isEncryptedRoom() && <RawIcon size="extra-small" src={ShieldIC} />}
+ <ScrollView autoHide>
+ <Text className="channel-input__textarea-wrapper">
+ <TextareaAutosize
+ ref={textAreaRef}
+ onChange={handleMsgTyping}
+ onResize={() => timelineScroll.autoReachBottom()}
+ onKeyDown={handleKeyDown}
+ placeholder="Send a message..."
+ />
+ </Text>
+ </ScrollView>
+ </div>
+ <div className="channel-input__option-container">
+ <ContextMenu
+ placement="top"
+ content={(
+ <EmojiBoard onSelect={addEmoji} />
+ )}
+ render={(toggleMenu) => <IconButton onClick={toggleMenu} tooltip="Emoji" src={EmojiIC} />}
+ />
+ <IconButton onClick={sendMessage} tooltip="Send" src={SendIC} />
+ </div>
+ </>
+ );
+ }
+
+ function attachFile() {
+ const fileType = attachment.type.slice(0, attachment.type.indexOf('/'));
+ return (
+ <div className="channel-attachment">
+ <div className={`channel-attachment__preview${fileType !== 'image' ? ' channel-attachment__icon' : ''}`}>
+ {fileType === 'image' && <img alt={attachment.name} src={URL.createObjectURL(attachment)} />}
+ {fileType === 'video' && <RawIcon src={VLCIC} />}
+ {fileType === 'audio' && <RawIcon src={VolumeFullIC} />}
+ {fileType !== 'image' && fileType !== 'video' && fileType !== 'audio' && <RawIcon src={FileIC} />}
+ </div>
+ <div className="channel-attachment__info">
+ <Text variant="b1">{attachment.name}</Text>
+ <Text variant="b3"><span ref={uploadProgressRef}>{`size: ${bytesToSize(attachment.size)}`}</span></Text>
+ </div>
+ </div>
+ );
+ }
+
+ return (
+ <>
+ { attachment !== null && attachFile() }
+ <form className="channel-input" onSubmit={(e) => { e.preventDefault(); }}>
+ {
+ roomTimeline.room.isSpaceRoom()
+ ? <Text className="channel-input__space" variant="b1">Spaces are yet to be implemented</Text>
+ : renderInputs()
+ }
+ </form>
+ </>
+ );
+}
+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 (
+ <div className="channel-cmd-bar">
+ {
+ followingMembers.length !== 0 && (
+ <TimelineChange
+ variant="follow"
+ content={getUsersActionJsx(followingMembers, 'following the conversation.')}
+ time=""
+ />
+ )
+ }
+ </div>
+ );
+}
+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 (
+ <div className="channel-view">
+ <ChannelViewHeader roomId={roomId} />
+ <div className="channel-view__content-wrapper">
+ <div className="channel-view__scrollable">
+ <ScrollView onScroll={onTimelineScroll} ref={timelineSVRef} autoHide>
+ {roomTimeline !== null && (
+ <ChannelViewContent
+ roomId={roomId}
+ roomTimeline={roomTimeline}
+ timelineScroll={timelineScroll}
+ />
+ )}
+ </ScrollView>
+ {roomTimeline !== null && (
+ <FloatingOptions
+ roomId={roomId}
+ roomTimeline={roomTimeline}
+ timelineScroll={timelineScroll}
+ />
+ )}
+ </div>
+ {roomTimeline !== null && (
+ <ChannelViewSticky>
+ <ChannelInput
+ roomId={roomId}
+ roomTimeline={roomTimeline}
+ timelineScroll={timelineScroll}
+ />
+ <ChannelCmdBar
+ roomId={roomId}
+ roomTimeline={roomTimeline}
+ />
+ </ChannelViewSticky>
+ )}
+ </div>
+ </div>
+ );
+}
+ChannelView.propTypes = {
+ roomId: PropTypes.string.isRequired,
+};
+
+export default ChannelView;
--- /dev/null
+.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
--- /dev/null
+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 (
+ <div className="people-drawer">
+ <Header>
+ <TitleWrapper>
+ <Text variant="s1">
+ People
+ <Text className="people-drawer__member-count" variant="b3">{`${room.getJoinedMemberCount()} members`}</Text>
+ </Text>
+ </TitleWrapper>
+ <IconButton onClick={() => openInviteUser(roomId)} tooltip="Invite" src={AddUserIC} />
+ </Header>
+ <div className="people-drawer__content-wrapper">
+ <div className="people-drawer__scrollable">
+ <ScrollView autoHide>
+ <div className="people-drawer__content">
+ {
+ memberList.map((member) => (
+ <PeopleSelector
+ key={member.userId}
+ onClick={() => 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)}
+ />
+ ))
+ }
+ <div className="people-drawer__load-more">
+ {
+ memberList.length !== totalMemberList.length && (
+ <Button onClick={loadMorePeople}>View more</Button>
+ )
+ }
+ </div>
+ </div>
+ </ScrollView>
+ </div>
+ <div className="people-drawer__sticky">
+ <form onSubmit={(e) => e.preventDefault()} className="people-search">
+ <Input type="text" placeholder="Search" required />
+ </form>
+ </div>
+ </div>
+ </div>
+ );
+}
+
+PeopleDrawer.propTypes = {
+ roomId: PropTypes.string.isRequired,
+};
+
+export default PeopleDrawer;
--- /dev/null
+.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
--- /dev/null
+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 (
+ <PopupWindow
+ isOpen={isOpen}
+ title="Create channel"
+ contentOptions={<IconButton src={CrossIC} onClick={onRequestClose} tooltip="Close" />}
+ onRequestClose={onRequestClose}
+ >
+ <div className="create-channel">
+ <form className="create-channel__form" onSubmit={(e) => { e.preventDefault(); createRoom(); }}>
+ <SettingTile
+ title="Make channel public"
+ options={<Toggle isActive={isPublic} onToggle={togglePublic} />}
+ content={<Text variant="b3">Public channel can be joined by anyone.</Text>}
+ />
+ {isPublic && (
+ <div>
+ <Text className="create-channel__address__label" variant="b2">Channel address</Text>
+ <div className="create-channel__address">
+ <Text variant="b1">#</Text>
+ <Input value={addressValue} onChange={validateAddress} state={(isValidAddress === false) ? 'error' : 'normal'} forwardRef={addressRef} placeholder="my_room" required />
+ <Text variant="b1">{hsString}</Text>
+ </div>
+ {isValidAddress === false && <Text className="create-channel__address__tip" variant="b3"><span style={{ color: 'var(--bg-danger)' }}>{`#${addressValue}${hsString} is already in use`}</span></Text>}
+ </div>
+ )}
+ {!isPublic && (
+ <SettingTile
+ title="Enable end-to-end encryption"
+ options={<Toggle isActive={isEncrypted} onToggle={toggleEncrypted} />}
+ content={<Text variant="b3">You can’t disable this later. Bridges & most bots won’t work yet.</Text>}
+ />
+ )}
+ <Input value={topicValue} onChange={handleTopicChange} forwardRef={topicRef} minHeight={174} resizable label="Topic (optional)" />
+ <div className="create-channel__name-wrapper">
+ <Input value={titleValue} onChange={handleTitleChange} forwardRef={nameRef} label="Channel name" required />
+ <Button disabled={isValidAddress === false || isCreatingRoom} iconSrc={HashPlusIC} type="submit" variant="primary">Create</Button>
+ </div>
+ {isCreatingRoom && (
+ <div className="create-channel__loading">
+ <Spinner size="small" />
+ <Text>Creating channel...</Text>
+ </div>
+ )}
+ {typeof creatingError === 'string' && <Text className="create-channel__error" variant="b3">{creatingError}</Text>}
+ </form>
+ </div>
+ </PopupWindow>
+ );
+}
+
+CreateChannel.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onRequestClose: PropTypes.func.isRequired,
+};
+
+export default CreateChannel;
--- /dev/null
+.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
--- /dev/null
+/* 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(
+ <span key={emojiIndex}>
+ {
+ parse(twemoji.parse(
+ emoji.unicode,
+ {
+ attributes: () => ({
+ unicode: emoji.unicode,
+ shortcodes: emoji.shortcodes?.toString(),
+ }),
+ },
+ ))
+ }
+ </span>,
+ );
+ }
+ emojiRows.push(<div key={r} className="emoji-row">{emojiRow}</div>);
+ }
+ return emojiRows;
+ }
+
+ return (
+ <div className="emoji-group">
+ <Text className="emoji-group__header" variant="b2">{name}</Text>
+ <div className="emoji-set">{getEmojiBoard()}</div>
+ </div>
+ );
+}
+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 && <EmojiGroup key="-1" name="Search results" emojis={searchedEmojis} />;
+}
+
+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 (
+ <div id="emoji-board" className="emoji-board">
+ <div className="emoji-board__content">
+ <div className="emoji-board__emojis">
+ <ScrollView ref={scrollEmojisRef} autoHide>
+ <div onMouseMove={hoverEmoji} onClick={selectEmoji}>
+ <SearchedEmoji />
+ {
+ emojiGroups.map((group) => (
+ <EmojiGroup key={group.name} name={group.name} emojis={group.emojis} />
+ ))
+ }
+ </div>
+ </ScrollView>
+ </div>
+ <div className="emoji-board__search">
+ <RawIcon size="small" src={SearchIC} />
+ <Input onChange={handleSearchChange} forwardRef={searchRef} placeholder="Search" />
+ </div>
+ </div>
+ <div className="emoji-board__nav">
+ <IconButton onClick={() => openGroup(0)} src={EmojiIC} tooltip="Smileys" tooltipPlacement="right" />
+ <IconButton onClick={() => openGroup(1)} src={DogIC} tooltip="Animals" tooltipPlacement="right" />
+ <IconButton onClick={() => openGroup(2)} src={CupIC} tooltip="Food" tooltipPlacement="right" />
+ <IconButton onClick={() => openGroup(3)} src={BallIC} tooltip="Activity" tooltipPlacement="right" />
+ <IconButton onClick={() => openGroup(4)} src={PhotoIC} tooltip="Travel" tooltipPlacement="right" />
+ <IconButton onClick={() => openGroup(5)} src={BulbIC} tooltip="Objects" tooltipPlacement="right" />
+ <IconButton onClick={() => openGroup(6)} src={PeaceIC} tooltip="Symbols" tooltipPlacement="right" />
+ <IconButton onClick={() => openGroup(7)} src={FlagIC} tooltip="Flags" tooltipPlacement="right" />
+ </div>
+ </div>
+ );
+}
+
+EmojiBoard.propTypes = {
+ onSelect: PropTypes.func.isRequired,
+};
+
+export default EmojiBoard;
--- /dev/null
+.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
--- /dev/null
+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,
+};
--- /dev/null
+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 (
+ <ChannelTile
+ key={myRoom.roomId}
+ name={roomName}
+ avatarSrc={initMatrix.matrixClient.getRoom(roomId).getAvatarUrl(initMatrix.matrixClient.baseUrl, 42, 42, 'crop')}
+ id={roomAlias}
+ inviterName={myRoom.getJoinedMembers()[0].userId}
+ options={
+ procInvite.has(myRoom.roomId)
+ ? (<Spinner size="small" />)
+ : (
+ <div className="invite-btn__container">
+ <Button onClick={() => rejectInvite(myRoom.roomId)}>Reject</Button>
+ <Button onClick={() => acceptInvite(myRoom.roomId)} variant="primary">Accept</Button>
+ </div>
+ )
+ }
+ />
+ );
+ }
+
+ return (
+ <PopupWindow
+ isOpen={isOpen}
+ title="Invites"
+ contentOptions={<IconButton src={CrossIC} onClick={onRequestClose} tooltip="Close" />}
+ onRequestClose={onRequestClose}
+ >
+ <div className="invites-content">
+ { initMatrix.roomList.inviteDirects.size !== 0 && (
+ <div className="invites-content__subheading">
+ <Text variant="b3">Direct Messages</Text>
+ </div>
+ )}
+ {
+ Array.from(initMatrix.roomList.inviteDirects).map((roomId) => {
+ const myRoom = initMatrix.matrixClient.getRoom(roomId);
+ const roomName = myRoom.name;
+ return (
+ <ChannelTile
+ key={myRoom.roomId}
+ name={roomName}
+ id={myRoom.getDMInviter()}
+ options={
+ procInvite.has(myRoom.roomId)
+ ? (<Spinner size="small" />)
+ : (
+ <div className="invite-btn__container">
+ <Button onClick={() => rejectInvite(myRoom.roomId, true)}>Reject</Button>
+ <Button onClick={() => acceptInvite(myRoom.roomId, true)} variant="primary">Accept</Button>
+ </div>
+ )
+ }
+ />
+ );
+ })
+ }
+ { initMatrix.roomList.inviteSpaces.size !== 0 && (
+ <div className="invites-content__subheading">
+ <Text variant="b3">Spaces</Text>
+ </div>
+ )}
+ { Array.from(initMatrix.roomList.inviteSpaces).map(renderChannelTile) }
+
+ { initMatrix.roomList.inviteRooms.size !== 0 && (
+ <div className="invites-content__subheading">
+ <Text variant="b3">Channels</Text>
+ </div>
+ )}
+ { Array.from(initMatrix.roomList.inviteRooms).map(renderChannelTile) }
+ </div>
+ </PopupWindow>
+ );
+}
+
+InviteList.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onRequestClose: PropTypes.func.isRequired,
+};
+
+export default InviteList;
--- /dev/null
+.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
--- /dev/null
+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) => <Text variant="b2"><span style={{ color: isPositive ? 'var(--bg-positive)' : 'var(--bg-negative)' }}>{message}</span></Text>;
+
+ if (mx.getUserId() === userId) return null;
+ if (procUsers.has(userId)) {
+ return <Spinner size="small" />;
+ }
+ if (createdDM.has(userId)) {
+ // eslint-disable-next-line max-len
+ return <Button onClick={() => { selectRoom(createdDM.get(userId)); onRequestClose(); }}>Open</Button>;
+ }
+ 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')
+ ? <Button onClick={() => inviteToRoom(userId)} variant="primary">Invite</Button>
+ : <Button onClick={() => createDM(userId)} variant="primary">Message</Button>;
+ };
+ const renderError = (userId) => {
+ if (!procUserError.has(userId)) return null;
+ return <Text variant="b2"><span style={{ color: 'var(--bg-danger)' }}>{procUserError.get(userId)}</span></Text>;
+ };
+
+ return users.map((user) => {
+ const userId = user.user_id;
+ const name = typeof user.display_name === 'string' ? user.display_name : userId;
+ return (
+ <ChannelTile
+ key={userId}
+ avatarSrc={typeof user.avatar_url === 'string' ? mx.mxcUrlToHttp(user.avatar_url, 42, 42, 'crop') : null}
+ name={name}
+ id={userId}
+ options={renderOptions(userId)}
+ desc={renderError(userId)}
+ />
+ );
+ });
+ }
+
+ return (
+ <PopupWindow
+ isOpen={isOpen}
+ title={(typeof roomId === 'string' ? `Invite to ${mx.getRoom(roomId).name}` : 'Direct message')}
+ contentOptions={<IconButton src={CrossIC} onClick={onRequestClose} tooltip="Close" />}
+ onRequestClose={onRequestClose}
+ >
+ <div className="invite-user">
+ <form className="invite-user__form" onSubmit={(e) => { e.preventDefault(); searchUser(); }}>
+ <Input forwardRef={usernameRef} label="Username or userId" />
+ <Button disabled={isSearching} iconSrc={UserIC} variant="primary" type="submit">Search</Button>
+ </form>
+ <div className="invite-user__search-status">
+ {
+ typeof searchQuery.username !== 'undefined' && isSearching && (
+ <div className="flex--center">
+ <Spinner size="small" />
+ <Text variant="b2">{`Searching for user "${searchQuery.username}"...`}</Text>
+ </div>
+ )
+ }
+ {
+ typeof searchQuery.username !== 'undefined' && !isSearching && (
+ <Text variant="b2">{`Search result for user "${searchQuery.username}"`}</Text>
+ )
+ }
+ {
+ searchQuery.error && <Text className="invite-user__search-error" variant="b2">{searchQuery.error}</Text>
+ }
+ </div>
+ { users.length !== 0 && (
+ <div className="invite-user__content">
+ {renderUserList()}
+ </div>
+ )}
+ </div>
+ </PopupWindow>
+ );
+}
+
+InviteUser.defaultProps = {
+ roomId: undefined,
+};
+
+InviteUser.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ roomId: PropTypes.string,
+ onRequestClose: PropTypes.func.isRequired,
+};
+
+export default InviteUser;
--- /dev/null
+.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
--- /dev/null
+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 (
+ <Header>
+ <TitleWrapper>
+ <Text variant="s1">{(tabId === 'channels' ? 'Home' : 'Direct messages')}</Text>
+ </TitleWrapper>
+ {(tabId === 'dm')
+ ? <IconButton onClick={() => openInviteUser()} tooltip="Start DM" src={PlusIC} size="normal" />
+ : (
+ <ContextMenu
+ content={(hideMenu) => (
+ <>
+ <MenuHeader>Add channel</MenuHeader>
+ <MenuItem
+ iconSrc={HashPlusIC}
+ onClick={() => { hideMenu(); openCreateChannel(); }}
+ >
+ Create new channel
+ </MenuItem>
+ <MenuItem
+ iconSrc={HashSearchIC}
+ onClick={() => { hideMenu(); openPublicChannels(); }}
+ >
+ Add Public channel
+ </MenuItem>
+ </>
+ )}
+ render={(toggleMenu) => (<IconButton onClick={toggleMenu} tooltip="Add channel" src={PlusIC} size="normal" />)}
+ />
+ )}
+ {/* <IconButton onClick={() => ''} tooltip="Menu" src={VerticalMenuIC} size="normal" /> */}
+ </Header>
+ );
+}
+DrawerHeader.propTypes = {
+ tabId: PropTypes.string.isRequired,
+};
+
+function DrawerBradcrumb() {
+ return (
+ <div className="breadcrumb__wrapper">
+ <ScrollView horizontal vertical={false}>
+ <div>
+ {/* TODO: bradcrumb space paths when spaces become a thing */}
+ </div>
+ </ScrollView>
+ </div>
+ );
+}
+
+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 (
+ <ChannelSelector
+ key={roomId}
+ iconSrc={
+ isDM
+ ? null
+ : (() => {
+ 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}
+ </ChannelSelector>
+ );
+}
+
+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 && <Text className="cat-header" variant="b3">Spaces</Text> }
+ { spaceIds.map((id) => renderSelector(mx.getRoom(id), id, selectedRoomId === id, false)) }
+ { roomIds.length !== 0 && <Text className="cat-header" variant="b3">Channels</Text> }
+ { 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 (
+ <div className="channels-container">
+ {
+ tabId === 'channels'
+ ? <Home selectedRoomId={selectedRoomId} />
+ : <Directs selectedRoomId={selectedRoomId} />
+ }
+ </div>
+ );
+}
+Channels.propTypes = {
+ tabId: PropTypes.string.isRequired,
+};
+
+function Drawer({ tabId }) {
+ return (
+ <div className="drawer">
+ <DrawerHeader tabId={tabId} />
+ <div className="drawer__content-wrapper">
+ <DrawerBradcrumb />
+ <div className="channels__wrapper">
+ <ScrollView autoHide>
+ <Channels tabId={tabId} />
+ </ScrollView>
+ </div>
+ </div>
+ </div>
+ );
+}
+
+Drawer.propTypes = {
+ tabId: PropTypes.string.isRequired,
+};
+
+export default Drawer;
--- /dev/null
+.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
--- /dev/null
+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 (
+ <div className="navigation">
+ <SideBar tabId={activeTab} changeTab={changeTab} />
+ <Drawer tabId={activeTab} />
+ </div>
+ );
+}
+
+export default Navigation;
--- /dev/null
+.navigation {
+ width: 100%;
+ height: 100%;
+ background-color: var(--bg-surface-low);
+
+ display: flex;
+}
\ No newline at end of file
--- /dev/null
+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 (
+ <ContextMenu
+ content={(hideMenu) => (
+ <>
+ <MenuHeader>{mx.getUserId()}</MenuHeader>
+ {/* <MenuItem iconSrc={UserIC} onClick={() => ''}>Profile</MenuItem> */}
+ {/* <MenuItem iconSrc={BellIC} onClick={() => ''}>Notification settings</MenuItem> */}
+ <MenuItem
+ iconSrc={SettingsIC}
+ onClick={() => { hideMenu(); openSettings(); }}
+ >
+ Settings
+ </MenuItem>
+ <MenuBorder />
+ <MenuItem iconSrc={PowerIC} variant="danger" onClick={logout}>Logout</MenuItem>
+ </>
+ )}
+ render={(toggleMenu) => (
+ <SidebarAvatar
+ onClick={toggleMenu}
+ tooltip={mx.getUser(mx.getUserId()).displayName}
+ imageSrc={mx.getUser(mx.getUserId()).avatarUrl !== null ? mx.mxcUrlToHttp(mx.getUser(mx.getUserId()).avatarUrl, 42, 42, 'crop') : null}
+ bgColor={colorMXID(mx.getUserId())}
+ text={mx.getUser(mx.getUserId()).displayName.slice(0, 1)}
+ />
+ )}
+ />
+ );
+}
+
+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 (
+ <div className="sidebar">
+ <div className="sidebar__scrollable">
+ <ScrollView invisible>
+ <div className="scrollable-content">
+ <div className="featured-container">
+ <SidebarAvatar active={tabId === 'channels'} onClick={() => changeTab('channels')} tooltip="Home" iconSrc={HomeIC} />
+ <SidebarAvatar active={tabId === 'dm'} onClick={() => changeTab('dm')} tooltip="People" iconSrc={UserIC} />
+ <SidebarAvatar onClick={() => openPublicChannels()} tooltip="Public channels" iconSrc={HashSearchIC} />
+ </div>
+ <div className="sidebar-divider" />
+ <div className="space-container" />
+ </div>
+ </ScrollView>
+ </div>
+ <div className="sidebar__sticky">
+ <div className="sidebar-divider" />
+ <div className="sticky-container">
+ { totalInvites !== 0 && (
+ <SidebarAvatar
+ notifyCount={totalInvites}
+ onClick={() => openInviteList()}
+ tooltip="Invites"
+ iconSrc={InviteIC}
+ />
+ )}
+ <ProfileAvatarMenu />
+ </div>
+ </div>
+ </div>
+ );
+}
+
+SideBar.propTypes = {
+ tabId: PropTypes.string.isRequired,
+ changeTab: PropTypes.func.isRequired,
+};
+
+export default SideBar;
--- /dev/null
+.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
--- /dev/null
+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 (
+ <ChannelTile
+ key={channel.room_id}
+ avatarSrc={typeof channel.avatar_url === 'string' ? initMatrix.matrixClient.mxcUrlToHttp(channel.avatar_url, 42, 42, 'crop') : null}
+ name={name}
+ id={alias}
+ memberCount={channel.num_joined_members}
+ desc={typeof channel.topic === 'string' ? channel.topic : null}
+ options={(
+ <>
+ {isJoined && <Button onClick={() => handleViewChannel(channel.room_id)}>Open</Button>}
+ {!isJoined && (joiningChannels.has(channel.room_id) ? <Spinner size="small" /> : <Button onClick={() => joinChannel(channel.room_id)} variant="primary">Join</Button>)}
+ </>
+ )}
+ />
+ );
+ });
+ }
+
+ return (
+ <PopupWindow
+ isOpen={isOpen}
+ title="Public channels"
+ contentOptions={<IconButton src={CrossIC} onClick={onRequestClose} tooltip="Close" />}
+ onRequestClose={onRequestClose}
+ >
+ <div className="public-channels">
+ <form className="public-channels__form" onSubmit={(e) => { e.preventDefault(); searchChannels(); }}>
+ <div className="public-channels__input-wrapper">
+ <Input forwardRef={channelNameRef} label="Channel name" />
+ <Input forwardRef={hsRef} value={userId.slice(userId.indexOf(':') + 1)} label="Homeserver" required />
+ </div>
+ <Button disabled={isSearching} iconSrc={HashSearchIC} variant="primary" type="submit">Search</Button>
+ </form>
+ <div className="public-channels__search-status">
+ {
+ typeof searchQuery.name !== 'undefined' && isSearching && (
+ searchQuery.name === ''
+ ? (
+ <div className="flex--center">
+ <Spinner size="small" />
+ <Text variant="b2">{`Loading public channels from ${searchQuery.homeserver}...`}</Text>
+ </div>
+ )
+ : (
+ <div className="flex--center">
+ <Spinner size="small" />
+ <Text variant="b2">{`Searching for "${searchQuery.name}" on ${searchQuery.homeserver}...`}</Text>
+ </div>
+ )
+ )
+ }
+ {
+ typeof searchQuery.name !== 'undefined' && !isSearching && (
+ searchQuery.name === ''
+ ? <Text variant="b2">{`Public channels on ${searchQuery.homeserver}.`}</Text>
+ : <Text variant="b2">{`Search result for "${searchQuery.name}" on ${searchQuery.homeserver}.`}</Text>
+ )
+ }
+ {
+ searchQuery.error && <Text className="public-channels__search-error" variant="b2">{searchQuery.error}</Text>
+ }
+ </div>
+ { publicChannels.length !== 0 && (
+ <div className="public-channels__content">
+ { renderChannelList(publicChannels) }
+ </div>
+ )}
+ { publicChannels.length !== 0 && publicChannels.length % SEARCH_LIMIT === 0 && (
+ <div className="public-channels__view-more">
+ { isViewMore !== true && (
+ <Button onClick={() => searchChannels(true)}>View more</Button>
+ )}
+ { isViewMore && <Spinner /> }
+ </div>
+ )}
+ </div>
+ </PopupWindow>
+ );
+}
+
+PublicChannels.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onRequestClose: PropTypes.func.isRequired,
+};
+
+export default PublicChannels;
--- /dev/null
+.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
--- /dev/null
+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 (
+ <>
+ <InviteList
+ isOpen={isInviteList}
+ onRequestClose={() => changeInviteList(false)}
+ />
+ <PublicChannels
+ isOpen={isPubilcChannels}
+ onRequestClose={() => changePubilcChannels(false)}
+ />
+ <CreateChannel
+ isOpen={isCreateChannel}
+ onRequestClose={() => changeCreateChannel(false)}
+ />
+ <InviteUser
+ isOpen={inviteUser.isOpen}
+ roomId={inviteUser.roomId}
+ onRequestClose={() => changeInviteUser({ isOpen: false, roomId: undefined })}
+ />
+ <Settings
+ isOpen={settings}
+ onRequestClose={() => changeSettings(false)}
+ />
+ </>
+ );
+}
+
+export default Windows;
--- /dev/null
+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 (
+ <PopupWindow
+ className="settings-window"
+ isOpen={isOpen}
+ onRequestClose={onRequestClose}
+ title="Settings"
+ contentOptions={<IconButton src={CrossIC} onClick={onRequestClose} tooltip="Close" />}
+ >
+ <div className="settings-content">
+ <SettingTile
+ title="Theme"
+ content={(
+ <SegmentedControls
+ selected={settings.getThemeIndex()}
+ segments={[
+ { text: 'Light' },
+ { text: 'Silver' },
+ { text: 'Dark' },
+ { text: 'Butter' },
+ ]}
+ onSelect={(index) => settings.setTheme(index)}
+ />
+ )}
+ />
+ <div style={{ flex: '1' }} />
+ <Text className="settings__about" variant="b1">
+ <a href="https://cinny.in/#about" target="_blank" rel="noreferrer">About</a>
+ </Text>
+ <Text className="settings__about">Version: 1.0.0</Text>
+ </div>
+ </PopupWindow>
+ );
+}
+
+Settings.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onRequestClose: PropTypes.func.isRequired,
+};
+
+export default Settings;
--- /dev/null
+.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
--- /dev/null
+import React from 'react';
+import './Welcome.scss';
+
+import Text from '../../atoms/text/Text';
+
+import CinnySvg from '../../../../public/res/svg/cinny.svg';
+
+function Welcome() {
+ return (
+ <div className="app-welcome flex--center">
+ <div className="flex-v--center">
+ <img className="app-welcome__logo noselect" src={CinnySvg} alt="Cinny logo" />
+ <Text className="app-welcome__heading" variant="h1">Welcome to Cinny</Text>
+ <Text className="app-welcome__subheading" variant="s1">Yet another matrix client</Text>
+ </div>
+ </div>
+ );
+}
+
+export default Welcome;
--- /dev/null
+.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
--- /dev/null
+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 (
+ <BrowserRouter>
+ <Switch>
+ <Route exact path="/">
+ { isAuthanticated() ? <Client /> : <Redirect to="/login" />}
+ </Route>
+ <Route path="/login">
+ { isAuthanticated() ? <Redirect to="/" /> : <Auth type="login" />}
+ </Route>
+ <Route path="/register">
+ { isAuthanticated() ? <Redirect to="/" /> : <Auth type="register" />}
+ </Route>
+ </Switch>
+ </BrowserRouter>
+ );
+}
+
+export default App;
--- /dev/null
+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' && <LoadingScreen message={process.message} />}
+ {process?.type === 'recaptcha' && <Recaptcha message="Please check the box below to proceed." sitekey={process.sitekey} onChange={(v) => { if (typeof v === 'string') register(v); }} />}
+ {process?.type === 'terms' && <Terms url={process.en.url} onSubmit={register} />}
+ {process?.type === 'email' && (
+ <ProcessWrapper>
+ <div style={{ margin: 'var(--sp-normal)', maxWidth: '450px' }}>
+ <Text variant="h2">Verify email</Text>
+ <div style={{ margin: 'var(--sp-normal) 0' }}>
+ <Text variant="b1">
+ Please check your email
+ {' '}
+ <b>{`(${emailRef.current.value})`}</b>
+ {' '}
+ and validate before continuing further.
+ </Text>
+ </div>
+ <Button variant="primary" onClick={() => register(undefined, undefined, true)}>Continue</Button>
+ </div>
+ </ProcessWrapper>
+ )}
+ <StaticWrapper>
+ <div className="auth-form__wrapper flex-v--center">
+ <form onSubmit={handleAuth} className="auth-form">
+ <Text variant="h2">{ type === 'login' ? 'Login' : 'Register' }</Text>
+ <div className="username__wrapper">
+ <Input
+ forwardRef={usernameRef}
+ onChange={(e) => validateOnChange(e, USERNAME_REGEX, BAD_USERNAME_ERROR)}
+ id="auth_username"
+ label="Username"
+ required
+ />
+ <Input
+ forwardRef={homeserverRef}
+ id="auth_homeserver"
+ placeholder="Homeserver"
+ value="matrix.org"
+ required
+ />
+ </div>
+ <Input
+ forwardRef={passwordRef}
+ onChange={(e) => validateOnChange(e, ((type === 'login') ? PASSWORD_REGEX : PASSWORD_STRENGHT_REGEX), BAD_PASSWORD_ERROR)}
+ id="auth_password"
+ type="password"
+ label="Password"
+ required
+ />
+ {type === 'register' && (
+ <>
+ <Input
+ forwardRef={confirmPasswordRef}
+ onChange={(e) => validateOnChange(e, new RegExp(`^(${passwordRef.current.value})$`), CONFIRM_PASSWORD_ERROR)}
+ id="auth_confirmPassword"
+ type="password"
+ label="Confirm password"
+ required
+ />
+ <Input
+ forwardRef={emailRef}
+ onChange={(e) => validateOnChange(e, EMAIL_REGEX, BAD_EMAIL_ERROR)}
+ id="auth_email"
+ type="email"
+ label="Email"
+ required
+ />
+ </>
+ )}
+ <div className="submit-btn__wrapper flex--end">
+ <Text id="auth_error" className="error-message" variant="b3">Error</Text>
+ <Button
+ id="auth_submit-btn"
+ variant="primary"
+ type="submit"
+ >
+ {type === 'login' ? 'Login' : 'Register' }
+ </Button>
+ </div>
+ </form>
+ </div>
+
+ <div className="flex--center">
+ <Text variant="b2">
+ {`${(type === 'login' ? 'Don\'t have' : 'Already have')} an account?`}
+ <Link to={type === 'login' ? '/register' : '/login'}>
+ { type === 'login' ? ' Register' : ' Login' }
+ </Link>
+ </Text>
+ </div>
+ </StaticWrapper>
+ </>
+ );
+}
+
+Auth.propTypes = {
+ type: PropTypes.string.isRequired,
+};
+
+function StaticWrapper({ children }) {
+ return (
+ <div className="auth__wrapper flex--center">
+ <div className="auth-card">
+ <div className="auth-card__interactive flex-v">
+ <div className="app-ident flex">
+ <img className="app-ident__logo noselect" src={CinnySvg} alt="Cinny logo" />
+ <div className="app-ident__text flex-v--center">
+ <Text variant="h2">Cinny</Text>
+ <Text variant="b2">Yet another matrix client.</Text>
+ </div>
+ </div>
+ { children }
+ </div>
+ </div>
+ </div>
+ );
+}
+
+StaticWrapper.propTypes = {
+ children: PropTypes.node.isRequired,
+};
+
+function LoadingScreen({ message }) {
+ return (
+ <ProcessWrapper>
+ <Spinner />
+ <div style={{ marginTop: 'var(--sp-normal)' }}>
+ <Text variant="b1">{message}</Text>
+ </div>
+ </ProcessWrapper>
+ );
+}
+LoadingScreen.propTypes = {
+ message: PropTypes.string.isRequired,
+};
+
+function Recaptcha({ message, sitekey, onChange }) {
+ return (
+ <ProcessWrapper>
+ <div style={{ marginBottom: 'var(--sp-normal)' }}>
+ <Text variant="s1">{message}</Text>
+ </div>
+ <ReCAPTCHA sitekey={sitekey} onChange={onChange} />
+ </ProcessWrapper>
+ );
+}
+Recaptcha.propTypes = {
+ message: PropTypes.string.isRequired,
+ sitekey: PropTypes.string.isRequired,
+ onChange: PropTypes.func.isRequired,
+};
+
+function Terms({ url, onSubmit }) {
+ return (
+ <ProcessWrapper>
+ <form onSubmit={() => onSubmit(undefined, true)}>
+ <div style={{ margin: 'var(--sp-normal)', maxWidth: '450px' }}>
+ <Text variant="h2">Agree with terms</Text>
+ <div style={{ marginBottom: 'var(--sp-normal)' }} />
+ <Text variant="b1">In order to complete registration, you need to agree with terms and conditions.</Text>
+ <div style={{ display: 'flex', alignItems: 'center', margin: 'var(--sp-normal) 0' }}>
+ <input id="termsCheckbox" type="checkbox" required />
+ <Text variant="b1">
+ {'I accept '}
+ <a style={{ cursor: 'pointer' }} href={url} rel="noreferrer" target="_blank">Terms and Conditions</a>
+ </Text>
+ </div>
+ <Button id="termsBtn" type="submit" variant="primary">Submit</Button>
+ </div>
+ </form>
+ </ProcessWrapper>
+ );
+}
+Terms.propTypes = {
+ url: PropTypes.string.isRequired,
+ onSubmit: PropTypes.func.isRequired,
+};
+
+function ProcessWrapper({ children }) {
+ return (
+ <div className="process-wrapper">
+ {children}
+ </div>
+ );
+}
+ProcessWrapper.propTypes = {
+ children: PropTypes.node.isRequired,
+};
+
+export default Auth;
--- /dev/null
+.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
--- /dev/null
+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 (
+ <div className="loading-display">
+ <Spinner />
+ <Text className="loading__message" variant="b2">Heating up</Text>
+
+ <div className="loading__appname">
+ <Text variant="h2">Cinny</Text>
+ </div>
+ </div>
+ );
+ }
+ return (
+ <div className="client-container">
+ <div className="navigation__wrapper">
+ <Navigation />
+ </div>
+ <div className="channel__wrapper">
+ <Channel />
+ </div>
+ <Windows />
+ </div>
+ );
+}
+
+export default Client;
--- /dev/null
+.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
--- /dev/null
+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 };
--- /dev/null
+import initMatrix from '../initMatrix';
+
+function logout() {
+ const mx = initMatrix.matrixClient;
+ mx.logout().then(() => {
+ mx.clearStores();
+ window.localStorage.clear();
+ window.location.reload();
+ });
+}
+
+export default logout;
--- /dev/null
+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,
+};
--- /dev/null
+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,
+};
--- /dev/null
+import { Dispatcher } from 'flux';
+
+const appDispatcher = new Dispatcher();
+export default appDispatcher;
--- /dev/null
+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;
--- /dev/null
+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;
--- /dev/null
+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;
--- /dev/null
+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;
--- /dev/null
+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,
+};
--- /dev/null
+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;
--- /dev/null
+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;
--- /dev/null
+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;
--- /dev/null
+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(
+ <App />,
+ document.getElementById('root'),
+);
--- /dev/null
+@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
--- /dev/null
+// 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];
+}
--- /dev/null
+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()
+ );
+}
--- /dev/null
+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,
+};
--- /dev/null
+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,
+ }
+ }
+ })
+ ],
+};
--- /dev/null
+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',
+ ],
+ },
+ ],
+ },
+});
--- /dev/null
+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',
+ }),
+ ],
+});