From 026f835a875d3d567a3d85ece941f2b6f1e310ce Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 28 Jul 2021 18:45:52 +0530 Subject: [PATCH] initial commit --- .eslintignore | 5 + .eslintrc.js | 24 + .github/FUNDING.yml | 1 + .github/ISSUE_TEMPLATE/bug_report.md | 32 + .github/ISSUE_TEMPLATE/feature_request.md | 20 + .github/PULL_REQUEST_TEMPLATE.md | 24 + .github/SECURITY.md | 3 + .gitignore | 5 + CONTRIBUTING.md | 139 ++ LICENSE | 21 + README.md | 11 + olm.wasm | Bin 0 -> 153551 bytes package.json | 76 ++ public/index.html | 22 + public/res/ic/outlined/add-user.svg | 12 + public/res/ic/outlined/ball.svg | 12 + public/res/ic/outlined/bell.svg | 11 + public/res/ic/outlined/bulb.svg | 10 + public/res/ic/outlined/chevron-bottom.svg | 9 + public/res/ic/outlined/chevron-left.svg | 9 + public/res/ic/outlined/chevron-right.svg | 9 + public/res/ic/outlined/chevron-top.svg | 9 + public/res/ic/outlined/circle-plus.svg | 11 + public/res/ic/outlined/coin.svg | 12 + public/res/ic/outlined/cross.svg | 7 + public/res/ic/outlined/cup.svg | 9 + public/res/ic/outlined/dog.svg | 18 + public/res/ic/outlined/download.svg | 12 + public/res/ic/outlined/emoji.svg | 13 + public/res/ic/outlined/explore.svg | 11 + public/res/ic/outlined/external.svg | 12 + public/res/ic/outlined/file.svg | 7 + public/res/ic/outlined/flag.svg | 8 + public/res/ic/outlined/hash-lock.svg | 12 + public/res/ic/outlined/hash-plus.svg | 13 + public/res/ic/outlined/hash-search.svg | 12 + public/res/ic/outlined/hash-shield.svg | 11 + public/res/ic/outlined/hash.svg | 7 + public/res/ic/outlined/heart.svg | 10 + public/res/ic/outlined/home.svg | 10 + public/res/ic/outlined/inbox.svg | 9 + public/res/ic/outlined/invite-arrow.svg | 11 + .../res/ic/outlined/invite-cancel-arrow.svg | 11 + public/res/ic/outlined/invite.svg | 11 + public/res/ic/outlined/join-arrow.svg | 8 + public/res/ic/outlined/leave-arrow.svg | 8 + public/res/ic/outlined/lock.svg | 11 + public/res/ic/outlined/pause.svg | 16 + public/res/ic/outlined/peace.svg | 9 + public/res/ic/outlined/photo.svg | 11 + public/res/ic/outlined/play.svg | 11 + public/res/ic/outlined/plus.svg | 7 + public/res/ic/outlined/power.svg | 11 + public/res/ic/outlined/reply-arrow.svg | 7 + public/res/ic/outlined/search.svg | 8 + public/res/ic/outlined/send.svg | 7 + public/res/ic/outlined/settings.svg | 22 + public/res/ic/outlined/shield.svg | 10 + public/res/ic/outlined/space-lock.svg | 13 + public/res/ic/outlined/space.svg | 10 + public/res/ic/outlined/sun.svg | 34 + public/res/ic/outlined/tick-mark.svg | 11 + public/res/ic/outlined/user.svg | 10 + public/res/ic/outlined/vertical-menu.svg | 11 + public/res/ic/outlined/vlc.svg | 8 + public/res/ic/outlined/volume-full.svg | 13 + public/res/ic/outlined/volume-mute.svg | 11 + public/res/svg/cinny.svg | 19 + public/res/svg/matrix-logo.svg | 49 + src/app/atoms/avatar/Avatar.jsx | 57 + src/app/atoms/avatar/Avatar.scss | 52 + src/app/atoms/badge/NotificationBadge.jsx | 28 + src/app/atoms/badge/NotificationBadge.scss | 18 + src/app/atoms/button/Button.jsx | 47 + src/app/atoms/button/Button.scss | 83 ++ src/app/atoms/button/IconButton.jsx | 60 + src/app/atoms/button/IconButton.scss | 45 + src/app/atoms/button/Toggle.jsx | 25 + src/app/atoms/button/Toggle.scss | 39 + src/app/atoms/button/_state.scss | 25 + src/app/atoms/button/script.js | 23 + src/app/atoms/context-menu/ContextMenu.jsx | 103 ++ src/app/atoms/context-menu/ContextMenu.scss | 71 + src/app/atoms/divider/Divider.jsx | 29 + src/app/atoms/divider/Divider.scss | 68 + src/app/atoms/header/Header.jsx | 29 + src/app/atoms/header/Header.scss | 63 + src/app/atoms/input/Input.jsx | 77 ++ src/app/atoms/input/Input.scss | 40 + src/app/atoms/modal/RawModal.jsx | 67 + src/app/atoms/modal/RawModal.scss | 63 + src/app/atoms/scroll/ScrollView.jsx | 37 + src/app/atoms/scroll/ScrollView.scss | 22 + src/app/atoms/scroll/_scrollbar.scss | 62 + .../segmented-controls/SegmentedControls.jsx | 51 + .../segmented-controls/SegmentedControls.scss | 61 + src/app/atoms/spinner/Spinner.jsx | 19 + src/app/atoms/spinner/Spinner.scss | 22 + src/app/atoms/system-icons/RawIcon.jsx | 25 + src/app/atoms/system-icons/RawIcon.scss | 25 + src/app/atoms/text/Text.jsx | 28 + src/app/atoms/text/Text.scss | 41 + .../molecules/channel-intro/ChannelIntro.jsx | 46 + .../molecules/channel-intro/ChannelIntro.scss | 31 + .../channel-selector/ChannelSelector.jsx | 73 ++ .../channel-selector/ChannelSelector.scss | 66 + .../molecules/channel-tile/ChannelTile.jsx | 72 ++ .../molecules/channel-tile/ChannelTile.scss | 21 + src/app/molecules/media/Media.jsx | 307 +++++ src/app/molecules/media/Media.scss | 62 + src/app/molecules/message/Message.jsx | 149 +++ src/app/molecules/message/Message.scss | 293 +++++ src/app/molecules/message/TimelineChange.jsx | 79 ++ src/app/molecules/message/TimelineChange.scss | 39 + .../people-selector/PeopleSelector.jsx | 42 + .../people-selector/PeopleSelector.scss | 40 + .../molecules/popup-window/PopupWindow.jsx | 123 ++ .../molecules/popup-window/PopupWindow.scss | 100 ++ .../molecules/setting-tile/SettingTile.jsx | 32 + .../molecules/setting-tile/SettingTile.scss | 16 + .../sidebar-avatar/SidebarAvatar.jsx | 72 ++ .../sidebar-avatar/SidebarAvatar.scss | 63 + src/app/organisms/channel/Channel.jsx | 40 + src/app/organisms/channel/Channel.scss | 4 + src/app/organisms/channel/ChannelView.jsx | 1142 +++++++++++++++++ src/app/organisms/channel/ChannelView.scss | 248 ++++ src/app/organisms/channel/PeopleDrawer.jsx | 138 ++ src/app/organisms/channel/PeopleDrawer.scss | 75 ++ .../create-channel/CreateChannel.jsx | 165 +++ .../create-channel/CreateChannel.scss | 103 ++ src/app/organisms/emoji-board/EmojiBoard.jsx | 195 +++ src/app/organisms/emoji-board/EmojiBoard.scss | 89 ++ src/app/organisms/emoji-board/emoji.js | 76 ++ src/app/organisms/invite-list/InviteList.jsx | 135 ++ src/app/organisms/invite-list/InviteList.scss | 39 + src/app/organisms/invite-user/InviteUser.jsx | 269 ++++ src/app/organisms/invite-user/InviteUser.scss | 55 + src/app/organisms/navigation/Drawer.jsx | 223 ++++ src/app/organisms/navigation/Drawer.scss | 48 + src/app/organisms/navigation/Navigation.jsx | 36 + src/app/organisms/navigation/Navigation.scss | 7 + src/app/organisms/navigation/SideBar.jsx | 118 ++ src/app/organisms/navigation/SideBar.scss | 70 + .../public-channels/PublicChannels.jsx | 199 +++ .../public-channels/PublicChannels.scss | 87 ++ src/app/organisms/pw/Windows.jsx | 80 ++ src/app/organisms/settings/Settings.jsx | 56 + src/app/organisms/settings/Settings.scss | 22 + src/app/organisms/welcome/Welcome.jsx | 20 + src/app/organisms/welcome/Welcome.scss | 20 + src/app/pages/App.jsx | 29 + src/app/templates/auth/Auth.jsx | 335 +++++ src/app/templates/auth/Auth.scss | 157 +++ src/app/templates/client/Client.jsx | 47 + src/app/templates/client/Client.scss | 34 + src/client/action/auth.js | 145 +++ src/client/action/logout.js | 12 + src/client/action/navigation.js | 64 + src/client/action/room.js | 189 +++ src/client/dispatcher.js | 4 + src/client/initMatrix.js | 89 ++ src/client/state/RoomList.js | 288 +++++ src/client/state/RoomTimeline.js | 161 +++ src/client/state/RoomsInput.js | 276 ++++ src/client/state/auth.js | 19 + src/client/state/cons.js | 65 + src/client/state/navigation.js | 59 + src/client/state/settings.js | 36 + src/index.jsx | 14 + src/index.scss | 318 +++++ src/util/colorMXID.js | 23 + src/util/common.js | 21 + src/util/matrixUtil.js | 67 + webpack.common.js | 69 + webpack.dev.js | 27 + webpack.prod.js | 39 + 176 files changed, 10613 insertions(+) create mode 100644 .eslintignore create mode 100644 .eslintrc.js create mode 100644 .github/FUNDING.yml create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/SECURITY.md create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 olm.wasm create mode 100644 package.json create mode 100644 public/index.html create mode 100644 public/res/ic/outlined/add-user.svg create mode 100644 public/res/ic/outlined/ball.svg create mode 100644 public/res/ic/outlined/bell.svg create mode 100644 public/res/ic/outlined/bulb.svg create mode 100644 public/res/ic/outlined/chevron-bottom.svg create mode 100644 public/res/ic/outlined/chevron-left.svg create mode 100644 public/res/ic/outlined/chevron-right.svg create mode 100644 public/res/ic/outlined/chevron-top.svg create mode 100644 public/res/ic/outlined/circle-plus.svg create mode 100644 public/res/ic/outlined/coin.svg create mode 100644 public/res/ic/outlined/cross.svg create mode 100644 public/res/ic/outlined/cup.svg create mode 100644 public/res/ic/outlined/dog.svg create mode 100644 public/res/ic/outlined/download.svg create mode 100644 public/res/ic/outlined/emoji.svg create mode 100644 public/res/ic/outlined/explore.svg create mode 100644 public/res/ic/outlined/external.svg create mode 100644 public/res/ic/outlined/file.svg create mode 100644 public/res/ic/outlined/flag.svg create mode 100644 public/res/ic/outlined/hash-lock.svg create mode 100644 public/res/ic/outlined/hash-plus.svg create mode 100644 public/res/ic/outlined/hash-search.svg create mode 100644 public/res/ic/outlined/hash-shield.svg create mode 100644 public/res/ic/outlined/hash.svg create mode 100644 public/res/ic/outlined/heart.svg create mode 100644 public/res/ic/outlined/home.svg create mode 100644 public/res/ic/outlined/inbox.svg create mode 100644 public/res/ic/outlined/invite-arrow.svg create mode 100644 public/res/ic/outlined/invite-cancel-arrow.svg create mode 100644 public/res/ic/outlined/invite.svg create mode 100644 public/res/ic/outlined/join-arrow.svg create mode 100644 public/res/ic/outlined/leave-arrow.svg create mode 100644 public/res/ic/outlined/lock.svg create mode 100644 public/res/ic/outlined/pause.svg create mode 100644 public/res/ic/outlined/peace.svg create mode 100644 public/res/ic/outlined/photo.svg create mode 100644 public/res/ic/outlined/play.svg create mode 100644 public/res/ic/outlined/plus.svg create mode 100644 public/res/ic/outlined/power.svg create mode 100644 public/res/ic/outlined/reply-arrow.svg create mode 100644 public/res/ic/outlined/search.svg create mode 100644 public/res/ic/outlined/send.svg create mode 100644 public/res/ic/outlined/settings.svg create mode 100644 public/res/ic/outlined/shield.svg create mode 100644 public/res/ic/outlined/space-lock.svg create mode 100644 public/res/ic/outlined/space.svg create mode 100644 public/res/ic/outlined/sun.svg create mode 100644 public/res/ic/outlined/tick-mark.svg create mode 100644 public/res/ic/outlined/user.svg create mode 100644 public/res/ic/outlined/vertical-menu.svg create mode 100644 public/res/ic/outlined/vlc.svg create mode 100644 public/res/ic/outlined/volume-full.svg create mode 100644 public/res/ic/outlined/volume-mute.svg create mode 100644 public/res/svg/cinny.svg create mode 100644 public/res/svg/matrix-logo.svg create mode 100644 src/app/atoms/avatar/Avatar.jsx create mode 100644 src/app/atoms/avatar/Avatar.scss create mode 100644 src/app/atoms/badge/NotificationBadge.jsx create mode 100644 src/app/atoms/badge/NotificationBadge.scss create mode 100644 src/app/atoms/button/Button.jsx create mode 100644 src/app/atoms/button/Button.scss create mode 100644 src/app/atoms/button/IconButton.jsx create mode 100644 src/app/atoms/button/IconButton.scss create mode 100644 src/app/atoms/button/Toggle.jsx create mode 100644 src/app/atoms/button/Toggle.scss create mode 100644 src/app/atoms/button/_state.scss create mode 100644 src/app/atoms/button/script.js create mode 100644 src/app/atoms/context-menu/ContextMenu.jsx create mode 100644 src/app/atoms/context-menu/ContextMenu.scss create mode 100644 src/app/atoms/divider/Divider.jsx create mode 100644 src/app/atoms/divider/Divider.scss create mode 100644 src/app/atoms/header/Header.jsx create mode 100644 src/app/atoms/header/Header.scss create mode 100644 src/app/atoms/input/Input.jsx create mode 100644 src/app/atoms/input/Input.scss create mode 100644 src/app/atoms/modal/RawModal.jsx create mode 100644 src/app/atoms/modal/RawModal.scss create mode 100644 src/app/atoms/scroll/ScrollView.jsx create mode 100644 src/app/atoms/scroll/ScrollView.scss create mode 100644 src/app/atoms/scroll/_scrollbar.scss create mode 100644 src/app/atoms/segmented-controls/SegmentedControls.jsx create mode 100644 src/app/atoms/segmented-controls/SegmentedControls.scss create mode 100644 src/app/atoms/spinner/Spinner.jsx create mode 100644 src/app/atoms/spinner/Spinner.scss create mode 100644 src/app/atoms/system-icons/RawIcon.jsx create mode 100644 src/app/atoms/system-icons/RawIcon.scss create mode 100644 src/app/atoms/text/Text.jsx create mode 100644 src/app/atoms/text/Text.scss create mode 100644 src/app/molecules/channel-intro/ChannelIntro.jsx create mode 100644 src/app/molecules/channel-intro/ChannelIntro.scss create mode 100644 src/app/molecules/channel-selector/ChannelSelector.jsx create mode 100644 src/app/molecules/channel-selector/ChannelSelector.scss create mode 100644 src/app/molecules/channel-tile/ChannelTile.jsx create mode 100644 src/app/molecules/channel-tile/ChannelTile.scss create mode 100644 src/app/molecules/media/Media.jsx create mode 100644 src/app/molecules/media/Media.scss create mode 100644 src/app/molecules/message/Message.jsx create mode 100644 src/app/molecules/message/Message.scss create mode 100644 src/app/molecules/message/TimelineChange.jsx create mode 100644 src/app/molecules/message/TimelineChange.scss create mode 100644 src/app/molecules/people-selector/PeopleSelector.jsx create mode 100644 src/app/molecules/people-selector/PeopleSelector.scss create mode 100644 src/app/molecules/popup-window/PopupWindow.jsx create mode 100644 src/app/molecules/popup-window/PopupWindow.scss create mode 100644 src/app/molecules/setting-tile/SettingTile.jsx create mode 100644 src/app/molecules/setting-tile/SettingTile.scss create mode 100644 src/app/molecules/sidebar-avatar/SidebarAvatar.jsx create mode 100644 src/app/molecules/sidebar-avatar/SidebarAvatar.scss create mode 100644 src/app/organisms/channel/Channel.jsx create mode 100644 src/app/organisms/channel/Channel.scss create mode 100644 src/app/organisms/channel/ChannelView.jsx create mode 100644 src/app/organisms/channel/ChannelView.scss create mode 100644 src/app/organisms/channel/PeopleDrawer.jsx create mode 100644 src/app/organisms/channel/PeopleDrawer.scss create mode 100644 src/app/organisms/create-channel/CreateChannel.jsx create mode 100644 src/app/organisms/create-channel/CreateChannel.scss create mode 100644 src/app/organisms/emoji-board/EmojiBoard.jsx create mode 100644 src/app/organisms/emoji-board/EmojiBoard.scss create mode 100644 src/app/organisms/emoji-board/emoji.js create mode 100644 src/app/organisms/invite-list/InviteList.jsx create mode 100644 src/app/organisms/invite-list/InviteList.scss create mode 100644 src/app/organisms/invite-user/InviteUser.jsx create mode 100644 src/app/organisms/invite-user/InviteUser.scss create mode 100644 src/app/organisms/navigation/Drawer.jsx create mode 100644 src/app/organisms/navigation/Drawer.scss create mode 100644 src/app/organisms/navigation/Navigation.jsx create mode 100644 src/app/organisms/navigation/Navigation.scss create mode 100644 src/app/organisms/navigation/SideBar.jsx create mode 100644 src/app/organisms/navigation/SideBar.scss create mode 100644 src/app/organisms/public-channels/PublicChannels.jsx create mode 100644 src/app/organisms/public-channels/PublicChannels.scss create mode 100644 src/app/organisms/pw/Windows.jsx create mode 100644 src/app/organisms/settings/Settings.jsx create mode 100644 src/app/organisms/settings/Settings.scss create mode 100644 src/app/organisms/welcome/Welcome.jsx create mode 100644 src/app/organisms/welcome/Welcome.scss create mode 100644 src/app/pages/App.jsx create mode 100644 src/app/templates/auth/Auth.jsx create mode 100644 src/app/templates/auth/Auth.scss create mode 100644 src/app/templates/client/Client.jsx create mode 100644 src/app/templates/client/Client.scss create mode 100644 src/client/action/auth.js create mode 100644 src/client/action/logout.js create mode 100644 src/client/action/navigation.js create mode 100644 src/client/action/room.js create mode 100644 src/client/dispatcher.js create mode 100644 src/client/initMatrix.js create mode 100644 src/client/state/RoomList.js create mode 100644 src/client/state/RoomTimeline.js create mode 100644 src/client/state/RoomsInput.js create mode 100644 src/client/state/auth.js create mode 100644 src/client/state/cons.js create mode 100644 src/client/state/navigation.js create mode 100644 src/client/state/settings.js create mode 100644 src/index.jsx create mode 100644 src/index.scss create mode 100644 src/util/colorMXID.js create mode 100644 src/util/common.js create mode 100644 src/util/matrixUtil.js create mode 100644 webpack.common.js create mode 100644 webpack.dev.js create mode 100644 webpack.prod.js diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..8ace574 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,5 @@ +webpack.common.js +webpack.dev.js +webpack.prod.js +experiment +node_modules \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..8ad268d --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,24 @@ +module.exports = { + env: { + browser: true, + es2021: true, + }, + extends: [ + 'plugin:react/recommended', + 'airbnb', + ], + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + ecmaVersion: 12, + sourceType: 'module', + }, + plugins: [ + 'react', + ], + rules: { + 'linebreak-style': 0, + 'no-underscore-dangle': 0, + }, +}; diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..1e14f86 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +liberapay: kfiven \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..da55602 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,32 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - OS: [e.g. iOS] + - Browser [e.g. chrome, safari] + - Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..bbcbbe7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..426c4d6 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,24 @@ + + +# Description + +Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. + +Fixes # (issue) + +## Type of change + +Please delete options that are not relevant. + +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] This change requires a documentation update + +# Checklist: + +- [ ] My code follows the style guidelines of this project +- [ ] I have performed a self-review of my own code +- [ ] I have commented my code, particularly in hard-to-understand areas +- [ ] I have made corresponding changes to the documentation +- [ ] My changes generate no new warnings \ No newline at end of file diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 0000000..2dd642d --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,3 @@ +# Reporting a Vulnerability + +**If you've found a security vulnerability, please report it to cinnyapp@gmail.com** \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f7efb60 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +experiment +package-lock.json +dist +node_modules +devAssets \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..86a4e4c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,139 @@ + +# Contributing to Cinny + +First off, thanks for taking the time to contribute! ❤️ + +All types of contributions are encouraged and valued. See the [Table of Contents](#table-of-contents) for different ways to help and details about how this project handles them. Please make sure to read the relevant section before making your contribution. It will make it a lot easier for us maintainers and smooth out the experience for all involved. The community looks forward to your contributions. 🎉 + +> And if you like the project, but just don't have time to contribute, that's fine. There are other easy ways to support the project and show your appreciation, which we would also be very happy about: +> - Star the project +> - Tweet about it (tag @cinnyapp) +> - Refer this project in your project's readme +> - Mention the project at local meetups and tell your friends/colleagues +> - [Donate to us](https://liberapay.com/kfiven/donate) + + +## Table of Contents + +- [I Have a Question](#i-have-a-question) +- [I Want To Contribute](#i-want-to-contribute) + - [Reporting Bugs](#reporting-bugs) + - [Suggesting Enhancements](#suggesting-enhancements) + - [Your First Code Contribution](#your-first-code-contribution) +- [Styleguides](#styleguides) + - [Commit Messages](#commit-messages) + - [Coding conventions](#coding-conventions) + +## I Have a Question + +Before you ask a question, it is best to search for existing [Issues](https://github.com/ajbura/cinny/issues) that might help you. In case you have found a suitable issue and still need clarification, you can write your question in this issue. + +If you then still feel the need to ask a question and need clarification, we recommend the following: + +- Ask in our [Matrix room](https://matrix.to/#/#cinny:matrix.org) or [IRC channel](https://web.libera.chat/?channel=#cinny). +- If no one respond in our channel, please open an [Issue](https://github.com/ajbura/cinny/issues/new). +- Provide as much context as you can about what you're running into. +- Provide project and platform versions (nodejs, npm, etc), depending on what seems relevant. + +We will then take care of the issue as soon as possible. + + +## I Want To Contribute + +> ### Legal Notice +> When contributing to this project, you must agree that you have authored 100% of the content, that you have the necessary rights to the content and that the content you contribute may be provided under the project license. + +### Reporting Bugs + + +#### Before Submitting a Bug Report + +A good bug report shouldn't leave others needing to chase you up for more information. Therefore, we ask you to investigate carefully, collect information and describe the issue in detail in your report. Please complete the following steps in advance to help us fix any potential bug as fast as possible. + +- Make sure that you are using the latest version. +- Determine if your bug is really a bug and not an error on your side. If you are looking for support, you might want to check [this section](#i-have-a-question)). +- To see if other users have experienced (and potentially already solved) the same issue you are having, check if there is not already a bug report existing for your bug or error in the [bug tracker](https://github.com/ajbura/cinny/issues?q=label%3Abug). +- Collect information about the bug: + - OS, Platform and Version (Windows, Linux, macOS, x86, ARM) + - Possibly your input and the output + - Can you reliably reproduce the issue? + + +#### How Do I Submit a Good Bug Report? + +> You must never report security related issues, vulnerabilities or bugs to the issue tracker, or elsewhere in public. Instead sensitive bugs must be sent by email to . + +We use GitHub issues to track bugs and errors. If you run into an issue with the project: + +- Open an [Issue](https://github.com/ajbura/cinny/issues/new). (Since we can't be sure at this point whether it is a bug or not, we ask you not to talk about a bug yet and not to label the issue.) +- Explain the behavior you would expect and the actual behavior. +- Please provide as much context as possible and describe the *reproduction steps* that someone else can follow to recreate the issue on their own. For good bug reports you should isolate the problem and create a reduced test case. +- Provide the information you collected in the previous section. + +Once it's filed: + +- The project team will label the issue accordingly. +- A team member will try to reproduce the issue with your provided steps. If there are no reproduction steps or no obvious way to reproduce the issue, the team will ask you for those steps and mark the issue as `needs-repro`. Bugs with the `needs-repro` tag will not be addressed until they are reproduced. +- If the team is able to reproduce the issue, it will be marked `needs-fix`, as well as possibly other tags (such as `critical`), and the issue will be left to be [implemented by someone](#your-first-code-contribution). + + +### Suggesting Enhancements + +This section guides you through submitting an enhancement suggestion for Cinny, **including completely new features and minor improvements to existing functionality**. Following these guidelines will help maintainers and the community to understand your suggestion and find related suggestions. + + +#### Before Submitting an Enhancement + +- Make sure that you are using the latest version. +- Perform a [search](https://github.com/ajbura/cinny/issues) to see if the enhancement has already been suggested. If it has, add a comment to the existing issue instead of opening a new one. +- Find out whether your idea fits with the scope and aims of the project. It's up to you to make a strong case to convince the project's developers of the merits of this feature. Keep in mind that we want features that will be useful to the majority of our users and not just a small subset. + + +#### How Do I Submit a Good Enhancement Suggestion? + +Enhancement suggestions are tracked as [GitHub issues](https://github.com/ajbura/cinny/issues). + +- Use a **clear and descriptive title** for the issue to identify the suggestion. +- Provide a **step-by-step description of the suggested enhancement** in as many details as possible. +- **Describe the current behavior** and **explain which behavior you expected to see instead** and why. At this point you can also tell which alternatives do not work for you. +- You may want to **include screenshots and animated GIFs** which help you demonstrate the steps or point out the part which the suggestion is related to. You can use [this tool](https://www.cockos.com/licecap/) to record GIFs on macOS and Windows, and [this tool](https://github.com/colinkeenan/silentcast) on Linux. +- **Explain why this enhancement would be useful** to most Cinny users. You may also want to point out the other projects that solved it better and which could serve as inspiration. + +### Your First Code Contribution +Please send a [GitHub Pull Request to cinny](https://github.com/ajbura/cinny/pull/new/master) with a clear list of what you've done (read more about [pull requests](http://help.github.com/pull-requests/)). + +When proposing a PR: + +- Describe what problem it solves, what side effects come with it. +- Adding some screenshots will help. +- Add some documentation if relevant. +- Add some comments around blocks/functions if relevant. + +Some reasons why a PR could be refused: + +- PR is not meeting one of the previous points. +- PR is not meeting project goals. +- PR is conflicting with another PR, and the latter is being preferred. +- PR slows down Cinny, or it obviously does too many + computations for the task being accomplished. It needs to be optimized. +- PR is using copy-n-paste-programming. It needs to be factorized. +- PR contains commented code: remove it. +- PR adds new features or changes the behavior of Cinny without + having be approved by the current project owners first. +- PR is too big and needs to be splitted in many smaller ones. +- PR contains unnecessary "space/indentations fixes". + +If a PR stays in a stale/WIP/POC state for too long, it may be closed +at any time. + + +## Styleguides +### Commit Messages +Always write a clear log message for your commits. One-line messages are fine for small changes, but bigger changes should look like this: + + $ git commit -m "A brief summary of the commit + > + > A paragraph describing what changed and its impact." + +### Coding conventions +We use [ESLint](https://eslint.org/) for clean and stylistically consistent code syntax. \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ff85e6a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Ajay Bura (ajbura) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..b3cc043 --- /dev/null +++ b/README.md @@ -0,0 +1,11 @@ +# Cinny + +## Table of Contents + +- [About](#about) +- [Getting Started](https://cinny.in) +- [Contributing](./CONTRIBUTING.md) + +## About + +Cinny is a [matrix](https://matrix.org) client focusing primarily on simple, elegant and secure interface. diff --git a/olm.wasm b/olm.wasm new file mode 100644 index 0000000000000000000000000000000000000000..97cce63b97b23a042422ed18ca7cdcad096cb11d GIT binary patch literal 153551 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

sME^{^8~1C0n*A=qQK%P43Y+o0x&s( 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
a>EZOSok2<8iAPX;5Y<@5HF+@JJvEX{=Dw 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 literal 0 HcmV?d00001 diff --git a/package.json b/package.json new file mode 100644 index 0000000..ad211e2 --- /dev/null +++ b/package.json @@ -0,0 +1,76 @@ +{ + "name": "cinny", + "version": "1.0.0", + "description": "Organized and powerful matrix client.", + "main": "index.js", + "engines": { + "npm": "6.14.11", + "node": "14.6.0" + }, + "scripts": { + "start": "webpack serve --config ./webpack.dev.js --open", + "build": "webpack --config ./webpack.prod.js" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "@tippyjs/react": "^4.2.5", + "babel-polyfill": "^6.26.0", + "browser-encrypt-attachment": "^0.3.0", + "dateformat": "^4.5.1", + "emojibase-data": "^6.2.0", + "flux": "^4.0.1", + "fuse.js": "^6.4.6", + "html-react-parser": "^1.2.7", + "linkifyjs": "^3.0.0-beta.3", + "matrix-js-sdk": "^11.2.0", + "olm": "https://packages.matrix.org/npm/olm/olm-3.2.1.tgz", + "prop-types": "^15.7.2", + "react": "^17.0.2", + "react-autosize-textarea": "^7.1.0", + "react-dom": "^17.0.2", + "react-google-recaptcha": "^2.1.0", + "react-markdown": "^6.0.1", + "react-modal": "^3.13.1", + "react-router-dom": "^5.2.0", + "react-syntax-highlighter": "^15.4.3", + "remark-gfm": "^1.0.0", + "tippy.js": "^6.3.1", + "twemoji": "^13.1.0" + }, + "devDependencies": { + "@babel/core": "^7.13.13", + "@babel/preset-env": "^7.13.12", + "@babel/preset-react": "^7.13.13", + "babel-loader": "^8.2.2", + "browserify-fs": "^1.0.0", + "buffer": "^6.0.3", + "clean-webpack-plugin": "^3.0.0", + "crypto-browserify": "^3.12.0", + "css-loader": "^5.2.0", + "css-minimizer-webpack-plugin": "^1.3.0", + "eslint": "^7.23.0", + "eslint-config-airbnb": "^18.2.1", + "eslint-plugin-import": "^2.22.1", + "eslint-plugin-jsx-a11y": "^6.4.1", + "eslint-plugin-react": "^7.23.1", + "eslint-plugin-react-hooks": "^4.2.0", + "favicons": "^6.2.1", + "favicons-webpack-plugin": "^5.0.2", + "file-loader": "^6.2.0", + "html-loader": "^2.1.2", + "html-webpack-plugin": "^5.3.1", + "mini-css-extract-plugin": "^1.4.0", + "path-browserify": "^1.0.1", + "sass": "^1.32.8", + "sass-loader": "^11.0.1", + "stream-browserify": "^3.0.0", + "style-loader": "^2.0.0", + "util": "^0.12.3", + "webpack": "^5.28.0", + "webpack-cli": "^4.5.0", + "webpack-dev-server": "^3.11.2", + "webpack-merge": "^5.7.3" + } +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..280e6dc --- /dev/null +++ b/public/index.html @@ -0,0 +1,22 @@ + + + + + + + + Cinny + + + + + + + + + + + +
+ + \ No newline at end of file diff --git a/public/res/ic/outlined/add-user.svg b/public/res/ic/outlined/add-user.svg new file mode 100644 index 0000000..c3803d8 --- /dev/null +++ b/public/res/ic/outlined/add-user.svg @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/public/res/ic/outlined/ball.svg b/public/res/ic/outlined/ball.svg new file mode 100644 index 0000000..d4b89ff --- /dev/null +++ b/public/res/ic/outlined/ball.svg @@ -0,0 +1,12 @@ + + + + + + diff --git a/public/res/ic/outlined/bell.svg b/public/res/ic/outlined/bell.svg new file mode 100644 index 0000000..d3d2f6d --- /dev/null +++ b/public/res/ic/outlined/bell.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/public/res/ic/outlined/bulb.svg b/public/res/ic/outlined/bulb.svg new file mode 100644 index 0000000..00e8088 --- /dev/null +++ b/public/res/ic/outlined/bulb.svg @@ -0,0 +1,10 @@ + + + + + + diff --git a/public/res/ic/outlined/chevron-bottom.svg b/public/res/ic/outlined/chevron-bottom.svg new file mode 100644 index 0000000..5562b7a --- /dev/null +++ b/public/res/ic/outlined/chevron-bottom.svg @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/public/res/ic/outlined/chevron-left.svg b/public/res/ic/outlined/chevron-left.svg new file mode 100644 index 0000000..ba9e12c --- /dev/null +++ b/public/res/ic/outlined/chevron-left.svg @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/public/res/ic/outlined/chevron-right.svg b/public/res/ic/outlined/chevron-right.svg new file mode 100644 index 0000000..7f6a806 --- /dev/null +++ b/public/res/ic/outlined/chevron-right.svg @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/public/res/ic/outlined/chevron-top.svg b/public/res/ic/outlined/chevron-top.svg new file mode 100644 index 0000000..f5948fe --- /dev/null +++ b/public/res/ic/outlined/chevron-top.svg @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/public/res/ic/outlined/circle-plus.svg b/public/res/ic/outlined/circle-plus.svg new file mode 100644 index 0000000..41690a0 --- /dev/null +++ b/public/res/ic/outlined/circle-plus.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/public/res/ic/outlined/coin.svg b/public/res/ic/outlined/coin.svg new file mode 100644 index 0000000..025424e --- /dev/null +++ b/public/res/ic/outlined/coin.svg @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/public/res/ic/outlined/cross.svg b/public/res/ic/outlined/cross.svg new file mode 100644 index 0000000..0acda88 --- /dev/null +++ b/public/res/ic/outlined/cross.svg @@ -0,0 +1,7 @@ + + + + + + diff --git a/public/res/ic/outlined/cup.svg b/public/res/ic/outlined/cup.svg new file mode 100644 index 0000000..8921e2c --- /dev/null +++ b/public/res/ic/outlined/cup.svg @@ -0,0 +1,9 @@ + + + + + + diff --git a/public/res/ic/outlined/dog.svg b/public/res/ic/outlined/dog.svg new file mode 100644 index 0000000..3b25295 --- /dev/null +++ b/public/res/ic/outlined/dog.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + diff --git a/public/res/ic/outlined/download.svg b/public/res/ic/outlined/download.svg new file mode 100644 index 0000000..677014f --- /dev/null +++ b/public/res/ic/outlined/download.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + diff --git a/public/res/ic/outlined/emoji.svg b/public/res/ic/outlined/emoji.svg new file mode 100644 index 0000000..0daac87 --- /dev/null +++ b/public/res/ic/outlined/emoji.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/public/res/ic/outlined/explore.svg b/public/res/ic/outlined/explore.svg new file mode 100644 index 0000000..7cc2a47 --- /dev/null +++ b/public/res/ic/outlined/explore.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/public/res/ic/outlined/external.svg b/public/res/ic/outlined/external.svg new file mode 100644 index 0000000..92b007c --- /dev/null +++ b/public/res/ic/outlined/external.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + diff --git a/public/res/ic/outlined/file.svg b/public/res/ic/outlined/file.svg new file mode 100644 index 0000000..d6a2a27 --- /dev/null +++ b/public/res/ic/outlined/file.svg @@ -0,0 +1,7 @@ + + + + + + diff --git a/public/res/ic/outlined/flag.svg b/public/res/ic/outlined/flag.svg new file mode 100644 index 0000000..8fce98d --- /dev/null +++ b/public/res/ic/outlined/flag.svg @@ -0,0 +1,8 @@ + + + + + + diff --git a/public/res/ic/outlined/hash-lock.svg b/public/res/ic/outlined/hash-lock.svg new file mode 100644 index 0000000..ae263ce --- /dev/null +++ b/public/res/ic/outlined/hash-lock.svg @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/public/res/ic/outlined/hash-plus.svg b/public/res/ic/outlined/hash-plus.svg new file mode 100644 index 0000000..69737fd --- /dev/null +++ b/public/res/ic/outlined/hash-plus.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/public/res/ic/outlined/hash-search.svg b/public/res/ic/outlined/hash-search.svg new file mode 100644 index 0000000..f135e89 --- /dev/null +++ b/public/res/ic/outlined/hash-search.svg @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/public/res/ic/outlined/hash-shield.svg b/public/res/ic/outlined/hash-shield.svg new file mode 100644 index 0000000..dfd344b --- /dev/null +++ b/public/res/ic/outlined/hash-shield.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/public/res/ic/outlined/hash.svg b/public/res/ic/outlined/hash.svg new file mode 100644 index 0000000..dcb8b96 --- /dev/null +++ b/public/res/ic/outlined/hash.svg @@ -0,0 +1,7 @@ + + + + + + diff --git a/public/res/ic/outlined/heart.svg b/public/res/ic/outlined/heart.svg new file mode 100644 index 0000000..c5b940b --- /dev/null +++ b/public/res/ic/outlined/heart.svg @@ -0,0 +1,10 @@ + + + + + + diff --git a/public/res/ic/outlined/home.svg b/public/res/ic/outlined/home.svg new file mode 100644 index 0000000..3c7a02d --- /dev/null +++ b/public/res/ic/outlined/home.svg @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/public/res/ic/outlined/inbox.svg b/public/res/ic/outlined/inbox.svg new file mode 100644 index 0000000..6543587 --- /dev/null +++ b/public/res/ic/outlined/inbox.svg @@ -0,0 +1,9 @@ + + + + + + diff --git a/public/res/ic/outlined/invite-arrow.svg b/public/res/ic/outlined/invite-arrow.svg new file mode 100644 index 0000000..370bf8e --- /dev/null +++ b/public/res/ic/outlined/invite-arrow.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/public/res/ic/outlined/invite-cancel-arrow.svg b/public/res/ic/outlined/invite-cancel-arrow.svg new file mode 100644 index 0000000..795a773 --- /dev/null +++ b/public/res/ic/outlined/invite-cancel-arrow.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/public/res/ic/outlined/invite.svg b/public/res/ic/outlined/invite.svg new file mode 100644 index 0000000..3896e15 --- /dev/null +++ b/public/res/ic/outlined/invite.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/public/res/ic/outlined/join-arrow.svg b/public/res/ic/outlined/join-arrow.svg new file mode 100644 index 0000000..90cfa65 --- /dev/null +++ b/public/res/ic/outlined/join-arrow.svg @@ -0,0 +1,8 @@ + + + + + + diff --git a/public/res/ic/outlined/leave-arrow.svg b/public/res/ic/outlined/leave-arrow.svg new file mode 100644 index 0000000..a51ac1d --- /dev/null +++ b/public/res/ic/outlined/leave-arrow.svg @@ -0,0 +1,8 @@ + + + + + + diff --git a/public/res/ic/outlined/lock.svg b/public/res/ic/outlined/lock.svg new file mode 100644 index 0000000..77021f0 --- /dev/null +++ b/public/res/ic/outlined/lock.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/public/res/ic/outlined/pause.svg b/public/res/ic/outlined/pause.svg new file mode 100644 index 0000000..c312613 --- /dev/null +++ b/public/res/ic/outlined/pause.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + diff --git a/public/res/ic/outlined/peace.svg b/public/res/ic/outlined/peace.svg new file mode 100644 index 0000000..8a7c81a --- /dev/null +++ b/public/res/ic/outlined/peace.svg @@ -0,0 +1,9 @@ + + + + + + diff --git a/public/res/ic/outlined/photo.svg b/public/res/ic/outlined/photo.svg new file mode 100644 index 0000000..af01a33 --- /dev/null +++ b/public/res/ic/outlined/photo.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/public/res/ic/outlined/play.svg b/public/res/ic/outlined/play.svg new file mode 100644 index 0000000..87b3a8f --- /dev/null +++ b/public/res/ic/outlined/play.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/public/res/ic/outlined/plus.svg b/public/res/ic/outlined/plus.svg new file mode 100644 index 0000000..ce37594 --- /dev/null +++ b/public/res/ic/outlined/plus.svg @@ -0,0 +1,7 @@ + + + + + + diff --git a/public/res/ic/outlined/power.svg b/public/res/ic/outlined/power.svg new file mode 100644 index 0000000..8aeb6db --- /dev/null +++ b/public/res/ic/outlined/power.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/public/res/ic/outlined/reply-arrow.svg b/public/res/ic/outlined/reply-arrow.svg new file mode 100644 index 0000000..3cda01c --- /dev/null +++ b/public/res/ic/outlined/reply-arrow.svg @@ -0,0 +1,7 @@ + + + + + + diff --git a/public/res/ic/outlined/search.svg b/public/res/ic/outlined/search.svg new file mode 100644 index 0000000..75dd632 --- /dev/null +++ b/public/res/ic/outlined/search.svg @@ -0,0 +1,8 @@ + + + + + + diff --git a/public/res/ic/outlined/send.svg b/public/res/ic/outlined/send.svg new file mode 100644 index 0000000..aa48713 --- /dev/null +++ b/public/res/ic/outlined/send.svg @@ -0,0 +1,7 @@ + + + + + + diff --git a/public/res/ic/outlined/settings.svg b/public/res/ic/outlined/settings.svg new file mode 100644 index 0000000..ee640b3 --- /dev/null +++ b/public/res/ic/outlined/settings.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + diff --git a/public/res/ic/outlined/shield.svg b/public/res/ic/outlined/shield.svg new file mode 100644 index 0000000..9bb46fa --- /dev/null +++ b/public/res/ic/outlined/shield.svg @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/public/res/ic/outlined/space-lock.svg b/public/res/ic/outlined/space-lock.svg new file mode 100644 index 0000000..b15705c --- /dev/null +++ b/public/res/ic/outlined/space-lock.svg @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/public/res/ic/outlined/space.svg b/public/res/ic/outlined/space.svg new file mode 100644 index 0000000..a4b54b3 --- /dev/null +++ b/public/res/ic/outlined/space.svg @@ -0,0 +1,10 @@ + + + + + + diff --git a/public/res/ic/outlined/sun.svg b/public/res/ic/outlined/sun.svg new file mode 100644 index 0000000..d8ed06f --- /dev/null +++ b/public/res/ic/outlined/sun.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/res/ic/outlined/tick-mark.svg b/public/res/ic/outlined/tick-mark.svg new file mode 100644 index 0000000..8e76ed5 --- /dev/null +++ b/public/res/ic/outlined/tick-mark.svg @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/public/res/ic/outlined/user.svg b/public/res/ic/outlined/user.svg new file mode 100644 index 0000000..6756a1b --- /dev/null +++ b/public/res/ic/outlined/user.svg @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/public/res/ic/outlined/vertical-menu.svg b/public/res/ic/outlined/vertical-menu.svg new file mode 100644 index 0000000..ec5c544 --- /dev/null +++ b/public/res/ic/outlined/vertical-menu.svg @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/public/res/ic/outlined/vlc.svg b/public/res/ic/outlined/vlc.svg new file mode 100644 index 0000000..8a2b844 --- /dev/null +++ b/public/res/ic/outlined/vlc.svg @@ -0,0 +1,8 @@ + + + + + + diff --git a/public/res/ic/outlined/volume-full.svg b/public/res/ic/outlined/volume-full.svg new file mode 100644 index 0000000..20419e7 --- /dev/null +++ b/public/res/ic/outlined/volume-full.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + diff --git a/public/res/ic/outlined/volume-mute.svg b/public/res/ic/outlined/volume-mute.svg new file mode 100644 index 0000000..beb0677 --- /dev/null +++ b/public/res/ic/outlined/volume-mute.svg @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/public/res/svg/cinny.svg b/public/res/svg/cinny.svg new file mode 100644 index 0000000..8701d67 --- /dev/null +++ b/public/res/svg/cinny.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/res/svg/matrix-logo.svg b/public/res/svg/matrix-logo.svg new file mode 100644 index 0000000..93c0eea --- /dev/null +++ b/public/res/svg/matrix-logo.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/app/atoms/avatar/Avatar.jsx b/src/app/atoms/avatar/Avatar.jsx new file mode 100644 index 0000000..616cea6 --- /dev/null +++ b/src/app/atoms/avatar/Avatar.jsx @@ -0,0 +1,57 @@ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import './Avatar.scss'; + +import Text from '../text/Text'; +import RawIcon from '../system-icons/RawIcon'; + +function Avatar({ + text, bgColor, iconSrc, imageSrc, size, +}) { + const [image, updateImage] = useState(imageSrc); + let textSize = 's1'; + if (size === 'large') textSize = 'h1'; + if (size === 'small') textSize = 'b1'; + if (size === 'extra-small') textSize = 'b3'; + + useEffect(() => updateImage(imageSrc), [imageSrc]); + + return ( +
+ { + image !== null + ? updateImage(null)} alt="avatar" /> + : ( + + { + iconSrc !== null + ? + : text !== null && {text} + } + + ) + } +
+ ); +} + +Avatar.defaultProps = { + text: null, + bgColor: 'transparent', + iconSrc: null, + imageSrc: null, + size: 'normal', +}; + +Avatar.propTypes = { + text: PropTypes.string, + bgColor: PropTypes.string, + iconSrc: PropTypes.string, + imageSrc: PropTypes.string, + size: PropTypes.oneOf(['large', 'normal', 'small', 'extra-small']), +}; + +export default Avatar; diff --git a/src/app/atoms/avatar/Avatar.scss b/src/app/atoms/avatar/Avatar.scss new file mode 100644 index 0000000..d7ddc6e --- /dev/null +++ b/src/app/atoms/avatar/Avatar.scss @@ -0,0 +1,52 @@ +.avatar-container { + display: inline-flex; + width: 42px; + height: 42px; + border-radius: var(--bo-radius); + position: relative; + + &__large { + width: var(--av-large); + height: var(--av-large); + } + &__normal { + width: var(--av-normal); + height: var(--av-normal); + } + + &__small { + width: var(--av-small); + height: var(--av-small); + } + + &__extra-small { + width: var(--av-extra-small); + height: var(--av-extra-small); + } + + + img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: inherit; + } + + .avatar__bordered { + box-shadow: var(--bs-surface-border); + } + + .avatar__border { + position: absolute; + top: 0; + left: 0; + + width: 100%; + height: 100%; + border-radius: inherit; + + .text { + color: var(--tc-primary-high); + } + } +} \ No newline at end of file diff --git a/src/app/atoms/badge/NotificationBadge.jsx b/src/app/atoms/badge/NotificationBadge.jsx new file mode 100644 index 0000000..846f99a --- /dev/null +++ b/src/app/atoms/badge/NotificationBadge.jsx @@ -0,0 +1,28 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import './NotificationBadge.scss'; + +import Text from '../text/Text'; + +function NotificationBadge({ alert, children }) { + const notificationClass = alert ? ' notification-badge--alert' : ''; + return ( +
+ {children} +
+ ); +} + +NotificationBadge.defaultProps = { + alert: false, +}; + +NotificationBadge.propTypes = { + alert: PropTypes.bool, + children: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number, + ]).isRequired, +}; + +export default NotificationBadge; diff --git a/src/app/atoms/badge/NotificationBadge.scss b/src/app/atoms/badge/NotificationBadge.scss new file mode 100644 index 0000000..797edae --- /dev/null +++ b/src/app/atoms/badge/NotificationBadge.scss @@ -0,0 +1,18 @@ +.notification-badge { + min-width: 18px; + padding: 1px var(--sp-ultra-tight); + background-color: var(--tc-surface-low); + border-radius: 9px; + + .text { + color: var(--bg-surface-low); + text-align: center; + } + + &--alert { + background-color: var(--bg-positive); + .text { + color: white; + } + } +} \ No newline at end of file diff --git a/src/app/atoms/button/Button.jsx b/src/app/atoms/button/Button.jsx new file mode 100644 index 0000000..b6e4a0f --- /dev/null +++ b/src/app/atoms/button/Button.jsx @@ -0,0 +1,47 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import './Button.scss'; + +import Text from '../text/Text'; +import RawIcon from '../system-icons/RawIcon'; +import { blurOnBubbling } from './script'; + +function Button({ + id, variant, iconSrc, type, onClick, children, disabled, +}) { + const iconClass = (iconSrc === null) ? '' : `btn-${variant}--icon`; + return ( + + ); +} + +Button.defaultProps = { + id: '', + variant: 'surface', + iconSrc: null, + type: 'button', + onClick: null, + disabled: false, +}; + +Button.propTypes = { + id: PropTypes.string, + variant: PropTypes.oneOf(['surface', 'primary', 'caution', 'danger']), + iconSrc: PropTypes.string, + type: PropTypes.oneOf(['button', 'submit']), + onClick: PropTypes.func, + children: PropTypes.node.isRequired, + disabled: PropTypes.bool, +}; + +export default Button; diff --git a/src/app/atoms/button/Button.scss b/src/app/atoms/button/Button.scss new file mode 100644 index 0000000..224c634 --- /dev/null +++ b/src/app/atoms/button/Button.scss @@ -0,0 +1,83 @@ +@use 'state'; + +.btn-surface, +.btn-primary, +.btn-caution, +.btn-danger { + display: inline-flex; + align-items: center; + justify-content: center; + + min-width: 80px; + padding: var(--sp-extra-tight) var(--sp-normal); + background-color: transparent; + border: none; + border-radius: var(--bo-radius); + cursor: pointer; + @include state.disabled; + + &--icon { + padding: { + left: var(--sp-tight); + right: var(--sp-loose); + } + + [dir=rtl] & { + padding: { + left: var(--sp-loose); + right: var(--sp-tight); + } + } + + .ic-raw { + margin-right: var(--sp-extra-tight); + + [dir=rtl] & { + margin: { + right: 0; + left: var(--sp-extra-tight); + } + } + } + } +} + +@mixin color($textColor, $iconColor) { + .text { + color: $textColor; + } + .ic-raw { + background-color: $iconColor; + } +} + + +.btn-surface { + box-shadow: var(--bs-surface-border); + @include color(var(--tc-surface-high), var(--ic-surface-normal)); + @include state.hover(var(--bg-surface-hover)); + @include state.focus(var(--bs-surface-outline)); + @include state.active(var(--bg-surface-active)); +} + +.btn-primary { + background-color: var(--bg-primary); + @include color(var(--tc-primary-high), var(--ic-primary-normal)); + @include state.hover(var(--bg-primary-hover)); + @include state.focus(var(--bs-primary-outline)); + @include state.active(var(--bg-primary-active)); +} +.btn-caution { + box-shadow: var(--bs-caution-border); + @include color(var(--tc-caution-high), var(--ic-caution-normal)); + @include state.hover(var(--bg-caution-hover)); + @include state.focus(var(--bs-caution-outline)); + @include state.active(var(--bg-caution-active)); +} +.btn-danger { + box-shadow: var(--bs-danger-border); + @include color(var(--tc-danger-high), var(--ic-danger-normal)); + @include state.hover(var(--bg-danger-hover)); + @include state.focus(var(--bs-danger-outline)); + @include state.active(var(--bg-danger-active)); +} \ No newline at end of file diff --git a/src/app/atoms/button/IconButton.jsx b/src/app/atoms/button/IconButton.jsx new file mode 100644 index 0000000..cda6f98 --- /dev/null +++ b/src/app/atoms/button/IconButton.jsx @@ -0,0 +1,60 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import './IconButton.scss'; + +import Tippy from '@tippyjs/react'; +import RawIcon from '../system-icons/RawIcon'; +import { blurOnBubbling } from './script'; +import Text from '../text/Text'; + +// TODO: +// 1. [done] an icon only button have "src" +// 2. have multiple variant +// 3. [done] should have a smart accessibility "label" arial-label +// 4. [done] have size as RawIcon + +const IconButton = React.forwardRef(({ + variant, size, type, + tooltip, tooltipPlacement, src, onClick, +}, ref) => ( + {tooltip}} + className="ic-btn-tippy" + touch="hold" + arrow={false} + maxWidth={250} + placement={tooltipPlacement} + delay={[0, 0]} + duration={[100, 0]} + > + + +)); + +IconButton.defaultProps = { + variant: 'surface', + size: 'normal', + type: 'button', + tooltipPlacement: 'top', + onClick: null, +}; + +IconButton.propTypes = { + variant: PropTypes.oneOf(['surface']), + size: PropTypes.oneOf(['normal', 'small', 'extra-small']), + type: PropTypes.oneOf(['button', 'submit']), + tooltip: PropTypes.string.isRequired, + tooltipPlacement: PropTypes.oneOf(['top', 'right', 'bottom', 'left']), + src: PropTypes.string.isRequired, + onClick: PropTypes.func, +}; + +export default IconButton; diff --git a/src/app/atoms/button/IconButton.scss b/src/app/atoms/button/IconButton.scss new file mode 100644 index 0000000..7bd327d --- /dev/null +++ b/src/app/atoms/button/IconButton.scss @@ -0,0 +1,45 @@ +@use 'state'; + +.ic-btn-surface, +.ic-btn-primary, +.ic-btn-caution, +.ic-btn-danger { + padding: var(--sp-extra-tight); + border: none; + border-radius: var(--bo-radius); + background-color: transparent; + font-size: 0; + line-height: 0; + cursor: pointer; + @include state.disabled; +} + +@mixin color($color) { + .ic-raw { + background-color: $color; + } +} +@mixin focus($color) { + &:focus { + outline: none; + background-color: $color; + } +} + +.ic-btn-surface { + @include color(var(--ic-surface-normal)); + @include state.hover(var(--bg-surface-hover)); + @include focus(var(--bg-surface-hover)); + @include state.active(var(--bg-surface-active)); +} + +.ic-btn-tippy { + padding: var(--sp-extra-tight) var(--sp-normal); + background-color: var(--bg-tooltip); + border-radius: var(--bo-radius); + box-shadow: var(--bs-popup); + + .text { + color: var(--tc-tooltip); + } +} \ No newline at end of file diff --git a/src/app/atoms/button/Toggle.jsx b/src/app/atoms/button/Toggle.jsx new file mode 100644 index 0000000..5d83c49 --- /dev/null +++ b/src/app/atoms/button/Toggle.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import './Toggle.scss'; + +function Toggle({ isActive, onToggle }) { + return ( + // eslint-disable-next-line jsx-a11y/control-has-associated-label + + + ); +} + +MenuItem.defaultProps = { + variant: 'surface', + iconSrc: 'none', + type: 'button', +}; + +MenuItem.propTypes = { + variant: PropTypes.oneOf(['surface', 'caution', 'danger']), + iconSrc: PropTypes.string, + type: PropTypes.oneOf(['button', 'submit']), + onClick: PropTypes.func.isRequired, + children: PropTypes.string.isRequired, +}; + +function MenuBorder() { + return
; +} + +export { + ContextMenu as default, MenuHeader, MenuItem, MenuBorder, +}; diff --git a/src/app/atoms/context-menu/ContextMenu.scss b/src/app/atoms/context-menu/ContextMenu.scss new file mode 100644 index 0000000..82a645b --- /dev/null +++ b/src/app/atoms/context-menu/ContextMenu.scss @@ -0,0 +1,71 @@ +.context-menu { + background-color: var(--bg-surface); + box-shadow: var(--bs-popup); + border-radius: var(--bo-radius); + overflow: hidden; + + &:focus { + outline: none; + } + & .tippy-content > div > .scrollbar { + max-height: 90vh; + } +} + +.context-menu__click-wrapper { + display: inline-flex; + + &:focus { + outline: none; + } +} + +.context-menu__header { + height: 34px; + padding: 0 var(--sp-tight); + margin-bottom: var(--sp-ultra-tight); + display: flex; + align-items: center; + border-bottom: 1px solid var(--bg-surface-border); + + .text { + color: var(--tc-surface-low); + } + + &:not(:first-child) { + margin-top: var(--sp-normal); + border-top: 1px solid var(--bg-surface-border); + } +} + +.context-menu__item { + button[class^="btn"] { + width: 100%; + justify-content: start; + border-radius: 0; + box-shadow: none; + + .text:first-child { + margin: { + left: calc(var(--ic-small) + var(--sp-ultra-tight)); + right: var(--sp-extra-tight); + } + + [dir=rtl] & { + margin: { + left: var(--sp-extra-tight); + right: calc(var(--ic-small) + var(--sp-ultra-tight)); + } + } + } + } + .btn-surface:focus { + background-color: var(--bg-surface-hover); + } + .btn-caution:focus { + background-color: var(--bg-caution-hover); + } + .btn-danger:focus { + background-color: var(--bg-danger-hover); + } +} \ No newline at end of file diff --git a/src/app/atoms/divider/Divider.jsx b/src/app/atoms/divider/Divider.jsx new file mode 100644 index 0000000..479fcec --- /dev/null +++ b/src/app/atoms/divider/Divider.jsx @@ -0,0 +1,29 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import './Divider.scss'; + +import Text from '../text/Text'; + +function Divider({ text, variant }) { + const dividerClass = ` divider--${variant}`; + return ( +
+ {text !== false && {text}} +
+ ); +} + +Divider.defaultProps = { + text: false, + variant: 'surface', +}; + +Divider.propTypes = { + text: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.bool, + ]), + variant: PropTypes.oneOf(['surface', 'primary', 'caution', 'danger']), +}; + +export default Divider; diff --git a/src/app/atoms/divider/Divider.scss b/src/app/atoms/divider/Divider.scss new file mode 100644 index 0000000..ded59af --- /dev/null +++ b/src/app/atoms/divider/Divider.scss @@ -0,0 +1,68 @@ +.divider { + --local-divider-color: var(--bg-surface-border); + + margin: var(--sp-extra-tight) var(--sp-normal); + margin-right: var(--sp-extra-tight); + display: flex; + align-items: center; + position: relative; + + &::before { + content: ""; + display: inline-block; + flex: 1; + margin-left: calc(var(--av-small) + var(--sp-tight)); + border-bottom: 1px solid var(--local-divider-color); + opacity: 0.18; + + [dir=rtl] & { + margin: { + left: 0; + right: calc(var(--av-small) + var(--sp-tight)); + } + } + } + + &__text { + margin-left: var(--sp-normal); + } + + [dir=rtl] & { + margin: { + left: var(--sp-extra-tight); + right: var(--sp-normal); + } + + &__text { + margin: { + left: 0; + right: var(--sp-normal); + } + } + } +} + +.divider--surface { + --local-divider-color: var(--tc-surface-low); + .divider__text { + color: var(--tc-surface-low); + } +} +.divider--primary { + --local-divider-color: var(--bg-primary); + .divider__text { + color: var(--bg-primary); + } +} +.divider--danger { + --local-divider-color: var(--bg-danger); + .divider__text { + color: var(--bg-danger); + } +} +.divider--caution { + --local-divider-color: var(--bg-caution); + .divider__text { + color: var(--bg-caution); + } +} \ No newline at end of file diff --git a/src/app/atoms/header/Header.jsx b/src/app/atoms/header/Header.jsx new file mode 100644 index 0000000..3c81e42 --- /dev/null +++ b/src/app/atoms/header/Header.jsx @@ -0,0 +1,29 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import './Header.scss'; + +function Header({ children }) { + return ( +
+ {children} +
+ ); +} + +Header.propTypes = { + children: PropTypes.node.isRequired, +}; + +function TitleWrapper({ children }) { + return ( +
+ {children} +
+ ); +} + +TitleWrapper.propTypes = { + children: PropTypes.node.isRequired, +}; + +export { Header as default, TitleWrapper }; diff --git a/src/app/atoms/header/Header.scss b/src/app/atoms/header/Header.scss new file mode 100644 index 0000000..05b1a15 --- /dev/null +++ b/src/app/atoms/header/Header.scss @@ -0,0 +1,63 @@ +.header { + padding: { + left: var(--sp-normal); + right: var(--sp-extra-tight); + } + width: 100%; + height: var(--header-height); + border-bottom: 1px solid var(--bg-surface-border); + display: flex; + align-items: center; + + [dir=rtl] & { + padding: { + left: var(--sp-extra-tight); + right: var(--sp-normal); + } + } + + &__title-wrapper { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + margin: 0 var(--sp-tight); + + &:first-child { + margin-left: 0; + [dir=rtl] & { + margin-right: 0; + } + } + + & > .text:first-child { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + & > .text-b3{ + flex: 1; + min-width: 0; + + margin-top: var(--sp-ultra-tight); + margin-left: var(--sp-tight); + padding-left: var(--sp-tight); + border-left: 1px solid var(--bg-surface-border); + max-height: calc(2 * var(--lh-b3)); + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + display: -webkit-box; + + [dir=rtl] & { + margin-left: 0; + padding-left: 0; + border-left: none; + margin-right: var(--sp-tight); + padding-right: var(--sp-tight); + border-right: 1px solid var(--bg-surface-border); + } + } + } +} \ No newline at end of file diff --git a/src/app/atoms/input/Input.jsx b/src/app/atoms/input/Input.jsx new file mode 100644 index 0000000..c5401a3 --- /dev/null +++ b/src/app/atoms/input/Input.jsx @@ -0,0 +1,77 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import './Input.scss'; + +import TextareaAutosize from 'react-autosize-textarea'; + +function Input({ + id, label, value, placeholder, + required, type, onChange, forwardRef, + resizable, minHeight, onResize, state, +}) { + return ( +
+ { label !== '' && } + { resizable + ? ( + + ) : ( + + )} +
+ ); +} + +Input.defaultProps = { + id: null, + label: '', + value: '', + placeholder: '', + type: 'text', + required: false, + onChange: null, + forwardRef: null, + resizable: false, + minHeight: 46, + onResize: null, + state: 'normal', +}; + +Input.propTypes = { + id: PropTypes.string, + label: PropTypes.string, + value: PropTypes.string, + placeholder: PropTypes.string, + required: PropTypes.bool, + type: PropTypes.string, + onChange: PropTypes.func, + forwardRef: PropTypes.shape({}), + resizable: PropTypes.bool, + minHeight: PropTypes.number, + onResize: PropTypes.func, + state: PropTypes.oneOf(['normal', 'success', 'error']), +}; + +export default Input; diff --git a/src/app/atoms/input/Input.scss b/src/app/atoms/input/Input.scss new file mode 100644 index 0000000..d029205 --- /dev/null +++ b/src/app/atoms/input/Input.scss @@ -0,0 +1,40 @@ +.input { + display: block; + width: 100%; + min-width: 0px; + padding: var(--sp-tight) var(--sp-normal); + background-color: var(--bg-surface-low); + color: var(--tc-surface-normal); + box-shadow: none; + border-radius: var(--bo-radius); + border: 1px solid var(--bg-surface-border); + font-size: var(--fs-b2); + letter-spacing: var(--ls-b2); + line-height: var(--lh-b2); + + &__label { + display: inline-block; + margin-bottom: var(--sp-ultra-tight); + color: var(--tc-surface-low); + } + + &--resizable { + resize: vertical !important; + } + &--success { + border: 1px solid var(--bg-positive); + box-shadow: none !important; + } + &--error { + border: 1px solid var(--bg-danger); + box-shadow: none !important; + } + + &:focus { + outline: none; + box-shadow: var(--bs-primary-border); + } + &::placeholder { + color: var(--tc-surface-low) + } +} \ No newline at end of file diff --git a/src/app/atoms/modal/RawModal.jsx b/src/app/atoms/modal/RawModal.jsx new file mode 100644 index 0000000..995ac60 --- /dev/null +++ b/src/app/atoms/modal/RawModal.jsx @@ -0,0 +1,67 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import './RawModal.scss'; + +import Modal from 'react-modal'; + +Modal.setAppElement('#root'); + +function RawModal({ + className, overlayClassName, + isOpen, size, onAfterOpen, onAfterClose, + onRequestClose, closeFromOutside, children, +}) { + let modalClass = (className !== null) ? `${className} ` : ''; + switch (size) { + case 'large': + modalClass += 'raw-modal__large '; + break; + case 'medium': + modalClass += 'raw-modal__medium '; + break; + case 'small': + default: + modalClass += 'raw-modal__small '; + } + const modalOverlayClass = (overlayClassName !== null) ? `${overlayClassName} ` : ''; + return ( + + {children} + + ); +} + +RawModal.defaultProps = { + className: null, + overlayClassName: null, + size: 'small', + onAfterOpen: null, + onAfterClose: null, + onRequestClose: null, + closeFromOutside: true, +}; + +RawModal.propTypes = { + className: PropTypes.string, + overlayClassName: PropTypes.string, + isOpen: PropTypes.bool.isRequired, + size: PropTypes.oneOf(['large', 'medium', 'small']), + onAfterOpen: PropTypes.func, + onAfterClose: PropTypes.func, + onRequestClose: PropTypes.func, + closeFromOutside: PropTypes.bool, + children: PropTypes.node.isRequired, +}; + +export default RawModal; diff --git a/src/app/atoms/modal/RawModal.scss b/src/app/atoms/modal/RawModal.scss new file mode 100644 index 0000000..d008cc0 --- /dev/null +++ b/src/app/atoms/modal/RawModal.scss @@ -0,0 +1,63 @@ +.ReactModal__Overlay { + opacity: 0; + transition: opacity 200ms cubic-bezier(0.13, 0.56, 0.25, 0.99); +} +.ReactModal__Overlay--after-open{ + opacity: 1; +} +.ReactModal__Overlay--before-close{ + opacity: 0; +} + +.ReactModal__Content { + transform: translateY(100%); + transition: transform 200ms cubic-bezier(0.13, 0.56, 0.25, 0.99); +} + +.ReactModal__Content--after-open{ + transform: translateY(0); +} + +.ReactModal__Content--before-close{ + transform: translateY(100%); +} + +.raw-modal { + --small-modal-width: 525px; + --medium-modal-width: 712px; + --large-modal-width: 1024px; + + + width: 100%; + max-height: 100%; + border-radius: var(--bo-radius); + box-shadow: var(--bs-popup); + outline: none; + overflow: hidden; + + &__small { + max-width: var(--small-modal-width); + } + &__medium { + max-width: var(--medium-modal-width); + } + &__large { + max-width: var(--large-modal-width); + } + + &__overlay { + position: fixed; + top: 0; + left: 0; + z-index: 999; + + display: flex; + justify-content: center; + align-items: center; + + padding: var(--sp-normal); + width: 100%; + height: 100%; + background-color: var(--bg-overlay); + } +} \ No newline at end of file diff --git a/src/app/atoms/scroll/ScrollView.jsx b/src/app/atoms/scroll/ScrollView.jsx new file mode 100644 index 0000000..26c0c83 --- /dev/null +++ b/src/app/atoms/scroll/ScrollView.jsx @@ -0,0 +1,37 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import './ScrollView.scss'; + +const ScrollView = React.forwardRef(({ + horizontal, vertical, autoHide, invisible, onScroll, children, +}, ref) => { + let scrollbarClasses = ''; + if (horizontal) scrollbarClasses += ' scrollbar__h'; + if (vertical) scrollbarClasses += ' scrollbar__v'; + if (autoHide) scrollbarClasses += ' scrollbar--auto-hide'; + if (invisible) scrollbarClasses += ' scrollbar--invisible'; + return ( +
+ {children} +
+ ); +}); + +ScrollView.defaultProps = { + horizontal: false, + vertical: true, + autoHide: false, + invisible: false, + onScroll: null, +}; + +ScrollView.propTypes = { + horizontal: PropTypes.bool, + vertical: PropTypes.bool, + autoHide: PropTypes.bool, + invisible: PropTypes.bool, + onScroll: PropTypes.func, + children: PropTypes.node.isRequired, +}; + +export default ScrollView; diff --git a/src/app/atoms/scroll/ScrollView.scss b/src/app/atoms/scroll/ScrollView.scss new file mode 100644 index 0000000..6c7d709 --- /dev/null +++ b/src/app/atoms/scroll/ScrollView.scss @@ -0,0 +1,22 @@ +@use '_scrollbar'; + +.scrollbar { + width: 100%; + height: 100%; + @include scrollbar.scroll; + + &__h { + @include scrollbar.scroll__h; + } + + &__v { + @include scrollbar.scroll__v; + } + + &--auto-hide { + @include scrollbar.scroll--auto-hide; + } + &--invisible { + @include scrollbar.scroll--invisible; + } +} \ No newline at end of file diff --git a/src/app/atoms/scroll/_scrollbar.scss b/src/app/atoms/scroll/_scrollbar.scss new file mode 100644 index 0000000..5baaaa6 --- /dev/null +++ b/src/app/atoms/scroll/_scrollbar.scss @@ -0,0 +1,62 @@ +.firefox-scrollbar { + scrollbar-width: thin; + scrollbar-color: var(--bg-surface-hover) transparent; + &--transparent { + scrollbar-color: transparent transparent; + } +} +.webkit-scrollbar { + &::-webkit-scrollbar { + width: 8px; + height: 8px; + } +} +.webkit-scrollbar-track { + &::-webkit-scrollbar-track { + background-color: transparent; + } +} +.webkit-scrollbar-thumb { + &::-webkit-scrollbar-thumb { + background-color: var(--bg-surface-hover); + } + &::-webkit-scrollbar-thumb:hover { + background-color: var(--bg-surface-active); + } + &--transparent { + &::-webkit-scrollbar-thumb { + background-color: transparent; + } + } +} + +@mixin scroll { + overflow: hidden; + @extend .firefox-scrollbar; + @extend .webkit-scrollbar; + @extend .webkit-scrollbar-track; + @extend .webkit-scrollbar-thumb; +} + +@mixin scroll__h { + overflow-x: scroll; +} +@mixin scroll__v { + overflow-y: scroll; +} +@mixin scroll--auto-hide { + @extend .firefox-scrollbar--transparent; + @extend .webkit-scrollbar-thumb--transparent; + + &:hover { + @extend .firefox-scrollbar; + @extend .webkit-scrollbar-thumb; + } +} +@mixin scroll--invisible { + -ms-overflow-style: none; + scrollbar-width: none; + &::-webkit-scrollbar { + display: none; + } +} \ No newline at end of file diff --git a/src/app/atoms/segmented-controls/SegmentedControls.jsx b/src/app/atoms/segmented-controls/SegmentedControls.jsx new file mode 100644 index 0000000..2faaf2b --- /dev/null +++ b/src/app/atoms/segmented-controls/SegmentedControls.jsx @@ -0,0 +1,51 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import './SegmentedControls.scss'; + +import { blurOnBubbling } from '../button/script'; + +import Text from '../text/Text'; +import RawIcon from '../system-icons/RawIcon'; + +function SegmentedControls({ + selected, segments, onSelect, +}) { + const [select, setSelect] = useState(selected); + + function selectSegment(segmentIndex) { + setSelect(segmentIndex); + onSelect(segmentIndex); + } + + return ( +
+ { + segments.map((segment, index) => ( + + )) + } +
+ ); +} + +SegmentedControls.propTypes = { + selected: PropTypes.number.isRequired, + segments: PropTypes.arrayOf(PropTypes.shape({ + iconSrc: PropTypes.string, + text: PropTypes.string, + })).isRequired, + onSelect: PropTypes.func.isRequired, +}; + +export default SegmentedControls; diff --git a/src/app/atoms/segmented-controls/SegmentedControls.scss b/src/app/atoms/segmented-controls/SegmentedControls.scss new file mode 100644 index 0000000..6df4130 --- /dev/null +++ b/src/app/atoms/segmented-controls/SegmentedControls.scss @@ -0,0 +1,61 @@ +@use '../button/state'; + +.segmented-controls { + background-color: var(--bg-surface-low); + border-radius: var(--bo-radius); + border: 1px solid var(--bg-surface-border); + + display: inline-flex; + overflow: hidden; +} + +.segment-btn { + padding: var(--sp-extra-tight) 0; + cursor: pointer; + @include state.hover(var(--bg-surface-hover)); + @include state.active(var(--bg-surface-active)); + + &__base { + padding: 0 var(--sp-normal); + display: flex; + align-items: center; + justify-content: center; + border-left: 1px solid var(--bg-surface-border); + + [dir=rtl] & { + border-left: none; + border-right: 1px solid var(--bg-surface-border); + } + + & .text:nth-child(2) { + margin: 0 var(--sp-extra-tight); + } + } + &:first-child &__base { + border: none; + } + + &--active { + background-color: var(--bg-surface); + border: 1px solid var(--bg-surface-border); + border-width: 0 1px 0 1px; + + & .segment-btn__base, + & + .segment-btn .segment-btn__base { + border: none; + } + &:first-child{ + border-left: none; + } + &:last-child { + border-right: none; + } + [dir=rtl] & { + border-left: 1px solid var(--bg-surface-border); + border-right: 1px solid var(--bg-surface-border); + + &:first-child { border-right: none;} + &:last-child { border-left: none;} + } + } +} \ No newline at end of file diff --git a/src/app/atoms/spinner/Spinner.jsx b/src/app/atoms/spinner/Spinner.jsx new file mode 100644 index 0000000..61c9747 --- /dev/null +++ b/src/app/atoms/spinner/Spinner.jsx @@ -0,0 +1,19 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import './Spinner.scss'; + +function Spinner({ size }) { + return ( +
+ ); +} + +Spinner.defaultProps = { + size: 'normal', +}; + +Spinner.propTypes = { + size: PropTypes.oneOf(['normal', 'small']), +}; + +export default Spinner; diff --git a/src/app/atoms/spinner/Spinner.scss b/src/app/atoms/spinner/Spinner.scss new file mode 100644 index 0000000..73fbf67 --- /dev/null +++ b/src/app/atoms/spinner/Spinner.scss @@ -0,0 +1,22 @@ +.donut-spinner { + display: inline-block; + border: 4px solid var(--bg-surface-border); + border-left-color: var(--tc-surface-normal); + border-radius: 50%; + animation: donut-spin 1.2s cubic-bezier(0.73, 0.32, 0.67, 0.86) infinite; + + &--normal { + width: 40px; + height: 40px; + } + &--small { + width: 28px; + height: 28px; + } +} + +@keyframes donut-spin { + to { + transform: rotate(1turn); + } +} \ No newline at end of file diff --git a/src/app/atoms/system-icons/RawIcon.jsx b/src/app/atoms/system-icons/RawIcon.jsx new file mode 100644 index 0000000..dff91ea --- /dev/null +++ b/src/app/atoms/system-icons/RawIcon.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import './RawIcon.scss'; + +function RawIcon({ color, size, src }) { + const style = { + WebkitMaskImage: `url(${src})`, + maskImage: `url(${src})`, + }; + if (color !== null) style.backgroundColor = color; + return ; +} + +RawIcon.defaultProps = { + color: null, + size: 'normal', +}; + +RawIcon.propTypes = { + color: PropTypes.string, + size: PropTypes.oneOf(['large', 'normal', 'small', 'extra-small']), + src: PropTypes.string.isRequired, +}; + +export default RawIcon; diff --git a/src/app/atoms/system-icons/RawIcon.scss b/src/app/atoms/system-icons/RawIcon.scss new file mode 100644 index 0000000..fa74069 --- /dev/null +++ b/src/app/atoms/system-icons/RawIcon.scss @@ -0,0 +1,25 @@ +@mixin icSize($size) { + width: $size; + height: $size; +} + +.ic-raw { + display: inline-block; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-size: cover; + mask-size: cover; + background-color: var(--ic-surface-normal); +} +.ic-raw-large { + @include icSize(var(--ic-large)); +} +.ic-raw-normal { + @include icSize(var(--ic-normal)); +} +.ic-raw-small { + @include icSize(var(--ic-small)); +} +.ic-raw-extra-small { + @include icSize(var(--ic-extra-small)); +} \ No newline at end of file diff --git a/src/app/atoms/text/Text.jsx b/src/app/atoms/text/Text.jsx new file mode 100644 index 0000000..cbd8d01 --- /dev/null +++ b/src/app/atoms/text/Text.jsx @@ -0,0 +1,28 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import './Text.scss'; + +function Text({ + id, className, variant, children, +}) { + const cName = className !== '' ? `${className} ` : ''; + if (variant === 'h1') return

{ children }

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

{ children }

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

{ children }

; + return

{ children }

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

{roomTopic}

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