├── .dockerignore ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── assets ├── .babelrc ├── css │ └── app.css ├── js │ ├── actions │ │ ├── cryptoActions.js │ │ ├── messageActions.js │ │ ├── userActions.js │ │ └── usersAction.js │ ├── app.js │ ├── components │ │ ├── App.js │ │ ├── ChatSegment.js │ │ ├── ColorPicker.js │ │ ├── ExportKey.js │ │ ├── Home.js │ │ ├── HomepageLayout.js │ │ ├── InviteUserModal.js │ │ ├── Main.js │ │ ├── MainMenuDropdown.js │ │ ├── MessageForm.js │ │ ├── Nav.js │ │ ├── OnlineUsersDropdown.js │ │ ├── RenderedMessage.js │ │ ├── RenderedUrl.js │ │ ├── Root.js │ │ ├── SignUp.js │ │ ├── TagsDropdown.js │ │ └── UserModal.js │ ├── reducers │ │ ├── appReducer.js │ │ ├── cryptoReducer.js │ │ ├── index.js │ │ ├── messageReducer.js │ │ ├── userReducer.js │ │ └── usersReducer.js │ ├── registerServiceWorker.js │ ├── socket.js │ └── utils │ │ └── api.js ├── package-lock.json ├── package.json ├── static │ ├── favicon.ico │ ├── images │ │ ├── matt.jpg │ │ ├── openpgp.worker.min.js │ │ └── phoenix.png │ └── robots.txt ├── test │ ├── App.test.js │ ├── __mocks__ │ │ ├── fileMock.js │ │ └── styleMock.js │ └── mocks │ │ ├── index.js │ │ └── initialState.json ├── theme │ ├── semantic.less │ ├── site │ │ └── globals │ │ │ └── site.overrides │ └── theme.config ├── webpack.config.js └── webpack.config.prod.js ├── config ├── config.exs ├── dev.exs ├── prod.exs └── test.exs ├── datamodel.mwb ├── docker-compose.dev.yml ├── docker-compose.gcp.yml ├── docker-compose.prod.yml ├── docker-compose.yml ├── docker-entrypoint.sh ├── lib ├── app.ex ├── app │ ├── application.ex │ ├── message.ex │ ├── message_tag.ex │ ├── repo.ex │ ├── request.ex │ ├── tag.ex │ ├── team.ex │ ├── user.ex │ ├── user_manager │ │ ├── error_handler.ex │ │ ├── guardian.ex │ │ ├── pipeline.ex │ │ └── user_manager.ex │ └── user_request.ex ├── app_web.ex └── app_web │ ├── channels │ ├── presence.ex │ ├── room_channel.ex │ ├── user_channel.ex │ └── user_socket.ex │ ├── controllers │ ├── page_controller.ex │ ├── team_controller.ex │ └── user_controller.ex │ ├── endpoint.ex │ ├── gettext.ex │ ├── router.ex │ ├── templates │ ├── layout │ │ └── app.html.eex │ └── page │ │ └── index.html.eex │ └── views │ ├── error_helpers.ex │ ├── error_view.ex │ ├── layout_view.ex │ ├── message_view.ex │ ├── page_view.ex │ ├── request_view.ex │ ├── tag_view.ex │ ├── team_view.ex │ ├── user_request_view.ex │ └── user_view.ex ├── manifest.json ├── mix.exs ├── mix.lock ├── priv ├── gettext │ ├── en │ │ └── LC_MESSAGES │ │ │ └── errors.po │ └── errors.pot └── repo │ ├── migrations │ ├── 20180426162009_create_users.exs │ ├── 20180427194318_create_messages.exs │ ├── 20180427194621_create_tags.exs │ ├── 20180427194813_create_message_tags.exs │ ├── 20180427195200_create_teams.exs │ ├── 20180428193155_add_body_index_to_tags.exs │ ├── 20180428200542_add_name_index_to_teams.exs │ ├── 20180507182458_add_color_to_user.exs │ ├── 20180512131400_add_claims_to_teams.exs │ ├── 20180515170348_add_unique_constraint_to_team_names.exs │ ├── 20180527173825_add_url_data_to_message.exs │ ├── 20180530191317_add_unique_constraint_to_tags.exs │ ├── 20180603151531_create_requests.exs │ ├── 20180603153408_addscope_and_group_pub_key_to_requests.exs │ ├── 20180606185126_add_avatar_to_users.exs │ ├── 20180609181732_add_avatar_to_requests.exs │ ├── 20180616164207_add_nickname_to_team.exs │ ├── 20181209031824_add_public_key_to_users.exs │ ├── 20181213004517_add_encrypted_password_to_users.exs │ ├── 20181222201425_add_required_to_public_key_on_users.exs │ ├── 20181222211648_create_user_requests.exs │ └── 20181223162110_add_rejected_to_user_requests.exs │ └── seeds.exs ├── react-logo.png ├── scripts ├── attach.sh ├── run_dev.sh ├── run_gcp.sh └── run_prod.sh └── test ├── app_web ├── channels │ └── room_channel_test.exs └── views │ ├── layout_view_test.exs │ └── page_view_test.exs ├── support ├── channel_case.ex ├── conn_case.ex └── data_case.ex └── test_helper.exs /.dockerignore: -------------------------------------------------------------------------------- 1 | assets/node_modules 2 | _build/ 3 | deps/ 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # App artifacts 2 | /_build 3 | /db 4 | /deps 5 | /*.ez 6 | 7 | # Generated on crash by the VM 8 | erl_crash.dump 9 | 10 | # Generated on crash by NPM 11 | npm-debug.log 12 | 13 | # Static artifacts 14 | /assets/node_modules 15 | 16 | # Since we are building assets from assets/, 17 | # we ignore priv/static. You may want to comment 18 | # this depending on your deployment strategy. 19 | /priv/static/ 20 | 21 | # Files matching config/*.secret.exs pattern contain sensitive 22 | # data and you should not commit them into version control. 23 | # 24 | # Alternatively, you may comment the line below and commit the 25 | # secrets files as long as you replace their contents by environment 26 | # variables. 27 | /config/*.secret.exs 28 | .env 29 | db-key.json 30 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM elixir:1.6 2 | 3 | RUN apt-get update && apt-get install -qq -y inotify-tools curl libnotify-bin --fix-missing --no-install-recommends 4 | 5 | RUN curl -sL https://deb.nodesource.com/setup_12.x | bash - 6 | RUN apt-get update && apt-get install -qq -y nodejs tar --fix-missing --no-install-recommends 7 | 8 | ENV RUSTUP_HOME=/usr/local/rustup \ 9 | CARGO_HOME=/usr/local/cargo \ 10 | PATH=/usr/local/cargo/bin:$PATH \ 11 | RUST_VERSION=1.33.0 12 | RUN set -eux; \ 13 | \ 14 | dpkgArch="$(dpkg --print-architecture)"; \ 15 | case "${dpkgArch##*-}" in \ 16 | amd64) rustArch='x86_64-unknown-linux-gnu'; rustupSha256='c9837990bce0faab4f6f52604311a19bb8d2cde989bea6a7b605c8e526db6f02' ;; \ 17 | armhf) rustArch='armv7-unknown-linux-gnueabihf'; rustupSha256='297661e121048db3906f8c964999f765b4f6848632c0c2cfb6a1e93d99440732' ;; \ 18 | arm64) rustArch='aarch64-unknown-linux-gnu'; rustupSha256='a68ac2d400409f485cb22756f0b3217b95449884e1ea6fd9b70522b3c0a929b2' ;; \ 19 | i386) rustArch='i686-unknown-linux-gnu'; rustupSha256='27e6109c7b537b92a6c2d45ac941d959606ca26ec501d86085d651892a55d849' ;; \ 20 | *) echo >&2 "unsupported architecture: ${dpkgArch}"; exit 1 ;; \ 21 | esac; \ 22 | \ 23 | url="https://static.rust-lang.org/rustup/archive/1.11.0/${rustArch}/rustup-init"; \ 24 | wget "$url"; \ 25 | echo "${rustupSha256} *rustup-init" | sha256sum -c -; \ 26 | chmod +x rustup-init; \ 27 | ./rustup-init -y --no-modify-path --default-toolchain $RUST_VERSION; \ 28 | rm rustup-init; \ 29 | chmod -R a+w $RUSTUP_HOME $CARGO_HOME; \ 30 | rustup --version; \ 31 | cargo --version; \ 32 | rustc --version; 33 | 34 | WORKDIR /app 35 | COPY ./mix* ./ 36 | RUN mix local.hex --force 37 | RUN mix local.rebar --force 38 | RUN export MIX_ENV=prod && mix deps.get --force 39 | RUN cd deps/html5ever/native/html5ever_nif && cargo update 40 | RUN export MIX_ENV=prod && mix deps.compile 41 | 42 | COPY ./assets/package* ./assets/ 43 | RUN cd assets && npm i 44 | COPY ./assets ./assets/ 45 | RUN cd assets && npm run build 46 | RUN mkdir -p /app/priv/static/js 47 | RUN cp /app/assets/node_modules/openpgp/dist/openpgp.worker.min.js /app/priv/static/js 48 | RUN cp /app/assets/node_modules/openpgp/dist/openpgp.min.js /app/priv/static/js 49 | 50 | 51 | COPY ./manifest.json ./priv/static/ 52 | COPY ./react-logo.png ./priv/static/images/ 53 | COPY ./ ./ 54 | RUN export MIX_ENV=prod && mix compile --force 55 | RUN export MIX_ENV=prod && mix phx.digest 56 | RUN rm -rf deps/*/.fetch 57 | 58 | EXPOSE 4000 59 | ENTRYPOINT [ "./docker-entrypoint.sh" ] 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Kenneth Bergquist 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mentat 2 | 3 | [ ![Codeship Status for kenforthewin/mentat](https://app.codeship.com/projects/b39d7c00-3d9f-0136-ecdb-161825e9517a/status?branch=master)](https://app.codeship.com/projects/290692) 4 | 5 | Mentat is a group chat application with a focus on message tagging and privacy. It allows deep categorization and retrieval of messages based on tags (a la Twitter hashtags). It also aims for reasonable privacy, meaning everything aside from feature metadata is end-to-end encrypted with OpenPGP, including avatars. Feature metadata is anything that the server depends on in order to deliver a feature; tags are stored in plaintext in order to index and retrieve them from the database, and URLs are sent as plaintext so the server can ping them and generate a thumbnail. 6 | 7 | See it in action here: 8 | 9 | https://metachat.app 10 | 11 | ## Features 12 | 13 | - End-to-end encryption by default 14 | - Deeply embedded tagging system 15 | - Link previews 16 | - Web notifications 17 | 18 | ## Usage Instructions 19 | 20 | ### Inviting users 21 | 22 | Each room is identified by its UUID. To invite a user, either share the UUID found in the URL of the room, or simply share the URL. The user will be instructed to set a username, then a new request will be generated. Click the users icon in the upper left corner and accept the request to add the user to the group. 23 | 24 | ### Adding message tags 25 | 26 | Message tagging is the key feature of Mentat. There are several ways to add a tag to a message: 27 | 28 | 1. Embed a tag in the message, like you can with a tweet. 29 | 2. Select a tag or several tags from the tag dropdown. When you send a message, all the selected tags will be added to the message. 30 | 3. Click the plus icon next to a message after it is sent. Type the tag and press Enter. 31 | 32 | ### Browsing tags 33 | 34 | When you start a session, no tags are selected. In this view, you will see every message that is sent, and you can scroll through all previous messages. When you select a tag, you will only see past messages that have that tag, and you will only receive messages with that tag. You can select several tags to sort by a number of categories, allowing quick access to past messages on the topic that interests you. Use this feature to categorize your messages based on project, memes, events, etc. 35 | 36 | ## User Authentication 37 | 38 | Like the [Web Auth API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API), Mentat uses asymmetric cryptography for authentication. When you first join a chat room, a personal keypair will be generated and stored in browser storage. If you are the creator of this room, the client will also generate a keypair for the room. If not, a request will be generated: the client will send its personal public key to the server and request access to the room. Someone who already has the group keypair must accept the request to grant you access. When the member accepts your request, her client will encrypt the room private key with your public key and send the encrypted key to the server. Now your client can grab the room key, decrypt it, and begin decrypting the room's messages. 39 | 40 | Right now, it's the user's responsibility to use a secure device that only she has access to. On the roadmap, a user could specify a temporary session that would be deleted after a certain amount of time or inactivity. 41 | 42 | ## Stack 43 | 44 | - fully containerized 45 | 46 | ### Server 47 | 48 | - Phoenix/Elixir 49 | - Postgres 50 | 51 | ### Client 52 | 53 | - React/Redux 54 | - OpenPGP.js 55 | 56 | ## Development 57 | 58 | Ensure that Docker and docker-compose are installed and the Docker daemon is running. Start the development environment by navigating to the root of the project and running the following script: `./scripts/run_dev.sh`. Once the compilation and Javascript build are complete, the app will be available at `http://localhost:4000`. 59 | 60 | ## Project Structure 61 | 62 | ## Invitation to contribute 63 | -------------------------------------------------------------------------------- /assets/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["env", "react", "stage-2"] 3 | } -------------------------------------------------------------------------------- /assets/css/app.css: -------------------------------------------------------------------------------- 1 | .visible.transition { 2 | margin-top: auto !important; 3 | display: inline-block !important; 4 | position: relative; 5 | top: 20%; 6 | } 7 | 8 | .newTagInput input { 9 | width: 8px; 10 | -webkit-transition: width 2s; /* Safari */ 11 | transition: width 2s; 12 | } 13 | 14 | .newTagInput input:focus { 15 | width: 100px; 16 | } -------------------------------------------------------------------------------- /assets/js/actions/cryptoActions.js: -------------------------------------------------------------------------------- 1 | let openpgp = require('openpgp'); 2 | import randomWords from 'random-words'; 3 | 4 | export const burnBrowser = () => { 5 | return { 6 | type: 'burn_browser' 7 | } 8 | } 9 | 10 | export const importKey = (publicKey, privateKey, passphrase, requests) => { 11 | return async (dispatch, _) => { 12 | dispatch({ 13 | type: 'new_key', 14 | publicKey, 15 | privateKey, 16 | passphrase 17 | }) 18 | const privKeyObj = openpgp.key.readArmored(privateKey).keys[0]; 19 | await privKeyObj.decrypt(passphrase) 20 | requests.requests.forEach(async (request) => { 21 | const privateGroupKeyOptions = { 22 | message: openpgp.message.readArmored(request.encrypted_team_private_key), 23 | privateKeys: [privKeyObj] 24 | }; 25 | const privateKey = await openpgp.decrypt(privateGroupKeyOptions) 26 | dispatch({ 27 | type: 'new_group_key', 28 | room: request.team_name, 29 | publicKey: request.team_public_key, 30 | privateKey: privateKey.data, 31 | name: request.team_nickname 32 | }) 33 | }) 34 | } 35 | } 36 | 37 | export const approveRequest = (publicKey, encryptedPrivateKey, encryptedPassphrase, requests) => { 38 | return async (dispatch, getState) => { 39 | const state = getState(); 40 | var privKeyObj = openpgp.key.readArmored(state.cryptoReducer.privateKey).keys[0]; 41 | await privKeyObj.decrypt(state.cryptoReducer.passphrase) 42 | const privateKeyOptions = { 43 | message: openpgp.message.readArmored(encryptedPrivateKey), 44 | privateKeys: [privKeyObj] 45 | }; 46 | const privateKey = await openpgp.decrypt(privateKeyOptions) 47 | const passphraseOptions = { 48 | message: openpgp.message.readArmored(encryptedPassphrase), 49 | privateKeys: [privKeyObj] 50 | }; 51 | const passphrase = await openpgp.decrypt(passphraseOptions) 52 | const newPrivateKey = privateKey.data 53 | const newPassphrase = passphrase.data 54 | dispatch({ 55 | type: 'new_key', 56 | publicKey, 57 | privateKey: newPrivateKey, 58 | passphrase: newPassphrase 59 | }) 60 | privKeyObj = openpgp.key.readArmored(newPrivateKey).keys[0]; 61 | await privKeyObj.decrypt(newPassphrase) 62 | requests.requests.forEach(async (request) => { 63 | const privateGroupKeyOptions = { 64 | message: openpgp.message.readArmored(request.encrypted_team_private_key), 65 | privateKeys: [privKeyObj] 66 | }; 67 | const privateKey = await openpgp.decrypt(privateGroupKeyOptions) 68 | dispatch({ 69 | type: 'new_group_key', 70 | room: request.team_name, 71 | publicKey: request.team_public_key, 72 | privateKey: privateKey.data, 73 | name: request.team_nickname 74 | }) 75 | }) 76 | } 77 | } 78 | 79 | export const generateKeypair = () => { 80 | return (dispatch, getState) => { 81 | const passphrase = randomWords({ exactly: 10, join: ' ' }) 82 | const options = { 83 | userIds: [{ name:'Example Example', email:'example@example.com' }], 84 | numBits: 2048, 85 | passphrase: passphrase 86 | }; 87 | 88 | openpgp.generateKey(options).then((key) => { 89 | const privateKey = key.privateKeyArmored; 90 | const publicKey = key.publicKeyArmored; 91 | dispatch({ 92 | type: 'new_key', 93 | passphrase, 94 | privateKey, 95 | publicKey 96 | }); 97 | }); 98 | } 99 | } 100 | 101 | export const generateGroupKeypair = (room, roomName) => { 102 | return (dispatch, getState) => { 103 | const options = { 104 | userIds: [{ name:'Example Example', email:'example@example.com' }], 105 | numBits: 2048, 106 | passphrase: '' 107 | }; 108 | 109 | openpgp.generateKey(options).then((key) => { 110 | const privateKey = key.privateKeyArmored; 111 | const publicKey = key.publicKeyArmored; 112 | return dispatch({ 113 | type: 'new_group_key', 114 | privateKey, 115 | publicKey, 116 | room, 117 | name: roomName 118 | }); 119 | }); 120 | } 121 | } 122 | 123 | export const receiveGroupKeypair = (room, publicKey, encryptedPrivateKey, users = [], name = '') => { 124 | return (dispatch, getState) => { 125 | const state = getState(); 126 | var privKeyObj = openpgp.key.readArmored(state.cryptoReducer.privateKey).keys[0]; 127 | privKeyObj.decrypt(state.cryptoReducer.passphrase).then(() => { 128 | const options = { 129 | message: openpgp.message.readArmored(encryptedPrivateKey), 130 | privateKeys: [privKeyObj] 131 | }; 132 | openpgp.decrypt(options).then((plaintext) => { 133 | dispatch({ 134 | type: 'new_group_key', 135 | privateKey: plaintext.data, 136 | publicKey, 137 | room, 138 | name 139 | }); 140 | const groupPrivateKey = openpgp.key.readArmored(plaintext.data).keys[0]; 141 | users.forEach((user) => { 142 | if (user.avatar) { 143 | const groupOptions = { 144 | privateKeys: [groupPrivateKey], 145 | message: openpgp.message.readArmored(user.avatar) 146 | } 147 | openpgp.decrypt(groupOptions).then((plaintext) => { 148 | const addUser = { 149 | ...user, 150 | avatar: plaintext.data 151 | }; 152 | dispatch({type: 'add_user', user: addUser}) 153 | }); 154 | } 155 | }); 156 | }); 157 | }); 158 | } 159 | } 160 | 161 | export const newGroupName = (room, nickname) => { 162 | return { 163 | type: 'new_group_name', 164 | room, 165 | nickname 166 | } 167 | } 168 | 169 | export const setGroupPublic = (room) => { 170 | return { 171 | type: 'set_public', 172 | room: room 173 | } 174 | } -------------------------------------------------------------------------------- /assets/js/actions/messageActions.js: -------------------------------------------------------------------------------- 1 | export const addMessage = (message) => { 2 | return { 3 | type: 'add_message', 4 | id: message.id, 5 | message 6 | } 7 | }; 8 | 9 | export const newUrl = (id, urlData, tag) => { 10 | return (dispatch, _) => { 11 | dispatch({ 12 | type: 'new_url', 13 | id, 14 | urlData 15 | }) 16 | 17 | if (tag) { 18 | dispatch({ 19 | type: 'new_tag', 20 | id, 21 | tag 22 | }) 23 | } 24 | } 25 | }; 26 | 27 | export const newTag = (id, tag) => { 28 | return { 29 | type: 'new_tag', 30 | id, 31 | tag 32 | } 33 | }; 34 | 35 | export const refreshTags = (id, tags) => { 36 | return { 37 | type: 'update_tags', 38 | id, 39 | tags 40 | } 41 | } 42 | 43 | export const removeTag = (id, tag) => { 44 | return { 45 | type: 'remove_tag', 46 | id, 47 | tag 48 | } 49 | } -------------------------------------------------------------------------------- /assets/js/actions/userActions.js: -------------------------------------------------------------------------------- 1 | export const updateName = (name, color) => { 2 | return { 3 | type: 'set_name', 4 | name, 5 | color 6 | } 7 | } 8 | 9 | export const updateUrlPreviews = (urlPreviews) => { 10 | return { 11 | type: 'set_url_previews', 12 | urlPreviews 13 | } 14 | } 15 | 16 | export const signIn = (email, password) => { 17 | return (dispatch, _) => { 18 | fetch('/auth/login', { 19 | method: 'POST', 20 | headers: { 21 | 'Content-Type': 'application/json' 22 | }, 23 | body: JSON.stringify({ 24 | email, 25 | password 26 | }) 27 | }).then((response) => { 28 | return response.json(); 29 | }).then((response) => { 30 | if (response.error) { 31 | dispatch({ 32 | type: 'auth_errors', 33 | errors: { email: ['or password incorrect.']} 34 | }) 35 | } else { 36 | dispatch({ 37 | type: 'sign_in', 38 | token: response.jwt, 39 | id: response.id, 40 | name: response.name, 41 | color: response.color 42 | }) 43 | } 44 | }) 45 | } 46 | } 47 | 48 | export const expireToken = () => { 49 | return { 50 | type: 'expire_token' 51 | } 52 | } 53 | 54 | export const signUp = (email, password) => { 55 | return (dispatch, getState) => { 56 | const state = getState() 57 | fetch('/auth/sign_up', { 58 | method: 'POST', 59 | headers: { 60 | 'Content-Type': 'application/json' 61 | }, 62 | body: JSON.stringify({ 63 | email, 64 | password, 65 | publicKey: state.cryptoReducer.publicKey, 66 | color: state.userReducer.color 67 | }) 68 | }).then((response) => { 69 | return response.json(); 70 | }).then((response) => { 71 | if (response.errors) { 72 | dispatch({ 73 | type: 'auth_errors', 74 | errors: response.errors 75 | }) 76 | } else { 77 | dispatch({ 78 | type: 'sign_in', 79 | token: response.jwt, 80 | id: response.id, 81 | name: email 82 | }) 83 | } 84 | }) 85 | } 86 | } -------------------------------------------------------------------------------- /assets/js/actions/usersAction.js: -------------------------------------------------------------------------------- 1 | export const addUser = (user) => { 2 | return { 3 | type: 'add_user', 4 | user 5 | } 6 | } 7 | 8 | export const setLastSynced = (lastSynced) => { 9 | return { 10 | type: 'set_last_synced', 11 | lastSynced 12 | } 13 | } -------------------------------------------------------------------------------- /assets/js/app.js: -------------------------------------------------------------------------------- 1 | // Brunch automatically concatenates all files in your 2 | // watched paths. Those paths can be configured at 3 | // config.paths.watched in "brunch-config.js". 4 | // 5 | // However, those files will only be executed if 6 | // explicitly imported. The only exception are files 7 | // in vendor, which are never wrapped in imports and 8 | // therefore are always executed. 9 | 10 | // Import dependencies 11 | // 12 | // If you no longer want to use a dependency, remember 13 | // to also remove its path from "config.paths.watched". 14 | import "phoenix_html" 15 | import "../theme/semantic.less"; 16 | import '../node_modules/huebee/dist/huebee.css' 17 | import '../css/app.css'; 18 | import React from 'react' 19 | import ReactDOM from 'react-dom' 20 | import Root from './components/Root' 21 | import registerServiceWorker from './registerServiceWorker'; 22 | 23 | ReactDOM.render(, document.getElementById('root')) 24 | registerServiceWorker(); -------------------------------------------------------------------------------- /assets/js/components/ChatSegment.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { Segment, Comment, Label, Ref} from 'semantic-ui-react' 3 | import RenderedMessage from './RenderedMessage'; 4 | 5 | class ChatSegment extends Component { 6 | constructor(props) { 7 | super(props) 8 | this.segmentStyles = { 9 | overflowY: 'scroll', 10 | overflowX: 'hidden', 11 | WebkitOverflowScrolling: 'touch', 12 | height: '100%' 13 | } 14 | this.chatSegment = React.createRef(); 15 | this.handleScroll = this.handleScroll.bind(this); 16 | this.scrollDown = this.scrollDown.bind(this); 17 | this.maybeScrollDown = this.maybeScrollDown.bind(this); 18 | this.scrolledDown = true; 19 | this.initialLoad = true 20 | } 21 | 22 | getSnapshotBeforeUpdate(prevProps, prevState) { 23 | return { scrollHeight: this.chatSegment.scrollHeight }; 24 | } 25 | 26 | componentDidMount() { 27 | setTimeout(() => { 28 | this.initialLoad = false; 29 | }, 5000) 30 | } 31 | 32 | componentDidUpdate(prevProps, prevState, snapshot) { 33 | if (prevProps.messageIds !== this.props.messageIds && this.props.messageIds.length > 0) { 34 | const node = this.chatSegment; 35 | if (this.props.updateType === 'append' && this.scrolledDown) { 36 | node.scrollTop = node.scrollHeight - node.clientHeight; 37 | } 38 | else if (this.props.updateType === 'prepend') { 39 | node.scrollTop = node.scrollHeight - snapshot.scrollHeight; 40 | } 41 | } 42 | } 43 | 44 | scrollDown() { 45 | this.chatSegment.scrollTop = this.chatSegment.scrollHeight - this.chatSegment.clientHeight; 46 | } 47 | 48 | maybeScrollDown() { 49 | if (this.initialLoad || this.scrolledDown) { 50 | this.scrollDown(); 51 | } 52 | } 53 | 54 | handleRef = node => this.chatSegment = node 55 | 56 | renderMessages() { 57 | const messages = this.props.messages; 58 | return messages.map((message, i) => { 59 | return ( 60 | 76 | ) 77 | }); 78 | } 79 | 80 | handleScroll(e) { 81 | const node = this.chatSegment; 82 | this.scrolledDown = node.scrollTop === node.scrollHeight - node.clientHeight; 83 | if (this.props.messagesLoading) { 84 | e.preventDefault() 85 | return false; 86 | } 87 | if (!this.props.lastMessageLoaded && !this.props.messagesLoading && node.scrollTop === 0 && this.props.messages.length > 0) { 88 | this.props.loadMoreMessages(); 89 | } 90 | } 91 | 92 | render() { 93 | return ( 94 | 95 | 96 | 97 | {this.renderMessages()} 98 | 99 | 102 | 103 | 104 | ); 105 | } 106 | } 107 | 108 | export default ChatSegment; -------------------------------------------------------------------------------- /assets/js/components/ColorPicker.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { Form } from 'semantic-ui-react' 3 | import Huebee from 'huebee' 4 | class ColorPicker extends Component { 5 | constructor(props) { 6 | super(props) 7 | } 8 | 9 | componentDidMount() { 10 | new Huebee(this.props.inputRef.current, {}) 11 | } 12 | 13 | render() { 14 | return ( 15 | 16 | 17 | 18 | ) 19 | } 20 | } 21 | 22 | export default ColorPicker; -------------------------------------------------------------------------------- /assets/js/components/ExportKey.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Segment, Button, Icon, Message, Container } from 'semantic-ui-react' 3 | import { connect } from 'react-redux' 4 | import { Redirect } from 'react-router-dom' 5 | import { saveAs } from 'file-saver'; 6 | 7 | class ExportKey extends Component { 8 | constructor(props) { 9 | super(props) 10 | 11 | this.onClick = this.onClick.bind(this) 12 | } 13 | 14 | onClick() { 15 | const { publicKey, privateKey, passphrase } = this.props.cryptoReducer 16 | const keyObject = { publicKey, privateKey, passphrase } 17 | const keyBlob = new Blob([JSON.stringify(keyObject)], {type: "text/plain;charset=utf-8"}); 18 | saveAs(keyBlob, "chat-key.json"); 19 | } 20 | 21 | render() { 22 | if (!this.props.userReducer.token) { 23 | return ( ) 24 | } 25 | return ( 26 | 27 |
28 | 29 | 30 | Export key 31 |

32 | Press the button below to export your private account key to a file. Keep this file safe and secure: you can use it to restore your account if you ever lose access. 33 |

34 |
35 |
36 | 37 |
38 |
39 | ) 40 | } 41 | } 42 | 43 | const mapStateToProps = (state) => { 44 | const { userReducer, cryptoReducer } = state 45 | return { userReducer, cryptoReducer } 46 | } 47 | 48 | export default connect(mapStateToProps, {})(ExportKey) -------------------------------------------------------------------------------- /assets/js/components/Home.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { Segment, Button, Header, Input, Icon, Form, Modal, Checkbox, Container } from 'semantic-ui-react' 3 | import { Link, Redirect } from 'react-router-dom'; 4 | import uuidv1 from 'uuid/v1'; 5 | import { connect } from 'react-redux'; 6 | import HomepageLayout from './HomepageLayout' 7 | 8 | class Home extends Component { 9 | constructor(props) { 10 | super(props); 11 | 12 | this.state = {buttonsDisabled: false, groupReady: false, groupForm: false, signUp: false, signIn: false} 13 | 14 | this.generateGroup = this.generateGroup.bind(this); 15 | this.redirectToGroup = this.redirectToGroup.bind(this); 16 | this.renderRecents = this.renderRecents.bind(this) 17 | this.loggedIn = this.loggedIn.bind(this) 18 | this.renderLoggedInNav = this.renderLoggedInNav.bind(this) 19 | 20 | this.inputGroupRef = React.createRef(); 21 | this.nameInput = React.createRef(); 22 | this.containerStyles = { 23 | display: 'flex', 24 | height: '100%', 25 | alignItems: 'center', 26 | justifyContent: 'center' 27 | } 28 | 29 | this.segmentStyles = { 30 | flex: '1' 31 | } 32 | } 33 | 34 | loggedIn() { 35 | return !!this.props.userReducer.token 36 | } 37 | 38 | generateGroup() { 39 | this.setState({...this.state, buttonsDisabled: true}) 40 | 41 | const group_uuid = uuidv1(); 42 | 43 | fetch('/api/teams', { 44 | method: 'POST', 45 | headers: { 46 | 'Content-Type': 'application/json', 47 | 'Authorization': `Bearer ${this.props.userReducer.token}` 48 | }, 49 | body: JSON.stringify({ 50 | uuid: group_uuid, 51 | name: this.nameInput.current.value 52 | }) 53 | }).then((response) => { 54 | return response.json(); 55 | }).then((response) => { 56 | this.setState({ 57 | groupReady: true, 58 | groupForm: false, 59 | groupUuid: response.uuid 60 | }) 61 | }) 62 | } 63 | 64 | redirectToGroup() { 65 | this.setState({ 66 | groupReady: true, 67 | groupUuid: this.inputGroupRef.inputRef.value 68 | }) 69 | } 70 | 71 | redirectNamedGroup(name) { 72 | this.inputGroupRef.inputRef.value = name; 73 | this.redirectToGroup(); 74 | } 75 | 76 | renderRecents() { 77 | const groups = this.props.cryptoReducer.groups; 78 | if (Object.keys(groups).length < 1) { 79 | return null; 80 | } 81 | const recents = Object.keys(groups).map((group, i) => { 82 | return (); 83 | }); 84 | return ( 85 | 86 |
Recent rooms
87 | {recents} 88 |
89 | ) 90 | } 91 | 92 | renderGroupModal() { 93 | return ( 94 | 95 | 96 |
97 | 98 | 99 | 100 |
101 |
102 | 103 | 106 | 107 |
108 | ) 109 | } 110 | 111 | renderLoggedInNav() { 112 | if (this.loggedIn()) { 113 | return ( 114 |
115 | {this.renderRecents()} 116 | 117 |
Enter room code
118 | } fluid ref={ref => this.inputGroupRef = ref} /> 119 |
120 | 121 |
Create a new room
122 | 123 |
124 |
125 | ) 126 | } 127 | } 128 | 129 | render() { 130 | if (!this.loggedIn()) { 131 | return ( 132 | 133 | ) 134 | } 135 | else if (this.state.groupReady) { 136 | return ( 137 | 138 | ) 139 | } else if (this.state.groupForm) { 140 | return this.renderGroupModal() 141 | } 142 | // else if (this.state.signUp && !this.props.userReducer.token) { 143 | // return ( 144 | // 145 | // ) 146 | // } else if (this.state.signIn && !this.props.userReducer.token) { 147 | // return ( 148 | // 149 | // ) 150 | // } 151 | return ( 152 | 153 |
154 |
155 |
156 |
157 |
158 | {this.renderLoggedInNav()} 159 |
160 |
161 |
162 | 163 | ); 164 | } 165 | } 166 | 167 | const mapStateToProps = (state) => { 168 | const {cryptoReducer, userReducer} = state; 169 | return {cryptoReducer, userReducer}; 170 | } 171 | 172 | export default connect(mapStateToProps, {})(Home); -------------------------------------------------------------------------------- /assets/js/components/HomepageLayout.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types' 2 | import React, { Component } from 'react' 3 | import { Link } from 'react-router-dom' 4 | import { 5 | Button, 6 | Container, 7 | Divider, 8 | Grid, 9 | Header, 10 | Icon, 11 | Image, 12 | List, 13 | Menu, 14 | Responsive, 15 | Segment, 16 | Sidebar, 17 | Visibility, 18 | } from 'semantic-ui-react' 19 | 20 | /* eslint-disable react/no-multi-comp */ 21 | /* Heads up! HomepageHeading uses inline styling, however it's not the best practice. Use CSS or styled components for 22 | * such things. 23 | */ 24 | const HomepageHeading = ({ mobile }) => ( 25 | 26 |
37 |
47 | 51 | 52 | ) 53 | 54 | HomepageHeading.propTypes = { 55 | mobile: PropTypes.bool, 56 | } 57 | 58 | /* Heads up! 59 | * Neither Semantic UI nor Semantic UI React offer a responsive navbar, however, it can be implemented easily. 60 | * It can be more complicated, but you can create really flexible markup. 61 | */ 62 | class DesktopContainer extends Component { 63 | state = {} 64 | 65 | hideFixedMenu = () => this.setState({ fixed: false }) 66 | showFixedMenu = () => this.setState({ fixed: true }) 67 | 68 | render() { 69 | const { children } = this.props 70 | const { fixed } = this.state 71 | 72 | return ( 73 | 74 | 80 | 81 | 82 | 83 | {children} 84 | 85 | ) 86 | } 87 | } 88 | 89 | DesktopContainer.propTypes = { 90 | children: PropTypes.node, 91 | } 92 | 93 | class MobileContainer extends Component { 94 | state = {} 95 | 96 | handleSidebarHide = () => this.setState({ sidebarOpened: false }) 97 | 98 | handleToggle = () => this.setState({ sidebarOpened: true }) 99 | 100 | render() { 101 | const { children } = this.props 102 | const { sidebarOpened } = this.state 103 | 104 | return ( 105 | 106 | 112 | 113 | 114 | {children} 115 | 116 | ) 117 | } 118 | } 119 | 120 | MobileContainer.propTypes = { 121 | children: PropTypes.node, 122 | } 123 | 124 | const ResponsiveContainer = ({ children }) => ( 125 |
126 | {children} 127 | {children} 128 |
129 | ) 130 | 131 | ResponsiveContainer.propTypes = { 132 | children: PropTypes.node, 133 | } 134 | 135 | const HomepageLayout = () => ( 136 | 137 | 138 | 139 | 140 | 141 |
142 | Chat smarter. 143 |
144 |

145 | Tag messages to create channels of content on the fly. Dynamically categorize your conversations, allowing quick retrieval of the content that matters to you. Metachat intelligently groups your shared links for you. 146 |

147 |
148 | Privacy matters. 149 |
150 |

151 | Your messages on Metachat are end-to-end encrypted: only the members of your group have access to your content. 152 |

153 |
154 | 155 | 156 | 157 |
158 |
159 |
160 | {/* 161 | 162 | 163 | 164 |
165 | "What a Company" 166 |
167 |

That is what they all say about us

168 |
169 | 170 |
171 | "I shouldn't have gone with their competitor." 172 |
173 |

174 | 175 | Nan Chief Fun Officer Acme Toys 176 |

177 |
178 |
179 |
180 |
181 | 182 | 183 |
184 | Breaking The Grid, Grabs Your Attention 185 |
186 |

187 | Instead of focusing on content creation and hard work, we have learned how to master the 188 | art of doing nothing by providing massive amounts of whitespace and generic content that 189 | can seem massive, monolithic and worth your attention. 190 |

191 | 194 | 200 | Case Studies 201 | 202 |
203 | Did We Tell You About Our Bananas? 204 |
205 |

206 | Yes I know you probably disregarded the earlier boasts as non-sequitur filler content, but 207 | it's really true. It took years of gene splicing and combinatory DNA research, but our 208 | bananas can really dance. 209 |

210 | 213 |
214 |
*/} 215 | 216 | 217 | 218 | 219 | 220 |
221 | 222 | Contact Us 223 | 224 | 225 | {/* 226 |
227 | 228 | Banana Pre-Order 229 | DNA FAQ 230 | How To Access 231 | Favorite X-Men 232 | 233 | */} 234 | 235 | 236 | 237 | {/*
238 | Footer Header 239 |
240 |

241 | Extra space for a call to action inside the footer that could help re-engage users. 242 |

*/} 243 |
244 | 245 | 246 | 247 | 248 | 249 | ) 250 | export default HomepageLayout -------------------------------------------------------------------------------- /assets/js/components/InviteUserModal.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import QRCode from 'qrcode' 3 | 4 | class InviteUserModal extends Component { 5 | constructor(props) { 6 | super(props); 7 | this.qr = React.createRef(); 8 | this.qrUrl = "https://metachat.app/t/" + this.props.qrInput; 9 | } 10 | 11 | componentDidMount() { 12 | QRCode.toCanvas(this.qr.current, this.qrUrl); 13 | } 14 | 15 | render() { 16 | return ( 17 | 18 | ); 19 | } 20 | } 21 | 22 | export default InviteUserModal; -------------------------------------------------------------------------------- /assets/js/components/Main.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { 4 | Route, 5 | Link, 6 | Redirect 7 | } from 'react-router-dom'; 8 | import { ConnectedRouter as Router } from 'react-router-redux'; 9 | import { Menu, Container, Modal, Header, Button, Icon, Segment, Sidebar } from 'semantic-ui-react'; 10 | import App from './App'; 11 | import Home from './Home'; 12 | import { Socket, Presence } from "phoenix" 13 | import { approveRequest, burnBrowser, receiveGroupKeypair } from '../actions/cryptoActions' 14 | import {persistor} from '../reducers/index'; 15 | import SignUp from './SignUp' 16 | import { signUp, signIn, expireToken } from '../actions/userActions' 17 | import { generateKeypair, importKey } from '../actions/cryptoActions' 18 | import Nav from './Nav' 19 | import ExportKey from './ExportKey' 20 | import HomepageLayout from './HomepageLayout'; 21 | 22 | let openpgp = require('openpgp'); 23 | 24 | class Main extends Component { 25 | constructor() { 26 | super(); 27 | this.state = { activeItem: 'home', userRequests: [], hasKeys: true }; 28 | this.handleItemClick = this.handleItemClick.bind(this); 29 | this.joinUserChannel = this.joinUserChannel.bind(this) 30 | this.approveUserRequest = this.approveUserRequest.bind(this) 31 | this.rejectUserRequest = this.rejectUserRequest.bind(this) 32 | this.loggedIn = this.loggedIn.bind(this) 33 | this.navApp = this.navApp.bind(this) 34 | this.maybeRenderNav = this.maybeRenderNav.bind(this) 35 | this.handleFile = this.handleFile.bind(this) 36 | 37 | this.fileRef = React.createRef() 38 | this.pgpWorkerStarted = openpgp.initWorker({ path:'/js/openpgp.worker.min.js' }) 39 | } 40 | 41 | componentDidMount() { 42 | if (this.props.userReducer.token) { 43 | this.joinUserChannel(); 44 | } 45 | } 46 | 47 | componentDidUpdate(prevProps, prevState, snapshot) { 48 | if (this.props.userReducer.token && !prevProps.userReducer.token) { 49 | this.joinUserChannel(); 50 | } 51 | } 52 | 53 | joinUserChannel() { 54 | let socket = new Socket("/socket", {params: {token: this.props.userReducer.token}}); 55 | socket.onError(() => { 56 | this.props.expireToken() 57 | window.location = '/sign-in' 58 | }) 59 | socket.connect(); 60 | this.channel = socket.channel(`user:${this.props.userReducer.uuid}`, {publicKey: this.props.cryptoReducer.publicKey}); 61 | this.channel.on("approve_user_request", payload => { 62 | if (payload.public_key === this.props.cryptoReducer.publicKey) { 63 | this.props.approveRequest(payload.new_public_key, payload.encrypted_private_key, payload.encrypted_passphrase, payload.requests); 64 | this.setState({hasKeys: true}) 65 | } 66 | }) 67 | this.channel.on('user_request', (payload) => { 68 | if (payload.public_key !== this.props.cryptoReducer.publicKey) { 69 | this.setState({ userRequests: [...this.state.userRequests, {public_key: payload.public_key}] }) 70 | } 71 | }) 72 | this.channel.join() 73 | .receive("ok", resp => { 74 | console.log(resp) 75 | this.setState({hasKeys: resp.has_keys}) 76 | if (!resp.has_keys && resp.encrypted_private_key) { 77 | this.props.approveRequest(resp.public_key, resp.encrypted_private_key, resp.encrypted_passphrase, resp.requests); 78 | } else if (resp.has_keys) { 79 | this.setState({ userRequests: resp.user_requests.user_requests }) 80 | resp.requests.requests.forEach((request) => { 81 | if (!this.props.cryptoReducer.groups[request.name]) { 82 | this.props.receiveGroupKeypair(request.team_name, request.team_public_key, request.encrypted_team_private_key, [], request.team_nickname) 83 | } 84 | }) 85 | } 86 | }) 87 | } 88 | 89 | handleItemClick(e, { name }) { this.setState({ activeItem: name }); } 90 | 91 | async approveUserRequest(publicKey) { 92 | const privateKeyOptions = { 93 | data: this.props.cryptoReducer.privateKey, 94 | publicKeys: openpgp.key.readArmored(publicKey).keys 95 | }; 96 | const encryptedPrivateKeyData = await openpgp.encrypt(privateKeyOptions) 97 | const encryptedPrivateKey = encryptedPrivateKeyData.data 98 | const passphraseOptions = { 99 | data: this.props.cryptoReducer.passphrase, 100 | publicKeys: openpgp.key.readArmored(publicKey).keys 101 | }; 102 | const encryptedPassphraseData = await openpgp.encrypt(passphraseOptions) 103 | const encryptedPassphrase = encryptedPassphraseData.data 104 | this.channel.push("approve_user_request", { 105 | publicKey, 106 | encryptedPrivateKey, 107 | encryptedPassphrase 108 | }) 109 | this.setState({ userRequests: this.state.userRequests.filter((e) => e.public_key !== publicKey) }) 110 | } 111 | 112 | rejectUserRequest(publicKey) { 113 | this.channel.push("reject_user_request", { publicKey }) 114 | this.setState({ userRequests: this.state.userRequests.filter((e) => e.public_key !== publicKey) }) 115 | } 116 | 117 | loggedIn() { 118 | return !!this.props.userReducer.token 119 | } 120 | 121 | navApp() { 122 | return this.props.routerReducer.location && this.props.routerReducer.location.pathname.startsWith('/t/'); 123 | } 124 | 125 | maybeRenderNav() { 126 | if (!this.navApp()) { 127 | return ( 128 |