├── .babelrc ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── images ├── add-app.png ├── alice-bob-chatting.png ├── database-firebase.png ├── find-users.png ├── firebaseconf.png ├── firebaseсonfig.png ├── open-two-windows.png ├── project-settings.png ├── sign-in-firebase.png └── sign-up.png ├── package-lock.json ├── package.json ├── src ├── App.tsx ├── components │ ├── AuthForm.tsx │ ├── Channels.tsx │ ├── ChatPrimitives.tsx │ ├── ChatWindow.tsx │ ├── InputField.tsx │ ├── Message.tsx │ ├── MessageField.tsx │ ├── Messages.tsx │ └── Primitives.tsx ├── declaration.d.ts ├── firebase.ts ├── fonts.css ├── index.html ├── index.tsx ├── models │ ├── AppState.ts │ ├── ChannelListModel.ts │ ├── ChannelModel.ts │ ├── ChatModel.ts │ ├── CryptoMessageList.ts │ ├── MessageListModel.ts │ ├── MessageStorage.ts │ ├── UserModel.ts │ └── helpers │ │ ├── FirebaseCollections.ts │ │ ├── Routes.ts │ │ └── base64UrlFromBase64.ts └── pages │ ├── AuthPage.tsx │ └── ChatPage.tsx ├── tsconfig.json ├── tslint.json └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/react", 4 | "@babel/env", 5 | "@babel/preset-typescript" 6 | ], 7 | "plugins": [ 8 | "@babel/proposal-class-properties", 9 | "@babel/proposal-object-rest-spread" 10 | ], 11 | "sourceMaps": "inline" 12 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .cache 4 | .vscode -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | printWidth: 100 2 | tabWidth: 4 3 | useTabs: false 4 | singleQuote: true 5 | trailingComma: "all" 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2020, Virgil Security, Inc. 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Virgil E3Kit JS Web + Firebase Demo 2 | 3 | This is an end-to-end encrypted HIPAA-compliant demo chat app for Firebase that is using [Virgil Security](https://virgilsecurity.com)'s [E3Kit JavaScript SDK](https://github.com/VirgilSecurity/virgil-e3kit-js). The demo allows you to register users and back up their private keys, create peer-to-peer channels and send end-to-end encrypted messages. 4 | 5 | You can reuse this sample in your projects to protect user data, documents, images using Virgil's end-to-end encryption [HIPAA whitepaper](https://virgilsecurity.com/wp-content/uploads/2018/07/Firebase-HIPAA-Chat-Whitepaper-Virgil-Security.pdf). 6 | 7 | After you set up the demo with the all credentials required in the instructions below you can run a chat for chatting: 8 | 9 | Chat screenshot 10 | 11 | > The demo is using E3Kit v2.3.3. 12 | 13 | ## Prerequisites 14 | 15 | * [Node v10](https://nodejs.org/en/download) or newer 16 | * [npm](https://www.npmjs.com/get-npm) or [yarn](https://yarnpkg.com/) 17 | 18 | ## Set up and run demo 19 | 20 | ### Clone JavaScript project 21 | 22 | ```bash 23 | git clone https://github.com/VirgilSecurity/demo-firebase-js 24 | cd demo-firebase-js 25 | ``` 26 | 27 | ### Connect your Virgil and Firebase accounts 28 | 29 | To connect your Virgil and Firebase accounts, you need to deploy a Firebase function that gives out Virgil JWT tokens for your authenticated users. 30 | 31 | To deploy the function, head over to our GitHub repo and follow the instructions in README: 32 | 33 | * **[Firebase function deployment instructions](https://github.com/VirgilSecurity/e3kit-firebase-func)** 34 | 35 | #### Configure Authentication 36 | 37 | At Firebase dashboard, select the **Authentication** panel and then click the **Sign In Method** tab. Choose the **E-mail** authentication method and click **Done**, then follow instructions and click **Save**. 38 | 39 | 40 |   41 | 42 | #### Configure Cloud Firestore 43 | 44 | Set up a Firestore database for the sample apps: 45 | 1. Select the **Cloud Firestore** panel 46 | 2. Click **Create database** under Firestore 47 | 3. Choose **Start in test mode** and click **Enable** 48 | 49 | Once the database is created, click on the **Rules** tab, click **Edit rules** and paste: 50 | ``` 51 | service cloud.firestore { 52 | match /databases/{database}/documents { 53 | match /{document=**} { 54 | allow read, write: if request.auth.uid != null; 55 | } 56 | } 57 | } 58 | ``` 59 | Now click **PUBLISH**. 60 | 61 | 62 | 63 |   64 | > You only need to do this once - if you had already done it earlier, you don't need to do it again. 65 | 66 | ### Add your Firebase project config to app 67 | 68 | 1. Go to the Firebase console -> your project's page in Firebase console, click the **gear icon** -> **Project settings** 69 | 70 |   71 | 72 | 2. Click **Add app** and choose **" Add Firebase to your web app"** 73 | Chat screenshot 74 |   75 | 76 | 3. Copy **only this part** to the clipboard: 77 | ``` 78 | var firebaseConfig = { 79 | apiKey: "...", 80 | authDomain: "...", 81 | databaseURL: "...", 82 | projectId: "...", 83 | storageBucket: "...", 84 | messagingSenderId: "..." 85 | }; 86 | ``` 87 | 88 | Chat screenshot 89 | 90 |   91 | 92 | 4. **Replace the copied block** in your `src/firebase.ts` file. 93 | 94 | Chat screenshot 95 |   96 | 97 | ### Build and run 98 | 99 | * **Update dependencies, build & run** 100 | ``` 101 | npm install 102 | npm run start 103 | ``` 104 | 105 | > In case you receive a message like `warning found n vulnerabilities` printed in the console after running the `npm install`, there is a potential security vulnerability in one of the demo's dependencies. Don't worry, this is a normal occurrence and in the majority of cases it is fixed by updating the packages. To install any updates, run the command `npm audit fix`. If some of the vulnerabilities persist after the update, check the results of the `npm audit` to see a detailed report. The report includes instructions on how to act on this information. 106 | 107 | ## Explore demo 108 | 109 | 1. **Browse to http://localhost:1234**. You will see a register/login form; you can use any e-mail and password to sign up a test user. 110 | 111 | Chat screenshot 112 | 113 | 2. Start a **second incognito or browser window** to have 2 chat apps running with 2 different users. Sign up one more user. 114 | 115 | 116 | 117 | 3. Click the "New Channel" to create a channel between the 2 users. 118 | 119 | Chat screenshot 120 | 121 |   122 | Now you can start sending encrypted messages between them. 123 | 124 | Chat screenshot 125 | 126 | > Remember, the app deletes messages right after delivery (it's a HIPAA requirement to meet the conduit exception). If you want to see encrypted messages in your Firestore database, run only 1 browser instance, send a message to your chat partner and check Firestore DB's contents before opening the other user's app to receive the message. If you don't want to implement this behavior in your own app, you can remove it from this sample [here](https://github.com/VirgilSecurity/demo-firebase-js/blob/d263f0ddd4f92f51ee2a925cdffd32a19a0387ae/src/models/MessageListModel.ts#L34). 127 | 128 | ## License 129 | 130 | This library is released under the [3-clause BSD License](LICENSE). 131 | 132 | ## Support 133 | Our developer support team is here to help you. Find out more information on our [Help Center](https://help.virgilsecurity.com/). 134 | 135 | You can find us on [Twitter](https://twitter.com/VirgilSecurity) or send us email support@VirgilSecurity.com. 136 | 137 | Also, get extra help from our support team on [Slack](https://virgilsecurity.com/join-community). 138 | -------------------------------------------------------------------------------- /images/add-app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VirgilSecurity/demo-firebase-js/f0bc57fb20bf583cedd456ee635a57db3149cadd/images/add-app.png -------------------------------------------------------------------------------- /images/alice-bob-chatting.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VirgilSecurity/demo-firebase-js/f0bc57fb20bf583cedd456ee635a57db3149cadd/images/alice-bob-chatting.png -------------------------------------------------------------------------------- /images/database-firebase.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VirgilSecurity/demo-firebase-js/f0bc57fb20bf583cedd456ee635a57db3149cadd/images/database-firebase.png -------------------------------------------------------------------------------- /images/find-users.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VirgilSecurity/demo-firebase-js/f0bc57fb20bf583cedd456ee635a57db3149cadd/images/find-users.png -------------------------------------------------------------------------------- /images/firebaseconf.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VirgilSecurity/demo-firebase-js/f0bc57fb20bf583cedd456ee635a57db3149cadd/images/firebaseconf.png -------------------------------------------------------------------------------- /images/firebaseсonfig.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VirgilSecurity/demo-firebase-js/f0bc57fb20bf583cedd456ee635a57db3149cadd/images/firebaseсonfig.png -------------------------------------------------------------------------------- /images/open-two-windows.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VirgilSecurity/demo-firebase-js/f0bc57fb20bf583cedd456ee635a57db3149cadd/images/open-two-windows.png -------------------------------------------------------------------------------- /images/project-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VirgilSecurity/demo-firebase-js/f0bc57fb20bf583cedd456ee635a57db3149cadd/images/project-settings.png -------------------------------------------------------------------------------- /images/sign-in-firebase.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VirgilSecurity/demo-firebase-js/f0bc57fb20bf583cedd456ee635a57db3149cadd/images/sign-in-firebase.png -------------------------------------------------------------------------------- /images/sign-up.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VirgilSecurity/demo-firebase-js/f0bc57fb20bf583cedd456ee635a57db3149cadd/images/sign-up.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo-firebase-js", 3 | "version": "2.4.0", 4 | "description": "Virgil Security Firebase JS Demo", 5 | "author": "Virgil Security Inc. ", 6 | "license": "BSD-3-Clause", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/VirgilSecurity/demo-twilio-chat-js" 10 | }, 11 | "main": "index.js", 12 | "dependencies": { 13 | "@babel/polyfill": "^7.11.5", 14 | "@babel/preset-react": "^7.10.4", 15 | "@types/styled-components": "^5.1.3", 16 | "@virgilsecurity/e3kit-browser": "^2.4.5", 17 | "css-loader": "^4.3.0", 18 | "date-fns": "^2.16.1", 19 | "firebase": "^7.21.0", 20 | "formik": "^2.1.5", 21 | "lodash": "^4.17.21", 22 | "normalize.css": "^8.0.1", 23 | "react": "^16.13.1", 24 | "react-dom": "^16.13.1", 25 | "style-loader": "^1.2.1", 26 | "styled-components": "^5.2.0", 27 | "virgil-crypto": "^4.2.2" 28 | }, 29 | "devDependencies": { 30 | "@babel/cli": "^7.15.7", 31 | "@babel/core": "^7.11.6", 32 | "@babel/plugin-proposal-class-properties": "^7.10.4", 33 | "@babel/plugin-proposal-object-rest-spread": "^7.11.0", 34 | "@babel/preset-env": "^7.11.5", 35 | "@babel/preset-typescript": "^7.10.4", 36 | "@types/lodash": "^4.14.161", 37 | "@types/react": "^16.9.49", 38 | "@types/react-dom": "^16.9.8", 39 | "@types/react-router-dom": "^5.1.5", 40 | "babel-loader": "^8.1.0", 41 | "babel-plugin-transform-runtime": "^6.23.0", 42 | "babel-polyfill": "^6.26.0", 43 | "babel-preset-env": "^1.7.0", 44 | "babel-preset-es2017": "^6.24.1", 45 | "babel-preset-react": "^6.24.1", 46 | "babel-runtime": "^6.26.0", 47 | "file-loader": "^6.1.0", 48 | "html-webpack-plugin": "^4.5.0", 49 | "prettier-tslint": "^0.4.2", 50 | "source-map-loader": "^1.1.0", 51 | "tslint": "^5.20.1", 52 | "tslint-config-prettier": "^1.18.0", 53 | "tslint-plugin-prettier": "^2.3.0", 54 | "tslint-react": "^4.1.0", 55 | "typescript": "^4.0.3", 56 | "webpack": "^4.44.2", 57 | "webpack-cli": "^3.3.12", 58 | "webpack-dev-server": "^3.11.0" 59 | }, 60 | "scripts": { 61 | "start": "webpack-dev-server --open", 62 | "test": "echo \"Error: no test specified\" && exit 1" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ChatPage from './pages/ChatPage'; 3 | import AuthPage from './pages/AuthPage'; 4 | 5 | import { createGlobalStyle } from 'styled-components'; 6 | import AppStore, { IAppStore } from './models/AppState'; 7 | import UserApi from './models/UserModel'; 8 | 9 | const GlobalStyle = createGlobalStyle` 10 | html { 11 | box-sizing: border-box; 12 | background-color: #fafafa; 13 | letter-spacing: 0.05em; 14 | font-family: Lato; 15 | } 16 | *, *:before, *:after { 17 | box-sizing: inherit; 18 | } 19 | 20 | @font-face { 21 | font-family: 'Lato'; 22 | font-weight: 400; 23 | font-style: normal; 24 | font-display: optional; 25 | src: url('https://cdn.virgilsecurity.com/assets/fonts/Lato/Lato-Regular.eot'); 26 | src: 27 | url('https://cdn.virgilsecurity.com/assets/fonts/Lato/Lato-Regular.eot?#iefix') format('embedded-opentype'), 28 | url('https://cdn.virgilsecurity.com/assets/fonts/Lato/Lato-Regular.woff2') format('woff2'), 29 | url('https://cdn.virgilsecurity.com/assets/fonts/Lato/Lato-Regular.woff') format('woff'), 30 | url('https://cdn.virgilsecurity.com/assets/fonts/Lato/Lato-Regular.ttf') format('truetype'); 31 | } 32 | @font-face { 33 | font-family: 'Muller'; 34 | font-weight: 400; 35 | font-style: normal; 36 | font-display: optional; 37 | src: url('https://cdn.virgilsecurity.com/assets/fonts/Muller/muller-regular.eot'); 38 | src: 39 | url('https://cdn.virgilsecurity.com/assets/fonts/Muller/muller-regular.eot?#iefix') format('embedded-opentype'), 40 | url('https://cdn.virgilsecurity.com/assets/fonts/Muller/muller-regular.woff2') format('woff2'), 41 | url('https://cdn.virgilsecurity.com/assets/fonts/Muller/muller-regular.woff') format('woff'), 42 | url('https://cdn.virgilsecurity.com/assets/fonts/Muller/muller-regular.ttf') format('truetype'); 43 | } 44 | 45 | `; 46 | 47 | export default class App extends React.Component<{}, IAppStore> { 48 | userModel: UserApi; 49 | store = new AppStore(this.setState.bind(this), () => this.state); 50 | 51 | constructor(props: {}) { 52 | super(props); 53 | this.state = this.store.defaultState; 54 | this.userModel = new UserApi(this.store); 55 | } 56 | 57 | render() { 58 | const isAuthPage = this.state.chatModel == null; 59 | const isChatPage = this.state.chatModel != null; 60 | return ( 61 | 62 | 63 | {isAuthPage && } 64 | {isChatPage && } 65 | 66 | ); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/components/AuthForm.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Formik, Form, Field, FieldProps, FormikHelpers as FormikActions, FormikProps, FormikErrors } from 'formik'; 3 | import InputField from './InputField'; 4 | import { PrimaryButton } from './Primitives'; 5 | import styled from 'styled-components'; 6 | 7 | const Buttons = styled.div` 8 | margin-top: 20px; 9 | display: flex; 10 | justify-content: space-between; 11 | `; 12 | 13 | const LoadingContainer = styled.div` 14 | width: 100%; 15 | text-align: center; 16 | ` 17 | 18 | export interface IAuthFormValues { 19 | username: string; 20 | password: string; 21 | brainkeyPassword: string; 22 | } 23 | 24 | type formikSubmit = ( 25 | values: IAuthFormValues, 26 | actions: FormikActions, 27 | ) => Promise; 28 | 29 | export interface IAuthFormProps { 30 | onSignIn: formikSubmit; 31 | onSignUp: formikSubmit; 32 | } 33 | 34 | export interface IAuthFormState { 35 | isSingInClicked: boolean; 36 | isMultiDeviceSupportEnabled: boolean; 37 | isLoading: boolean; 38 | } 39 | 40 | export default class AuthForm extends React.Component { 41 | state = { isSingInClicked: false, isMultiDeviceSupportEnabled: false, isLoading: false }; 42 | 43 | validateForm = (values: IAuthFormValues) => { 44 | let errors: FormikErrors = {}; 45 | 46 | if (values.password === '' || values.password == null) { 47 | errors.password = 'required'; 48 | } 49 | 50 | if (values.brainkeyPassword === '' || values.brainkeyPassword == null) { 51 | errors.brainkeyPassword = 'required'; 52 | } 53 | 54 | 55 | return errors; 56 | }; 57 | 58 | renderEmailInput = ({ field, form }: FieldProps) => { 59 | const error = 60 | form.touched.username && form.errors.username ? (form.errors.username as string) : null; 61 | 62 | return ; 63 | }; 64 | 65 | renderPasswordInput = ({ field, form }: FieldProps) => { 66 | const error = 67 | form.touched.password && form.errors.password ? (form.errors.password as string) : null; 68 | return ; 69 | }; 70 | 71 | renderBrainKeyPasswordInput = ({ field, form }: FieldProps) => { 72 | const error = 73 | form.touched.brainkeyPassword && form.errors.brainkeyPassword 74 | ? (form.errors.brainkeyPassword as string) 75 | : null; 76 | return ; 77 | }; 78 | 79 | onSubmit: formikSubmit = (values, actions) => { 80 | this.setState({ isLoading: true }); 81 | let promise; 82 | if (this.state.isSingInClicked) { 83 | promise = this.props.onSignIn(values, actions); 84 | } else { 85 | promise = this.props.onSignUp(values, actions); 86 | } 87 | 88 | return promise 89 | .catch(() => this.setState({ isLoading: false })); 90 | }; 91 | 92 | renderForm = ({ isValid }: FormikProps) => { 93 | return ( 94 |
95 | {this.renderEmailInput} 96 | {this.renderPasswordInput} 97 | {this.renderBrainKeyPasswordInput} 98 | {this.state.isLoading ? this.renderLoading() : this.renderButtons(isValid)} 99 |
100 | ); 101 | }; 102 | 103 | render() { 104 | return ( 105 | 110 | {this.renderForm} 111 | 112 | ); 113 | } 114 | 115 | private renderButtons = (isValid: boolean) => { 116 | return ( 117 | 118 | this.setState({ isSingInClicked: true })} 122 | > 123 | Sign In 124 | 125 | this.setState({ isSingInClicked: false })} 129 | > 130 | Sign Up 131 | 132 | 133 | ); 134 | }; 135 | 136 | private renderLoading = () => { 137 | return loading 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/components/Channels.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import styled from 'styled-components'; 3 | import { Avatar } from './Primitives'; 4 | import ChannelModel, { IChannel } from '../models/ChannelModel'; 5 | 6 | const ChannelsWrapper = styled.div` 7 | flex: 1 0 auto; 8 | max-height: calc(100vh - 130px); 9 | overflow: scroll; 10 | `; 11 | 12 | const SideBarItem = styled.button` 13 | height: 80px; 14 | width: 100%; 15 | padding: 20px; 16 | display: flex; 17 | align-items: center; 18 | border: 0; 19 | `; 20 | 21 | const Username = styled.div` 22 | display: flex; 23 | align-items: center; 24 | flex: 1 0 auto; 25 | max-width: 130px; 26 | word-break: break-all; 27 | padding: 0px 20px; 28 | `; 29 | 30 | export interface IChannelsProps { 31 | channels: ChannelModel[]; 32 | username: string; 33 | onClick: (channel: IChannel) => void; 34 | } 35 | 36 | export default class Channels extends React.Component { 37 | renderItem = (item: ChannelModel) => { 38 | 39 | return ( 40 | this.props.onClick(item)} key={item.id}> 41 | {item.receiver.username.slice(0, 2).toUpperCase()} 42 | {item.receiver.username} 43 | 44 | ); 45 | }; 46 | 47 | render() { 48 | return ( 49 | 50 | {this.props.channels.map(channel => this.renderItem(channel))} 51 | 52 | ); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/components/ChatPrimitives.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { PrimaryButton } from './Primitives'; 3 | 4 | export const ChatContainer = styled.div` 5 | max-width: 1024px; 6 | min-width: 600px; 7 | margin: 0 auto; 8 | background-color: white; 9 | `; 10 | 11 | export const Header = styled.header` 12 | width: 100%; 13 | height: 50px; 14 | background-color: #9e3621; 15 | display: flex; 16 | justify-content: space-between; 17 | `; 18 | 19 | export const ChatLayout = styled.div` 20 | display: flex; 21 | height: calc(100vh - 50px); 22 | `; 23 | 24 | export const SideBar = styled.aside` 25 | width: 250px; 26 | border-right: 2px solid grey; 27 | flex: 0 0 auto; 28 | height: 100%; 29 | display: flex; 30 | flex-direction: column; 31 | `; 32 | 33 | export const ChatWorkspace = styled.main` 34 | width: 100%; 35 | height: 100%; 36 | overflow: hidden; 37 | display: flex; 38 | flex-direction: column; 39 | align-items: center; 40 | justify-content: center; 41 | `; 42 | 43 | export const BottomPrimaryButton = styled(PrimaryButton)` 44 | margin: 10px 0px; 45 | `; 46 | 47 | export const RightSide = styled.span` 48 | color: white; 49 | `; 50 | -------------------------------------------------------------------------------- /src/components/ChatWindow.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import Channels from '../components/Channels'; 4 | import Messages from '../components/Messages'; 5 | import MessageField from '../components/MessageField'; 6 | import { PrimaryButton, LinkButton } from '../components/Primitives'; 7 | import ChatModel from '../models/ChatModel'; 8 | import { IChannel } from '../models/ChannelModel'; 9 | import { IAppStore } from '../models/AppState'; 10 | 11 | const ChatContainer = styled.div` 12 | max-width: 1024px; 13 | min-width: 600px; 14 | margin: 0 auto; 15 | background-color: white; 16 | `; 17 | 18 | const Header = styled.header` 19 | width: 100%; 20 | height: 50px; 21 | background-color: #9e3621; 22 | display: flex; 23 | justify-content: space-between; 24 | `; 25 | 26 | const ChatLayout = styled.div` 27 | display: flex; 28 | height: calc(100vh - 50px); 29 | `; 30 | 31 | const SideBar = styled.aside` 32 | width: 250px; 33 | border-right: 2px solid grey; 34 | flex: 0 0 auto; 35 | height: 100%; 36 | display: flex; 37 | flex-direction: column; 38 | `; 39 | 40 | const ChatWorkspace = styled.main` 41 | width: 100%; 42 | height: 100%; 43 | overflow: hidden; 44 | display: flex; 45 | flex-direction: column; 46 | align-items: center; 47 | justify-content: center; 48 | `; 49 | 50 | const BottomPrimaryButton = styled(PrimaryButton)` 51 | margin: 10px 0px; 52 | `; 53 | 54 | const RightSide = styled.span` 55 | color: white; 56 | `; 57 | 58 | export interface IChatPageProps { 59 | model: ChatModel; 60 | store: IAppStore; 61 | signOut: () => void; 62 | } 63 | 64 | export default class ChatPage extends React.Component { 65 | model = this.props.model; 66 | 67 | componentWillUnmount() { 68 | this.model.unsubscribe(); 69 | } 70 | 71 | createChannel = async () => { 72 | const receiver = prompt('receiver', ''); 73 | if (!receiver) return alert('Add receiver please'); 74 | try { 75 | await this.model.channelsList.createChannel(receiver); 76 | } catch (e) { 77 | alert(e.message); 78 | } 79 | }; 80 | 81 | sendMessage = async (message: string) => { 82 | try { 83 | await this.model.sendMessage(message) 84 | } catch (e) { 85 | alert(e); 86 | } 87 | } 88 | 89 | selectChannel = (channelInfo: IChannel) => this.model.listenMessages(channelInfo); 90 | 91 | render() { 92 | if (this.props.store.error) alert(this.props.store.error); 93 | return ( 94 | 95 |
96 | 97 | Virgilgram 98 | 99 | 100 | {this.props.store.email} 101 | 102 | logout 103 | 104 | 105 |
106 | 107 | 108 | 113 | 114 | New Channel 115 | 116 | 117 | 118 | {this.props.store.currentChannel ? ( 119 | 120 | 121 | 122 | 123 | ) : ( 124 | 'Select Channel First' 125 | )} 126 | 127 | 128 |
129 | ); 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/components/InputField.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | const Label = styled.label` 5 | font-size: 11px; 6 | font-family: 'Muller'; 7 | text-transform: uppercase; 8 | font-weight: bold; 9 | color: #a6a6a6; 10 | display: flex; 11 | flex-direction: column; 12 | width: 100%; 13 | 14 | &:nth-child(n + 1) { 15 | margin-bottom: 20px; 16 | } 17 | `; 18 | 19 | const Input = styled.input` 20 | border: 1px solid #a6a6a6; 21 | color: #333; 22 | border-radius: 3px; 23 | padding: 0 16px; 24 | height: 44px; 25 | margin-top: 10px; 26 | width: 100%; 27 | display: inline-block; 28 | 29 | &:hover { 30 | border: 1px solid #333; 31 | } 32 | `; 33 | 34 | export interface IInputFieldProps extends React.InputHTMLAttributes { 35 | label: string; 36 | error?: string | null; 37 | } 38 | 39 | export default class InputField extends React.Component { 40 | render() { 41 | const { label, error, ...props } = this.props; 42 | return ( 43 | 48 | ); 49 | } 50 | } -------------------------------------------------------------------------------- /src/components/Message.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import styled from 'styled-components'; 3 | import { Avatar } from './Primitives'; 4 | import format from 'date-fns/format'; 5 | import { IMessage } from '../models/MessageListModel'; 6 | 7 | const MessageContainer = styled.div` 8 | width: 100%; 9 | min-height: 100px; 10 | display: flex; 11 | ` 12 | 13 | const MessageContent = styled.div` 14 | display: flex; 15 | flex-direction: column; 16 | flex: 1 0 auto; 17 | max-width: calc(100% - 100px); 18 | ` 19 | 20 | const MessageHeader = styled.span` 21 | display: flex; 22 | justify-content: space-between; 23 | align-items: center; 24 | ` 25 | 26 | const MessageBody = styled.div` 27 | overflow: hidden; 28 | word-break: break-all; 29 | ` 30 | 31 | const MessageAvatar = styled(Avatar)` 32 | margin: 25px; 33 | flex: 0 0 auto; 34 | ` 35 | 36 | 37 | export interface IMessageProps { 38 | message: IMessage; 39 | } 40 | 41 | export default function Message({ message }: IMessageProps) { 42 | return ( 43 | 44 | {message.sender.slice(0, 2).toUpperCase()} 45 | 46 | 47 |

{message.sender}

48 | {format(message.createdAt, 'HH:mm:ss')} 49 |
50 | {message.body === '' ? '*Message Deleted*' : message.body} 51 |
52 |
53 | ); 54 | 55 | } -------------------------------------------------------------------------------- /src/components/MessageField.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { SecondaryButton } from '../components/Primitives'; 4 | 5 | export const inputHeight = '100px'; 6 | 7 | const MessageFieldContainer = styled.form` 8 | width: 100%; 9 | height: ${inputHeight}; 10 | flex: 0 0 auto; 11 | padding: 20px 100px 20px; 12 | display: flex; 13 | `; 14 | 15 | const MessageFieldElement = styled.textarea` 16 | flex: 1 1 auto; 17 | border: 0; 18 | border-bottom: 2px solid #9e3621; 19 | `; 20 | 21 | export interface IMessageFieldProps { 22 | handleSend: (message: string) => void; 23 | } 24 | 25 | export interface IMessageFieldState { 26 | message: string; 27 | } 28 | 29 | export default class MessageField extends React.Component { 30 | state = { 31 | message: '', 32 | }; 33 | 34 | handleSend = (e: React.FormEvent) => { 35 | e.preventDefault(); 36 | this.props.handleSend(this.state.message); 37 | this.setState({ message: '' }); 38 | }; 39 | 40 | handleMessageChange = (e: React.ChangeEvent) => { 41 | this.setState({ message: e.target.value }); 42 | }; 43 | 44 | handleEnter = (e: React.KeyboardEvent) => { 45 | if (this.state.message.trim() === '') return; 46 | if (e.keyCode === 13 && !e.ctrlKey) { 47 | e.preventDefault(); 48 | this.props.handleSend(this.state.message) 49 | this.setState({ message: '' }); 50 | } else if (e.ctrlKey) { 51 | this.setState(state => ({ message: state.message + '\n' })); 52 | } 53 | } 54 | 55 | render() { 56 | return ( 57 | 58 | 64 | send 65 | 66 | ); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/components/Messages.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Message from './Message'; 3 | import styled from 'styled-components'; 4 | import { inputHeight } from './MessageField'; 5 | import { IMessage } from '../models/MessageListModel'; 6 | 7 | const MessageWrapper = styled.div` 8 | flex: 1 0 auto; 9 | width: 100%; 10 | overflow: scroll; 11 | max-height: calc(100% - ${inputHeight}); 12 | padding: 25px 100px 0; 13 | `; 14 | 15 | export interface IMessagesProps { 16 | messages: IMessage[]; 17 | } 18 | 19 | export default class Messages extends React.Component { 20 | ref?: HTMLElement | null; 21 | 22 | componentDidUpdate() { 23 | if (this.ref) { 24 | this.ref.scrollTo({ top: this.ref.scrollHeight }) 25 | } 26 | } 27 | 28 | render() { 29 | 30 | const messages = this.props.messages.map(message => ( 31 | 32 | )); 33 | 34 | return {this.ref = ref}}> 35 | {messages} 36 | ; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/components/Primitives.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Button = styled.button` 4 | font-family: 'Muller'; 5 | font-size: 14px; 6 | display: inline-flex; 7 | justify-content: center; 8 | height: 44px; 9 | transition: all 0.5s; 10 | text-transform: uppercase; 11 | border: 0; 12 | border-radius: 3px; 13 | align-items: center; 14 | `; 15 | 16 | export const PrimaryButton = styled(Button)` 17 | color: white; 18 | background-color: #9e3621; 19 | box-shadow: 0 15px 20px -15px rgba(158, 54, 33, 0.5); 20 | padding: 0 25px; 21 | 22 | &:hover:not(:disabled) { 23 | background-color: #da322c; 24 | } 25 | 26 | &:disabled { 27 | opacity: 0.5; 28 | } 29 | `; 30 | 31 | export const SecondaryButton = styled(Button)` 32 | --webkit-appearance: none; 33 | border: 0; 34 | display: inline-block; 35 | padding: 16px 19px; 36 | color: #9e3621; 37 | margin: 2px; 38 | font-family: Muller; 39 | text-transform: uppercase; 40 | text-decoration: none; 41 | 42 | &:disabled { 43 | color: #ebebeb; 44 | } 45 | `; 46 | 47 | export const Avatar = styled.div` 48 | height: 50px; 49 | width: 50px; 50 | line-height: 50px; 51 | border-radius: 50px; 52 | text-align: center; 53 | font-size: 24px; 54 | background-color: lightgray; 55 | `; 56 | 57 | export const LinkButton = styled.a` 58 | --webkit-appearance: none; 59 | border: 0; 60 | display: inline-block; 61 | padding: 16px 19px; 62 | color: ${props => props.color}; 63 | font-family: Muller; 64 | text-transform: uppercase; 65 | text-decoration: none; 66 | 67 | &:hover { 68 | cursor: pointer; 69 | background-color: rgba(255, 255, 255, 0.1) 70 | } 71 | ` -------------------------------------------------------------------------------- /src/declaration.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'virgil-crypto/dist/virgil-crypto-pythia.es' { 2 | export * from 'virgil-crypto/dist/types/pythia'; 3 | } 4 | 5 | declare module 'virgil-pythia' { 6 | 7 | function createBrainKey({}: any): any; 8 | 9 | export interface BrainKey { 10 | generateKeyPair(password: string, id?: string): Promise 11 | } 12 | } -------------------------------------------------------------------------------- /src/firebase.ts: -------------------------------------------------------------------------------- 1 | import * as firebase from 'firebase'; 2 | 3 | // PASTE YOUR CONFIG VARIABLE HERE 4 | 5 | var firebaseConfig = {}; 6 | // Initialize Firebase 7 | firebase.initializeApp(firebaseConfig); 8 | 9 | firebase.auth().setPersistence(firebase.auth.Auth.Persistence.SESSION); 10 | -------------------------------------------------------------------------------- /src/fonts.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css?family=Source+Code+Pro'); 2 | 3 | @font-face { 4 | font-family: 'Muller'; 5 | font-weight: 400; 6 | font-style: normal; 7 | font-display: optional; 8 | src: url('https://cdn.virgilsecurity.com/assets/fonts/Muller/muller-regular.eot'); 9 | src: 10 | url('https://cdn.virgilsecurity.com/assets/fonts/Muller/muller-regular.eot?#iefix') format('embedded-opentype'), 11 | url('https://cdn.virgilsecurity.com/assets/fonts/Muller/muller-regular.woff2') format('woff2'), 12 | url('https://cdn.virgilsecurity.com/assets/fonts/Muller/muller-regular.woff') format('woff'), 13 | url('https://cdn.virgilsecurity.com/assets/fonts/Muller/muller-regular.ttf') format('truetype'); 14 | } 15 | 16 | @font-face { 17 | font-family: 'Muller'; 18 | font-weight: 500; 19 | font-style: normal; 20 | font-display: optional; 21 | src: url('https://cdn.virgilsecurity.com/assets/fonts/Muller/muller-medium.eot'); 22 | src: 23 | url('https://cdn.virgilsecurity.com/assets/fonts/Muller/muller-medium.eot?#iefix') format('embedded-opentype'), 24 | url('https://cdn.virgilsecurity.com/assets/fonts/Muller/muller-medium.woff2') format('woff2'), 25 | url('https://cdn.virgilsecurity.com/assets/fonts/Muller/muller-medium.woff') format('woff'), 26 | url('https://cdn.virgilsecurity.com/assets/fonts/Muller/muller-medium.ttf') format('truetype'); 27 | } 28 | 29 | @font-face { 30 | font-family: 'Muller'; 31 | font-weight: 700; 32 | font-style: normal; 33 | font-display: optional; 34 | src: url('https://cdn.virgilsecurity.com/assets/fonts/Muller/muller-bold.eot'); 35 | src: 36 | url('https://cdn.virgilsecurity.com/assets/fonts/Muller/muller-bold.eot?#iefix') format('embedded-opentype'), 37 | url('https://cdn.virgilsecurity.com/assets/fonts/Muller/muller-bold.woff2') format('woff2'), 38 | url('https://cdn.virgilsecurity.com/assets/fonts/Muller/muller-bold.woff') format('woff'), 39 | url('https://cdn.virgilsecurity.com/assets/fonts/Muller/muller-bold.ttf') format('truetype'); 40 | } 41 | 42 | @font-face { 43 | font-family: 'Lato'; 44 | font-weight: 400; 45 | font-style: normal; 46 | font-display: optional; 47 | src: url('https://cdn.virgilsecurity.com/assets/fonts/Lato/Lato-Regular.eot'); 48 | src: 49 | url('https://cdn.virgilsecurity.com/assets/fonts/Lato/Lato-Regular.eot?#iefix') format('embedded-opentype'), 50 | url('https://cdn.virgilsecurity.com/assets/fonts/Lato/Lato-Regular.woff2') format('woff2'), 51 | url('https://cdn.virgilsecurity.com/assets/fonts/Lato/Lato-Regular.woff') format('woff'), 52 | url('https://cdn.virgilsecurity.com/assets/fonts/Lato/Lato-Regular.ttf') format('truetype'); 53 | } 54 | 55 | @font-face { 56 | font-family: 'Lato'; 57 | font-weight: 500; 58 | font-style: normal; 59 | font-display: optional; 60 | src: url('https://cdn.virgilsecurity.com/assets/fonts/Lato/Lato-Medium.eot'); 61 | src: 62 | url('https://cdn.virgilsecurity.com/assets/fonts/Lato/Lato-Medium.eot?#iefix') format('embedded-opentype'), 63 | url('https://cdn.virgilsecurity.com/assets/fonts/Lato/Lato-Medium.woff2') format('woff2'), 64 | url('https://cdn.virgilsecurity.com/assets/fonts/Lato/Lato-Medium.woff') format('woff'), 65 | url('https://cdn.virgilsecurity.com/assets/fonts/Lato/Lato-Medium.ttf') format('truetype'); 66 | } 67 | 68 | @font-face { 69 | font-family: 'Lato'; 70 | font-weight: 700; 71 | font-style: normal; 72 | font-display: optional; 73 | src: url('https://cdn.virgilsecurity.com/assets/fonts/Lato/Lato-Bold.eot'); 74 | src: 75 | url('https://cdn.virgilsecurity.com/assets/fonts/Lato/Lato-Bold.eot?#iefix') format('embedded-opentype'), 76 | url('https://cdn.virgilsecurity.com/assets/fonts/Lato/Lato-Bold.woff2') format('woff2'), 77 | url('https://cdn.virgilsecurity.com/assets/fonts/Lato/Lato-Bold.woff') format('woff'), 78 | url('https://cdn.virgilsecurity.com/assets/fonts/Lato/Lato-Bold.ttf') format('truetype'); 79 | } -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Firebase Demo 8 | 9 | 10 | 11 | 12 |
13 |

Make sure that you added config with your parameters to 14 | firebase.ts file

15 |

Also you need change 16 | FIREBASE_FUNCTION_URL variable in src/models/UserModel.ts file

17 | You can find instructions 18 | here 19 |
20 | 21 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './firebase'; 4 | import App from './App'; 5 | import 'normalize.css/normalize.css'; 6 | import './fonts.css'; 7 | 8 | ReactDOM.render(, document.getElementById('root')); -------------------------------------------------------------------------------- /src/models/AppState.ts: -------------------------------------------------------------------------------- 1 | import ChannelModel, { IChannel } from './ChannelModel'; 2 | import { IMessage } from './MessageListModel'; 3 | import ChatModel from './ChatModel'; 4 | 5 | export interface IAppStore { 6 | chatModel: null | ChatModel; 7 | error: null | Error | string; 8 | channels: ChannelModel[]; 9 | messages: IMessage[]; 10 | currentChannel: IChannel | null; 11 | email?: string; 12 | } 13 | 14 | export default class AppStore { 15 | defaultState: IAppStore = { 16 | error: null, 17 | currentChannel: null, 18 | channels: [], 19 | messages: [], 20 | chatModel: null, 21 | }; 22 | 23 | get state() { 24 | return this.stateLink(); 25 | } 26 | 27 | // tslint:disable-next-line:no-any 28 | constructor(public setState: any, private stateLink: () => IAppStore) { 29 | 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/models/ChannelListModel.ts: -------------------------------------------------------------------------------- 1 | import { FirebaseCollections } from './helpers/FirebaseCollections'; 2 | import firebase from 'firebase/app'; 3 | import ChannelModel, { IChannel } from './ChannelModel'; 4 | import { EThree } from '@virgilsecurity/e3kit-browser'; 5 | import { base64UrlFromBase64 } from './helpers/base64UrlFromBase64'; 6 | 7 | export default class ChannelListModel { 8 | static channelCollectionRef = firebase.firestore().collection(FirebaseCollections.Channels); 9 | static userCollectionRef = firebase.firestore().collection(FirebaseCollections.Users); 10 | channels: ChannelModel[] = []; 11 | 12 | constructor(private senderUsername: string, private e3kit: EThree) {} 13 | 14 | getChannel(channelId: string) { 15 | const channel = this.channels.find(e => e.id === channelId); 16 | if (!channel) throw Error('Channel not found'); 17 | return channel; 18 | } 19 | 20 | listenUpdates(senderUsername: string, cb: (channels: ChannelModel[]) => void) { 21 | return ChannelListModel.userCollectionRef.doc(senderUsername).onSnapshot(async snapshot => { 22 | const channelIds = snapshot.data()!.channels as string[]; 23 | 24 | const channelsRefs = await Promise.all( 25 | channelIds.map((id: string) => ChannelListModel.channelCollectionRef.doc(id).get()), 26 | ); 27 | 28 | const channels = channelsRefs.map(this.getChannelFromSnapshot); 29 | this.channels = channels.map( 30 | channel => new ChannelModel(channel, senderUsername, this.e3kit), 31 | ); 32 | cb(this.channels); 33 | }); 34 | } 35 | 36 | async createChannel(receiverUsername: string) { 37 | // We are using email auth provider, so all nicknames are lowercased by firebase 38 | receiverUsername = receiverUsername.toLowerCase(); 39 | if (receiverUsername === this.senderUsername) { 40 | throw new Error('Autocommunication is not supported yet'); 41 | } 42 | const hasChat = this.channels.some(e => e.receiver.username === receiverUsername); 43 | if (hasChat) throw new Error('You already has this channel'); 44 | 45 | const receiverRef = firebase 46 | .firestore() 47 | .collection(FirebaseCollections.Users) 48 | .doc(receiverUsername); 49 | 50 | const senderRef = firebase 51 | .firestore() 52 | .collection(FirebaseCollections.Users) 53 | .doc(this.senderUsername); 54 | const [receiverDoc, senderDoc] = await Promise.all([receiverRef.get(), senderRef.get()]); 55 | 56 | if (!receiverDoc.exists) throw new Error("receiverDoc doesn't exist"); 57 | if (!senderDoc.exists) throw new Error("senderDoc doesn't exist"); 58 | 59 | const channelId = this.getChannelId(receiverUsername, this.senderUsername); 60 | const channelRef = await ChannelListModel.channelCollectionRef.doc(channelId); 61 | 62 | return await firebase.firestore().runTransaction(async transaction => { 63 | const senderChannels = senderDoc.data()!.channels; 64 | const receiverChannels = receiverDoc.data()!.channels; 65 | 66 | transaction.set(channelRef, { 67 | count: 0, 68 | members: [ 69 | { username: this.senderUsername, uid: senderDoc.data()!.uid }, 70 | { username: receiverUsername, uid: receiverDoc.data()!.uid }, 71 | ], 72 | }); 73 | 74 | transaction.update(senderRef, { 75 | channels: senderChannels ? senderChannels.concat(channelId) : [channelId], 76 | }); 77 | 78 | transaction.update(receiverRef, { 79 | channels: receiverChannels ? receiverChannels.concat(channelId) : [channelId], 80 | }); 81 | 82 | return transaction; 83 | }); 84 | } 85 | 86 | private getChannelFromSnapshot(snapshot: firebase.firestore.DocumentSnapshot): IChannel { 87 | return { 88 | ...(snapshot.data() as IChannel), 89 | id: snapshot.id, 90 | } as IChannel; 91 | } 92 | 93 | private getChannelId(username1: string, username2: string) { 94 | // Make hash of users the same independently of usernames order 95 | const combination = username1 > username2 ? username1 + username2 : username1 + username2; 96 | return base64UrlFromBase64(this.e3kit.virgilCrypto 97 | .calculateHash(combination) 98 | .toString('base64')); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/models/ChannelModel.ts: -------------------------------------------------------------------------------- 1 | import MessagesListModel, { IMessage } from './MessageListModel'; 2 | import MessageStorage from './MessageStorage'; 3 | import CryptoMessageList from './CryptoMessageList'; 4 | import { EThree } from '@virgilsecurity/e3kit-browser'; 5 | 6 | export interface IChannel { 7 | id: string; 8 | count: number; 9 | members: ChannelUser[]; 10 | } 11 | 12 | export type ChannelUser = { username: string, uid: string }; 13 | 14 | export default class ChannelModel implements IChannel { 15 | public id: string; 16 | public count: number; 17 | public members: ChannelUser[]; 18 | // public receiver: ChannelUser; 19 | private messageStorage: MessageStorage; 20 | private encryptedMessageList: CryptoMessageList; 21 | 22 | constructor( 23 | { id, count, members }: IChannel, 24 | public senderUsername: string, 25 | public virgilE2ee: EThree, 26 | ) { 27 | this.id = id; 28 | this.count = count; 29 | this.members = members; 30 | this.messageStorage = new MessageStorage(this.id); 31 | 32 | const messageList = new MessagesListModel(this); 33 | 34 | this.encryptedMessageList = new CryptoMessageList(messageList, virgilE2ee); 35 | } 36 | 37 | get receiver() { 38 | return this.members.filter(e => e.username !== this.senderUsername)[0]; 39 | } 40 | 41 | get sender() { 42 | return this.members.filter(e => e.username === this.senderUsername)[0]; 43 | } 44 | 45 | async sendMessage(message: string) { 46 | try { 47 | return this.encryptedMessageList.sendMessage(message); 48 | } catch (e) { 49 | throw e; 50 | } 51 | } 52 | 53 | listenMessages(cb: (messages: IMessage[]) => void) { 54 | return this.encryptedMessageList.listenUpdates(this.id, messages => { 55 | const allMessages = this.messageStorage.addMessages(messages); 56 | cb(allMessages); 57 | }); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/models/ChatModel.ts: -------------------------------------------------------------------------------- 1 | import AppStore from './AppState'; 2 | import firebase from 'firebase/app'; 3 | import ChannelListModel from './ChannelListModel'; 4 | import { IChannel } from './ChannelModel'; 5 | import { EThree } from '@virgilsecurity/e3kit-browser'; 6 | 7 | export class ChatModel { 8 | channelsList: ChannelListModel; 9 | 10 | channelsListener?: firebase.Unsubscribe; 11 | messageListener?: firebase.Unsubscribe; 12 | 13 | constructor(public store: AppStore, public email: string, virgilE2ee: EThree) { 14 | this.channelsList = new ChannelListModel(email, virgilE2ee); 15 | this.listenChannels(email); 16 | } 17 | 18 | sendMessage = async (message: string) => { 19 | if (!this.store.state.currentChannel) throw Error('set channel first'); 20 | const currentChannel = this.channelsList.getChannel(this.store.state.currentChannel.id); 21 | 22 | return await currentChannel.sendMessage(message); 23 | }; 24 | 25 | listenMessages = async (channel: IChannel) => { 26 | if (this.messageListener) this.messageListener(); 27 | const channelModel = this.channelsList.getChannel(channel.id); 28 | this.store.setState({ currentChannel: channel, messages: [] }); 29 | this.messageListener = channelModel.listenMessages(messages => 30 | this.store.setState({ messages }), 31 | ); 32 | }; 33 | 34 | unsubscribe() { 35 | if (this.channelsListener) this.channelsListener(); 36 | if (this.messageListener) this.messageListener(); 37 | } 38 | 39 | private async listenChannels(username: string) { 40 | if (this.channelsListener) this.channelsListener(); 41 | this.channelsListener = this.channelsList.listenUpdates(username, channels => 42 | this.store.setState({ channels }), 43 | ); 44 | } 45 | } 46 | 47 | export default ChatModel; 48 | -------------------------------------------------------------------------------- /src/models/CryptoMessageList.ts: -------------------------------------------------------------------------------- 1 | import MessagesListModel, { IMessage } from './MessageListModel'; 2 | import { EThree, ICard } from '@virgilsecurity/e3kit-browser'; 3 | 4 | export default class EncryptedMessageList { 5 | receiverCard: Promise; 6 | senderCard: Promise; 7 | 8 | constructor(readonly messageList: MessagesListModel, readonly virgilE2ee: EThree) { 9 | this.receiverCard = virgilE2ee.findUsers(this.messageList.channel.receiver.uid); 10 | this.senderCard = virgilE2ee.findUsers(this.messageList.channel.sender.uid); 11 | } 12 | 13 | async sendMessage(message: string) { 14 | const encryptedMessage = await this.virgilE2ee.authEncrypt(message, await this.receiverCard); 15 | 16 | this.messageList.sendMessage(encryptedMessage); 17 | } 18 | 19 | listenUpdates(id: string, cb: (messages: IMessage[]) => void) { 20 | return this.messageList.listenUpdates(id, async messages => { 21 | const newMessages = messages.filter(m => m.body !== ''); 22 | const promises = newMessages.map(async message => { 23 | // message sender differs from channel sender 24 | // cause in channel context current user is always sender 25 | const publicKey = message.sender === this.messageList.channel.sender.username 26 | ? this.senderCard 27 | : this.receiverCard; 28 | 29 | return this.virgilE2ee.authDecrypt(message.body, await publicKey); 30 | }); 31 | const decryptedBodies = await Promise.all(promises); 32 | newMessages.forEach((m, i) => { 33 | m.body = decryptedBodies[i]; 34 | }); 35 | cb(messages); 36 | }); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/models/MessageListModel.ts: -------------------------------------------------------------------------------- 1 | import { FirebaseCollections } from './helpers/FirebaseCollections'; 2 | import ChannelListModel from './ChannelListModel'; 3 | import firebase from 'firebase/app'; 4 | import ChannelModel from './ChannelModel'; 5 | 6 | export interface IMessage { 7 | id: string; 8 | body: string | Buffer; 9 | createdAt: Date; 10 | receiver: string; 11 | sender: string; 12 | } 13 | 14 | export default class MessagesListModel { 15 | constructor(public channel: ChannelModel) {} 16 | 17 | async sendMessage(message: string | Buffer) { 18 | firebase.firestore().runTransaction(transaction => { 19 | return this.updateMessage(transaction, message); 20 | }); 21 | } 22 | 23 | listenUpdates(id: string, cb: (messages: IMessage[]) => void) { 24 | return ChannelListModel.channelCollectionRef 25 | .doc(id) 26 | .collection(FirebaseCollections.Messages) 27 | .orderBy('createdAt', 'asc') 28 | .onSnapshot(async snapshot => { 29 | const loadedMessages = snapshot 30 | .docChanges() 31 | .filter(messageSnapshot => messageSnapshot.type === 'added') 32 | .map(e => this.getMessageFromSnapshot(e.doc)); 33 | 34 | await this.blindNewMessages(loadedMessages); // To disable HIPAA behavior, remove this line 35 | cb(loadedMessages); 36 | }); 37 | } 38 | 39 | private getMessageFromSnapshot(snapshot: firebase.firestore.QueryDocumentSnapshot): IMessage { 40 | return { 41 | id: snapshot.id, 42 | ...snapshot.data(), 43 | createdAt: snapshot.data().createdAt.toDate(), 44 | } as IMessage; 45 | } 46 | 47 | private updateMessage = async ( 48 | transaction: firebase.firestore.Transaction, 49 | message: string | Buffer, 50 | ) => { 51 | const channelRef = ChannelListModel.channelCollectionRef.doc(this.channel.id); 52 | const snapshot = await transaction.get(channelRef); 53 | let messagesCount: number = snapshot.data()!.count; 54 | const messagesCollectionRef = channelRef 55 | .collection(FirebaseCollections.Messages) 56 | .doc(messagesCount.toString()); 57 | 58 | if (snapshot.exists) { 59 | transaction.update(channelRef, { count: ++messagesCount }); 60 | transaction.set(messagesCollectionRef, { 61 | body: message, 62 | createdAt: new Date(), 63 | sender: this.channel.sender.username, 64 | receiver: this.channel.receiver.username, 65 | }); 66 | } 67 | 68 | return transaction; 69 | }; 70 | 71 | private async blindNewMessages(loadedMessages: IMessage[]) { 72 | // Messages are deleted after receiver read it, so we can't encrypt them 73 | const newMessages = loadedMessages.filter(m => m.body !== ''); 74 | // Here we deleting message content after receiver read it. 75 | // This is one of the HIPPA requirements. 76 | newMessages.forEach(this.blindMessage); 77 | 78 | return newMessages; 79 | } 80 | 81 | private blindMessage = async (message: IMessage) => { 82 | // if messages loaded by receiver do not blind body 83 | if (this.channel.receiver.username === message.receiver) return; 84 | return ChannelListModel.channelCollectionRef 85 | .doc(this.channel.id) 86 | .collection(FirebaseCollections.Messages) 87 | .doc(message.id) 88 | .update({ 89 | ...message, 90 | body: '', 91 | }); 92 | }; 93 | } 94 | -------------------------------------------------------------------------------- /src/models/MessageStorage.ts: -------------------------------------------------------------------------------- 1 | import { differenceBy } from 'lodash'; 2 | import { IMessage } from './MessageListModel'; 3 | 4 | const messageStorageName = 'virgil_firebase_messages'; 5 | 6 | export default class MessageStorage { 7 | messages: IMessage[] = []; 8 | 9 | get lastMessageDate() { 10 | if (this.messages.length) { 11 | return this.messages[this.messages.length - 1].createdAt; 12 | } 13 | return new Date(0); 14 | } 15 | 16 | constructor(private channelId: string) { 17 | this.restoreMessages(); 18 | } 19 | 20 | addMessages(messages: IMessage[]): IMessage[] { 21 | const newMessages = differenceBy(messages, this.messages, e => e.id); 22 | this.messages = this.messages 23 | .concat(newMessages) 24 | .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); 25 | const storageString = window.localStorage.getItem(messageStorageName); 26 | 27 | let storage: { [s: string]: IMessage[] } = {}; 28 | 29 | if (storageString) { 30 | try { 31 | storage = JSON.parse(storageString); 32 | } catch (e) {} 33 | } 34 | 35 | storage[this.channelId] = this.messages; 36 | 37 | window.localStorage.setItem(messageStorageName, JSON.stringify(storage)); 38 | 39 | return this.messages; 40 | } 41 | 42 | private restoreMessages() { 43 | let messages: IMessage[] = []; 44 | const savedMessages = window.localStorage.getItem(messageStorageName); 45 | let parsedStore: { [s: string]: IMessage[] } = {}; 46 | if (savedMessages) parsedStore = JSON.parse(savedMessages); 47 | const hasMessages = parsedStore[this.channelId] && Array.isArray(parsedStore[this.channelId]); 48 | messages = hasMessages ? parsedStore[this.channelId] : []; 49 | messages.forEach(m => (m.createdAt = new Date(m.createdAt))); 50 | this.messages = messages; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/models/UserModel.ts: -------------------------------------------------------------------------------- 1 | import * as firebase from 'firebase'; 2 | import { EThree } from '@virgilsecurity/e3kit-browser'; 3 | import { FirebaseCollections } from './helpers/FirebaseCollections'; 4 | import AppStore from './AppState'; 5 | import ChatModel from './ChatModel'; 6 | 7 | export type AuthHandler = (client: EThree | null) => void; 8 | 9 | class UserApi { 10 | collectionRef = firebase.firestore().collection(FirebaseCollections.Users); 11 | eThree: Promise; 12 | 13 | constructor(public state: AppStore) { 14 | // here we will keep link to promise with initialized e3kit library 15 | this.eThree = new Promise((resolve, reject) => { 16 | firebase.auth().onAuthStateChanged(async user => { 17 | if (user) { 18 | const getVirgilJwt = firebase.functions().httpsCallable('getVirgilJwt'); 19 | const initializeFunction = () => getVirgilJwt().then(result => result.data.token); 20 | // callback onAuthStateChanged can be called with second user, so we make new 21 | // reference to e3kit 22 | this.eThree = EThree.initialize(initializeFunction); 23 | this.eThree.then(resolve).catch(reject); 24 | const eThree = await this.eThree; 25 | // if user has private key locally, then he didn't logout 26 | if (await eThree.hasLocalPrivateKey()) this.openChatWindow(user.email!, eThree) 27 | } else { 28 | this.state.setState(state.defaultState); 29 | // cleanup private key on logout 30 | this.eThree.then(eThree => eThree.cleanup()); 31 | } 32 | }); 33 | }); 34 | } 35 | 36 | async signUp(email: string, password: string, brainkeyPassword: string) { 37 | email = email.toLocaleLowerCase(); 38 | 39 | const userInfo = await firebase.auth().createUserWithEmailAndPassword(email, password); 40 | 41 | const eThree = await this.eThree; 42 | 43 | try { 44 | await eThree.register(); 45 | await eThree.backupPrivateKey(brainkeyPassword); 46 | await this.collectionRef.doc(email).set({ 47 | createdAt: new Date(), 48 | uid: userInfo.user!.uid, 49 | channels: [], 50 | }); 51 | this.openChatWindow(email, eThree); 52 | } catch (error) { 53 | await userInfo.user!.delete(); 54 | console.error(error); 55 | throw error; 56 | } 57 | } 58 | 59 | async signIn(email: string, password: string, brainkeyPassword: string) { 60 | email = email.toLocaleLowerCase(); 61 | 62 | await firebase.auth().signInWithEmailAndPassword(email, password); 63 | const eThree = await this.eThree; 64 | const hasPrivateKey = await eThree.hasLocalPrivateKey(); 65 | try { 66 | if (!hasPrivateKey) await eThree.restorePrivateKey(brainkeyPassword); 67 | this.openChatWindow(email, eThree); 68 | } catch (e) { 69 | firebase.auth().signOut(); 70 | throw e; 71 | } 72 | } 73 | 74 | async openChatWindow(email: string, eThree: EThree) { 75 | const chatModel = new ChatModel(this.state, email, eThree); 76 | this.state.setState({ chatModel, email }); 77 | } 78 | } 79 | 80 | export default UserApi; 81 | -------------------------------------------------------------------------------- /src/models/helpers/FirebaseCollections.ts: -------------------------------------------------------------------------------- 1 | export enum FirebaseCollections { 2 | Channels = 'Channels', 3 | Messages = 'Messages', 4 | Users = 'Users' 5 | } 6 | -------------------------------------------------------------------------------- /src/models/helpers/Routes.ts: -------------------------------------------------------------------------------- 1 | export enum Routes { 2 | index = '/', 3 | auth = '/auth', 4 | } -------------------------------------------------------------------------------- /src/models/helpers/base64UrlFromBase64.ts: -------------------------------------------------------------------------------- 1 | export function base64UrlFromBase64 (input: string) { 2 | input = input.split('=')[0]; 3 | input = input.replace(/\+/g, '-').replace(/\//g, '_'); 4 | return input; 5 | } 6 | -------------------------------------------------------------------------------- /src/pages/AuthPage.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import styled from 'styled-components'; 3 | import AuthForm, { IAuthFormValues } from '../components/AuthForm'; 4 | import { FormikHelpers as FormikActions } from 'formik'; 5 | import UserApi from '../models/UserModel'; 6 | import { IAppStore } from '../models/AppState'; 7 | 8 | const Background = styled.div` 9 | display: flex; 10 | justify-content: center; 11 | align-items: center; 12 | height: 100vh; 13 | `; 14 | 15 | const CenterCard = styled.div` 16 | display: flex; 17 | min-height: 250px; 18 | width: 400px; 19 | border: 1px solid #ebebeb; 20 | border-radius: 3px; 21 | box-shadow: 0 2px 40px 2px rgba(26, 29, 36, 0.16); 22 | padding: 25px; 23 | flex-direction: column; 24 | `; 25 | 26 | export interface IAuthPageProps { 27 | store: IAppStore; 28 | model: UserApi; 29 | } 30 | 31 | 32 | class AuthPage extends React.Component { 33 | handleSignUp = async (values: IAuthFormValues, actions: FormikActions) => { 34 | const UserApi = this.props.model; 35 | try { 36 | await UserApi.signUp(values.username, values.password, values.brainkeyPassword); 37 | } catch (e) { 38 | actions.setErrors({ username: e.message }); 39 | throw e; 40 | } 41 | }; 42 | 43 | handleSignIn = async (values: IAuthFormValues, actions: FormikActions) => { 44 | const UserApi = this.props.model; 45 | try { 46 | await UserApi.signIn(values.username, values.password, values.brainkeyPassword); 47 | } catch (e) { 48 | actions.setErrors({ username: e.message }); 49 | throw e; 50 | } 51 | }; 52 | 53 | render() { 54 | return ( 55 | 56 | 57 | 58 | 59 | 60 | ); 61 | } 62 | } 63 | 64 | export default AuthPage; 65 | -------------------------------------------------------------------------------- /src/pages/ChatPage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import firebase from 'firebase/app'; 3 | import ChatModel from '../models/ChatModel'; 4 | import { IAppStore } from '../models/AppState'; 5 | import Channels from '../components/Channels'; 6 | import Messages from '../components/Messages'; 7 | import MessageField from '../components/MessageField'; 8 | import { LinkButton } from '../components/Primitives'; 9 | import { IChannel } from '../models/ChannelModel'; 10 | import { ChatContainer, Header, RightSide, ChatLayout, SideBar, BottomPrimaryButton, ChatWorkspace } from '../components/ChatPrimitives'; 11 | 12 | export interface IChatPageProps { 13 | model: ChatModel; 14 | store: IAppStore; 15 | } 16 | 17 | export default class ChatPage extends React.Component { 18 | 19 | componentWillUnmount() { 20 | this.props.model.unsubscribe(); 21 | } 22 | 23 | createChannel = async () => { 24 | const receiver = prompt('receiver', ''); 25 | if (!receiver) return alert('Add receiver please'); 26 | try { 27 | await this.props.model.channelsList.createChannel(receiver); 28 | } catch (e) { 29 | alert(e.message); 30 | } 31 | }; 32 | 33 | sendMessage = async (message: string) => { 34 | try { 35 | await this.props.model.sendMessage(message) 36 | } catch (e) { 37 | alert(e); 38 | } 39 | } 40 | 41 | selectChannel = (channelInfo: IChannel) => this.props.model.listenMessages(channelInfo); 42 | signOut = () => firebase.auth().signOut(); 43 | 44 | render() { 45 | if (this.props.store.error) alert(this.props.store.error); 46 | return ( 47 | 48 |
49 | 50 | Virgilgram 51 | 52 | 53 | {this.props.model.email} 54 | 55 | logout 56 | 57 | 58 |
59 | 60 | 61 | 66 | 67 | New Channel 68 | 69 | 70 | 71 | {this.props.store.currentChannel ? ( 72 | 73 | 74 | 75 | 76 | ) : ( 77 | 'Select Channel First' 78 | )} 79 | 80 | 81 |
82 | ); 83 | } 84 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "esnext", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */ 5 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 6 | "lib": ["es2015", "dom", "es2015.promise", "es2016.array.include"], /* Specify library files to be included in the compilation. */ 7 | "allowJs": true, /* Allow javascript files to be compiled. */ 8 | // "checkJs": true, /* Report errors in .js files. */ 9 | "jsx": "react", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 10 | "declaration": false, /* Generates corresponding '.d.ts' file. */ 11 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 12 | "sourceMap": true, /* Generates corresponding '.map' file. */ 13 | // "outFile": "./", /* Concatenate and emit output to single file. */ 14 | // "outDir": "./", /* Redirect output structure to the directory. */ 15 | "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 16 | // "composite": true, /* Enable project compilation */ 17 | // "removeComments": true, /* Do not emit comments to output. */ 18 | "noEmit": true, /* Do not emit outputs. */ 19 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 20 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 21 | "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 22 | 23 | /* Strict Type-Checking Options */ 24 | "strict": true, /* Enable all strict type-checking options. */ 25 | "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 26 | // "strictNullChecks": true, /* Enable strict null checks. */ 27 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 28 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 29 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 30 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 31 | 32 | /* Additional Checks */ 33 | "noUnusedLocals": true, /* Report errors on unused locals. */ 34 | "noUnusedParameters": true, /* Report errors on unused parameters. */ 35 | "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 36 | "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 37 | 38 | /* Module Resolution Options */ 39 | "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 40 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 41 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 42 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 43 | // "typeRoots": [], /* List of folders to include type definitions from. */ 44 | // "types": [], /* Type declaration files to be included in compilation. */ 45 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 46 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 47 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 48 | 49 | /* Source Map Options */ 50 | // "sourceRoot": "./", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 51 | // "mapRoot": "./", /* Specify the location where debugger should locate map files instead of generated locations. */ 52 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 53 | "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 54 | 55 | /* Experimental Options */ 56 | "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 57 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 58 | }, 59 | "include": ["./src/**/*"] 60 | } -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "tslint-react" 4 | ], 5 | "rules": { 6 | "ban": false, 7 | "class-name": true, 8 | "comment-format": [ 9 | true, 10 | "check-space" 11 | ], 12 | "curly": false, 13 | "eofline": false, 14 | "forin": true, 15 | "interface-name": [true, "always-prefix"], 16 | "jsdoc-format": true, 17 | "jsx-no-lambda": false, 18 | "jsx-no-multiline-js": false, 19 | "label-position": true, 20 | "jsx-wrap-multiline": false, 21 | "jsx-boolean-value": false, 22 | "no-any": true, 23 | "no-arg": true, 24 | "no-bitwise": false, 25 | "no-console": [ 26 | true, 27 | "log", 28 | "debug", 29 | "info", 30 | "time", 31 | "timeEnd" 32 | ], 33 | "no-construct": true, 34 | "no-debugger": true, 35 | "no-duplicate-variable": true, 36 | "no-eval": true, 37 | "no-string-literal": true, 38 | "no-switch-case-fall-through": true, 39 | "no-trailing-whitespace": false, 40 | "no-use-before-declare": true, 41 | "prettier": true, 42 | "radix": true, 43 | "switch-default": true, 44 | "trailing-comma": false, 45 | "triple-equals": [ true, "allow-null-check" ], 46 | "typedef": [ 47 | true, 48 | "parameter", 49 | "property-declaration" 50 | ], 51 | "variable-name": [true, "ban-keywords", "check-format", "allow-leading-underscore", "allow-pascal-case"] 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | 4 | module.exports = { 5 | mode: 'development', 6 | entry: ['babel-polyfill', './src/index.tsx'], 7 | devtool: 'cheap-module-source-map', 8 | output: { 9 | filename: 'main.js', 10 | path: path.resolve(__dirname, 'dist'), 11 | }, 12 | resolve: { 13 | extensions: ['.tsx', '.ts', '.js'] 14 | }, 15 | module: { 16 | rules: [ 17 | { 18 | test: /\.css$/, 19 | use: [ 20 | 'style-loader', 21 | 'css-loader' 22 | ] 23 | }, 24 | { 25 | test: /\.tsx?$/, 26 | loader: 'babel-loader', 27 | }, 28 | { 29 | test: /\.wasm$/, 30 | type: 'javascript/auto', 31 | loader: 'file-loader', 32 | } 33 | ], 34 | }, 35 | plugins: [ 36 | new HtmlWebpackPlugin({ 37 | inject: true, 38 | template: path.resolve(__dirname, './src/index.html'), 39 | }), 40 | ], 41 | devServer: { 42 | contentBase: './dist/', 43 | port: 1234, 44 | hot: false 45 | } 46 | }; --------------------------------------------------------------------------------