├── .gitattributes ├── src ├── main │ ├── windows │ │ ├── index.js │ │ └── main.js │ ├── lib │ │ ├── State.js │ │ ├── Server.js │ │ ├── util.js │ │ ├── Queue.js │ │ ├── Chats.js │ │ ├── Peers.js │ │ └── Crypto.js │ ├── menu.js │ └── index.js ├── renderer │ ├── components │ │ ├── Messenger.js │ │ ├── ChatSearch.js │ │ ├── Compose.js │ │ ├── Toolbar.js │ │ ├── SetupIdentityModal.js │ │ ├── Modal.js │ │ ├── ToolbarDropdown.js │ │ ├── ImportIdentityModal.js │ │ ├── CreateIdentityModal.js │ │ ├── ChatList.js │ │ ├── Chat.js │ │ ├── Message.js │ │ ├── MessageList.js │ │ └── App.js │ ├── index.js │ └── lib │ │ ├── util.js │ │ └── notifications.js ├── consts.js └── config.js ├── .github ├── messenger.png └── messenger_dark.png ├── static ├── fonts │ └── ionicons.woff2 ├── scss │ ├── themes │ │ ├── default │ │ │ ├── _index.scss │ │ │ ├── _dark.scss │ │ │ └── _light.scss │ │ ├── vibrancy │ │ │ ├── _index.scss │ │ │ ├── _dark.scss │ │ │ └── _light.scss │ │ └── _index.scss │ ├── components │ │ ├── SetupIdentityModal.scss │ │ ├── ChatList.scss │ │ ├── ChatSearch.scss │ │ ├── Button.scss │ │ ├── Messenger.scss │ │ ├── Toolbar.scss │ │ ├── Notification.scss │ │ ├── MessageList.scss │ │ ├── Compose.scss │ │ ├── ToolbarButton.scss │ │ ├── Chat.scss │ │ ├── Message.scss │ │ └── Modal.scss │ ├── index.scss │ └── ionicons.scss └── index.html ├── .gitmodules ├── .editorconfig ├── .babelrc ├── .gitignore ├── scripts └── sass-loader.js ├── gulpfile.js ├── LICENSE ├── webpack.config.js ├── readme.md └── package.json /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /src/main/windows/index.js: -------------------------------------------------------------------------------- 1 | exports.main = require('./main') -------------------------------------------------------------------------------- /.github/messenger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hr/ciphora/master/.github/messenger.png -------------------------------------------------------------------------------- /.github/messenger_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hr/ciphora/master/.github/messenger_dark.png -------------------------------------------------------------------------------- /static/fonts/ionicons.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hr/ciphora/master/static/fonts/ionicons.woff2 -------------------------------------------------------------------------------- /static/scss/themes/default/_index.scss: -------------------------------------------------------------------------------- 1 | @import 'dark'; 2 | @import 'light'; 3 | 4 | $theme: ( 5 | light: $lightTheme, 6 | dark: $darkTheme, 7 | ) -------------------------------------------------------------------------------- /static/scss/themes/vibrancy/_index.scss: -------------------------------------------------------------------------------- 1 | @import 'dark'; 2 | @import 'light'; 3 | 4 | $theme: ( 5 | light: $lightTheme, 6 | dark: $darkTheme, 7 | ) -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "src/main/lib/simple-peer"] 2 | path = src/main/lib/simple-peer 3 | url = https://github.com/t-mullen/simple-peer 4 | branch = datachannel2 5 | -------------------------------------------------------------------------------- /static/scss/components/SetupIdentityModal.scss: -------------------------------------------------------------------------------- 1 | @import '../themes'; 2 | 3 | @include themify() { 4 | .setup-identity-modal .modal-body { 5 | text-align: center; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | quote_type = single 11 | max_line_length = 80 -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/env", "@babel/react"], 3 | "plugins": [ 4 | "@babel/plugin-proposal-class-properties", 5 | [ 6 | "@babel/plugin-transform-runtime", 7 | { 8 | "regenerator": true 9 | } 10 | ] 11 | ] 12 | } -------------------------------------------------------------------------------- /static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Ciphora 7 | 8 | 9 |
10 | 11 | -------------------------------------------------------------------------------- /static/scss/components/ChatList.scss: -------------------------------------------------------------------------------- 1 | @import '../themes'; 2 | 3 | @include themify() { 4 | .chat-list { 5 | min-height: 100vh; 6 | display: flex; 7 | flex-direction: column; 8 | -webkit-app-region: drag; 9 | 10 | > .toolbar { 11 | background-color: transparent; 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/renderer/components/Messenger.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export default function Messenger (props) { 4 | return ( 5 |
6 |
{props.sidebar}
7 |
{props.content}
8 |
9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /src/renderer/components/ChatSearch.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export default function ChatSearch () { 4 | return ( 5 |
6 | 11 |
12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /src/renderer/components/Compose.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export default function Compose (props) { 4 | return ( 5 |
6 |
7 | 8 | {!props.disabled &&
{props.rightitems}
} 9 |
10 |
11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /src/consts.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /** 3 | * Constants (static) 4 | */ 5 | 6 | module.exports = { 7 | PGP_KEY_ALGOS: [ 8 | 'rsa-4096', 9 | 'rsa-2048', 10 | 'ecc-curve25519', 11 | 'ecc-ed25519', 12 | 'ecc-p256', 13 | 'ecc-p384', 14 | 'ecc-p521', 15 | 'ecc-secp256k1', 16 | 'ecc-brainpoolP256r1', 17 | 'ecc-brainpoolP384r1', 18 | 'ecc-brainpoolP512r1' 19 | ], 20 | COMPOSE_CHAT_ID: 'newchat', 21 | CONTENT_TYPES: { 22 | TEXT: 'text', 23 | IMAGE: 'image', 24 | FILE: 'file' 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/renderer/components/Toolbar.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export function ToolbarButton (props) { 4 | return 5 | } 6 | 7 | export default function Toolbar (props) { 8 | const { title, leftItems, rightItems } = props 9 | return ( 10 |
11 |
{leftItems}
12 | {title &&

{title}

} 13 | {rightItems &&
{rightItems}
} 14 |
15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /static/scss/components/ChatSearch.scss: -------------------------------------------------------------------------------- 1 | @import '../themes'; 2 | 3 | @include themify() { 4 | .chat-search { 5 | padding: 10px; 6 | display: flex; 7 | flex-direction: column; 8 | } 9 | 10 | .chat-search-input { 11 | background: themed('backgroundColor'); 12 | padding: 8px 10px; 13 | border-radius: 10px; 14 | border: none; 15 | font-size: 14px; 16 | } 17 | 18 | .chat-search-input::placeholder { 19 | text-align: center; 20 | } 21 | 22 | .chat-search-input:focus::placeholder { 23 | text-align: left; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /static/scss/themes/_index.scss: -------------------------------------------------------------------------------- 1 | @import 'default'; 2 | 3 | @mixin themify() { 4 | .theme-light { 5 | $internalTheme: map-get($theme, 'light') !global; 6 | @content; 7 | $internalTheme: null !global; 8 | } 9 | 10 | .theme-dark { 11 | $internalTheme: map-get($theme, 'dark') !global; 12 | @content; 13 | $internalTheme: null !global; 14 | } 15 | } 16 | 17 | @mixin shadow { 18 | box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 19 | 0 4px 6px -2px rgba(0, 0, 0, 0.05); 20 | } 21 | 22 | @function themed($key: 'primaryColor') { 23 | @return map-get($internalTheme, $key); 24 | } 25 | 26 | @function to-string($value) { 27 | @return inspect($value); 28 | } 29 | 30 | $horizontal-spacing: 0.8rem; 31 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /** 3 | * Configuration 4 | */ 5 | 6 | const path = require('path'), 7 | { app } = require('electron'), 8 | { is } = require('electron-util') 9 | 10 | const CONFIG = { 11 | LOAD_URL: `file://${path.join(__dirname, '../app/index.html')}`, 12 | WS_URI: 'ws://cipher.com', 13 | DB_PATH: path.join(app.getPath('userData'), 'ciphora.db'), 14 | MEDIA_DIR: path.join(app.getPath('userData'), 'media'), 15 | MAIN_WIN_WIDTH: 875, 16 | MAIN_WIN_HEIGHT: 500 17 | } 18 | 19 | const CONFIG_DEV = { 20 | ...CONFIG, 21 | LOAD_URL: 'http://localhost:9000', 22 | WS_URI: 'ws://localhost:7000', 23 | MAIN_WIN_WIDTH: 875, 24 | MAIN_WIN_HEIGHT: 500 25 | } 26 | 27 | module.exports = is.development ? CONFIG_DEV : CONFIG 28 | -------------------------------------------------------------------------------- /static/scss/components/Button.scss: -------------------------------------------------------------------------------- 1 | @import '../themes'; 2 | 3 | @include themify() { 4 | button { 5 | display: inline-block; 6 | border: none; 7 | border-radius: 30px; 8 | padding: 0.6rem 2rem; 9 | margin: 0; 10 | text-decoration: none; 11 | background: themed('primaryColor'); 12 | color: themed('buttonForegroundColor'); 13 | font-size: 1rem; 14 | font-weight: bold; 15 | cursor: pointer; 16 | text-align: center; 17 | transition: filter 250ms ease-in-out; 18 | -webkit-appearance: none; 19 | } 20 | 21 | button:hover { 22 | filter: brightness(80%); 23 | cursor: pointer; 24 | } 25 | 26 | button:active { 27 | filter: brightness(60%); 28 | } 29 | 30 | button:disabled { 31 | background: themed('backgroundColor'); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/renderer/components/SetupIdentityModal.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Modal from './Modal' 3 | 4 | export default function SetupIdentityModal (props) { 5 | return ( 6 | 10 | 11 |

Setup your identity

12 |

All you need to start messaging is a PGP key

13 | 14 | } 15 | body={ 16 | 17 | 18 |
19 | 20 |
21 | } 22 | {...props} 23 | /> 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /static/scss/themes/default/_dark.scss: -------------------------------------------------------------------------------- 1 | $darkTheme: ( 2 | primaryColor: #0061E6, 3 | 4 | windowBackgroundColor: #000, 5 | contentBackgroundColor: #000, 6 | backgroundColor: #323232, 7 | transparentBackgroundColor: #3339, 8 | overlayBackgroundColor: #0006, 9 | 10 | primaryForegroundColor: #fffc, 11 | primaryColorForegroundColor: #fff, 12 | secondaryForegroundColor: #aaa, 13 | mutedForegroundColor: #252525, 14 | 15 | highlightBorderColor: #333, 16 | 17 | sidebarBackgroundColor: #222, 18 | sidebarSelectedItemBackgroundColor: #323232, 19 | 20 | modalBackgroundColor: #222, 21 | modalOverlayColor: #1119, 22 | modalShadowColor: #0008, 23 | 24 | toolbarButtonColor: #0061E6, 25 | buttonBackgroundColor: #0061E6, 26 | buttonForegroundColor: #fff, 27 | 28 | dangerColor: #dc3545, 29 | dangerForegroundColor: #fff, 30 | onlineColor: #01A452, 31 | ); -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore build folder 2 | app/ 3 | 4 | # Logs 5 | logs 6 | *.log 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | 13 | # Directory for instrumented libs generated by jscoverage/JSCover 14 | lib-cov 15 | 16 | # Coverage directory used by tools like istanbul 17 | coverage 18 | 19 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 20 | .grunt 21 | 22 | # node-waf configuration 23 | .lock-wscript 24 | 25 | # Compiled binary addons (http://nodejs.org/api/addons.html) 26 | build/Release 27 | .eslintcache 28 | 29 | # Dependency directory 30 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 31 | node_modules 32 | 33 | # OSX 34 | .DS_Store 35 | 36 | # App packaged 37 | release 38 | dist 39 | dll 40 | *.map 41 | 42 | npm-debug.log.* 43 | *.css.d.ts 44 | *.sass.d.ts 45 | *.scss.d.ts -------------------------------------------------------------------------------- /static/scss/themes/default/_light.scss: -------------------------------------------------------------------------------- 1 | $lightTheme: ( 2 | primaryColor: #0084ff, 3 | 4 | windowBackgroundColor: #fff, 5 | contentBackgroundColor: #fff, 6 | backgroundColor: #ebebeb, 7 | transparentBackgroundColor: #0001, 8 | overlayBackgroundColor: #fffa, 9 | 10 | primaryForegroundColor: #000000, 11 | primaryColorForegroundColor: #fff, 12 | secondaryForegroundColor: #555, 13 | mutedForegroundColor: #a5a5a5, 14 | 15 | highlightBorderColor: #eee, 16 | 17 | sidebarBackgroundColor: #f5f5f5, 18 | sidebarSelectedItemBackgroundColor: #ebebeb, 19 | 20 | modalBackgroundColor: #fff, 21 | modalOverlayColor: #1119, 22 | modalShadowColor: #0003, 23 | 24 | toolbarButtonColor: #0084ff, 25 | buttonBackgroundColor: #0084ff, 26 | buttonForegroundColor: #fff, 27 | 28 | dangerColor: #dc3545, 29 | dangerForegroundColor: #fff, 30 | onlineColor: #01A452, 31 | ); -------------------------------------------------------------------------------- /static/scss/themes/vibrancy/_dark.scss: -------------------------------------------------------------------------------- 1 | $darkTheme: ( 2 | primaryColor: #0061E6, 3 | 4 | windowBackgroundColor: transparent, 5 | contentBackgroundColor: #222e, 6 | backgroundColor: #222, 7 | transparentBackgroundColor: #222e, 8 | overlayBackgroundColor: #5556, 9 | 10 | primaryForegroundColor: #fffc, 11 | primaryColorForegroundColor: #fff, 12 | secondaryForegroundColor: #aaa, 13 | mutedForegroundColor: #252525, 14 | 15 | highlightBorderColor: #333, 16 | 17 | sidebarBackgroundColor: transparent, 18 | sidebarSelectedItemBackgroundColor: #222e, 19 | 20 | modalBackgroundColor: #555d, 21 | modalOverlayColor: #1119, 22 | modalShadowColor: #0008, 23 | 24 | toolbarButtonColor: #0061E6, 25 | buttonBackgroundColor: #0061E6, 26 | buttonForegroundColor: #fff, 27 | 28 | dangerColor: #dc3545, 29 | dangerForegroundColor: #fff, 30 | onlineColor: #01A452, 31 | ); -------------------------------------------------------------------------------- /static/scss/themes/vibrancy/_light.scss: -------------------------------------------------------------------------------- 1 | $lightTheme: ( 2 | primaryColor: #0084ff, 3 | 4 | windowBackgroundColor: transparent, 5 | contentBackgroundColor: #fffe, 6 | backgroundColor: #fff, 7 | transparentBackgroundColor: #fffe, 8 | overlayBackgroundColor: #eeea, 9 | 10 | primaryForegroundColor: #000000, 11 | primaryColorForegroundColor: #fff, 12 | secondaryForegroundColor: #555, 13 | mutedForegroundColor: #a5a5a5, 14 | 15 | highlightBorderColor: #eee, 16 | 17 | sidebarBackgroundColor: transparent, 18 | sidebarSelectedItemBackgroundColor: #fffe, 19 | 20 | modalBackgroundColor: #fffe, 21 | modalOverlayColor: #1119, 22 | modalShadowColor: #0003, 23 | 24 | toolbarButtonColor: #0084ff, 25 | buttonBackgroundColor: #0084ff, 26 | buttonForegroundColor: #fff, 27 | 28 | dangerColor: #dc3545, 29 | dangerForegroundColor: #fff, 30 | onlineColor: #01A452, 31 | ); -------------------------------------------------------------------------------- /static/scss/index.scss: -------------------------------------------------------------------------------- 1 | @import 'components/*'; 2 | @import 'ionicons'; 3 | @import 'themes'; 4 | 5 | @include themify() { 6 | margin: 0; 7 | padding: 0; 8 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 9 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 10 | sans-serif; 11 | -webkit-font-smoothing: antialiased; 12 | background-color: themed('windowBackgroundColor'); 13 | 14 | .danger { 15 | background: themed('dangerColor') !important; 16 | color: themed('dangerForegroundColor') !important; 17 | } 18 | 19 | code { 20 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 21 | monospace; 22 | } 23 | 24 | * { 25 | -webkit-appearance: none; 26 | user-select: none; 27 | color: themed('primaryForegroundColor'); 28 | } 29 | 30 | *:focus { 31 | outline: none; 32 | } 33 | 34 | a { 35 | cursor: pointer; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /scripts/sass-loader.js: -------------------------------------------------------------------------------- 1 | const path = require('path'), 2 | glob = require('glob') 3 | 4 | module.exports = { 5 | loader: 'sass-loader', 6 | options: { 7 | sassOptions: loaderContext => { 8 | const { resourcePath, rootContext } = loaderContext 9 | const dir = path.dirname(resourcePath) 10 | return { 11 | indentWidth: 4, 12 | webpackImporter: false, 13 | includePaths: ['static/scss'], 14 | importer: function (url, prev, done) { 15 | // Add support for sass @import globs 16 | const absUrl = path.join(dir, url) 17 | globs = absUrl.includes('*') && glob.sync(absUrl) 18 | 19 | if (globs) { 20 | const contents = globs 21 | .map(p => `@import '${p.replace(dir + path.sep, '')}';`) 22 | .join('\n') 23 | 24 | return { contents } 25 | } 26 | 27 | return null 28 | } 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | const spawn = require('child_process').spawn 2 | const { watch } = require('gulp') 3 | var p 4 | 5 | const SRC_FILES = [ 6 | './src/main/*.js', 7 | './src/main/windows/*.js', 8 | './src/main/lib/*.js' 9 | ] 10 | const ELECTRON = __dirname + '/node_modules/.bin/electron' 11 | const DEBUG = false 12 | let args = ['.'] 13 | // Start the electron process. 14 | async function electron () { 15 | // kill previous spawned process 16 | if (p) { 17 | p.kill() 18 | } 19 | 20 | if (DEBUG) args.unshift('--inspect=5858') 21 | // `spawn` a child `gulp` process linked to the parent `stdio` 22 | p = await spawn(ELECTRON, args, { 23 | stdio: 'inherit', 24 | env: { 25 | ...process.env, 26 | DEBUG: 'simple-peer' 27 | } 28 | }) 29 | } 30 | 31 | exports.default = () => { 32 | watch( 33 | SRC_FILES, 34 | { 35 | queue: false, 36 | ignoreInitial: false // Execute task on startup 37 | }, 38 | electron 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /static/scss/components/Messenger.scss: -------------------------------------------------------------------------------- 1 | @import '../themes'; 2 | 3 | @include themify() { 4 | .messenger { 5 | display: grid; 6 | width: 100%; 7 | height: 100vh; 8 | background: transparent; 9 | grid-template-columns: 270px auto; 10 | grid-template-rows: 60px auto 60px; 11 | grid-column-gap: 0; 12 | grid-row-gap: 0; 13 | } 14 | 15 | .container { 16 | padding: 10px; 17 | } 18 | 19 | .scrollable { 20 | position: relative; 21 | overflow-y: scroll; 22 | } 23 | 24 | .scrollable::-webkit-scrollbar { 25 | width: 0 !important 26 | } 27 | 28 | .sidebar { 29 | background: themed('sidebarBackgroundColor'); 30 | grid-row-start: 1; 31 | grid-row-end: span 3; 32 | } 33 | 34 | .content { 35 | background: themed('contentBackgroundColor'); 36 | grid-row-start: 1; 37 | grid-row-end: span 3; 38 | } 39 | 40 | .footer { 41 | grid-column-start: 2; 42 | background-color: themed('backgroundColor'); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /static/scss/components/Toolbar.scss: -------------------------------------------------------------------------------- 1 | @import '../themes'; 2 | 3 | @include themify() { 4 | .toolbar { 5 | display: flex; 6 | align-items: center; 7 | justify-content: space-between; 8 | font-weight: 500; 9 | position: sticky; 10 | top: 0px; 11 | border: none; 12 | background-color: themed('overlayBackgroundColor'); 13 | backdrop-filter: blur(20px); 14 | z-index: 1000; 15 | } 16 | 17 | .toolbar-title { 18 | margin: 0; 19 | font-size: 16px; 20 | font-weight: 800; 21 | } 22 | 23 | .left-items, 24 | .right-items { 25 | flex: 0 1; 26 | padding: 10px; 27 | display: flex; 28 | } 29 | 30 | .right-items { 31 | flex-direction: row-reverse; 32 | } 33 | 34 | .left-items .toolbar-button { 35 | margin-right: 20px; 36 | } 37 | 38 | .right-items .toolbar-button { 39 | margin-left: 20px; 40 | } 41 | 42 | .left-items .toolbar-button:last-child, 43 | .right-items .toolbar-button:last-child { 44 | margin: 0; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /static/scss/components/Notification.scss: -------------------------------------------------------------------------------- 1 | @import '../themes'; 2 | 3 | @include themify() { 4 | .notification-container { 5 | position: fixed; 6 | bottom: 0; 7 | width: 100%; 8 | } 9 | 10 | .notification { 11 | margin: 0 auto; 12 | margin-bottom: 15px; 13 | width: 300px; 14 | background: themed('backgroundColor'); 15 | backdrop-filter: blur(20px); 16 | text-align: left; 17 | font-size: 14px; 18 | overflow: hidden; 19 | border-radius: 0.4rem; 20 | padding: 1rem; 21 | font-weight: bold; 22 | display: flex; 23 | align-items: center; 24 | justify-content: space-between; 25 | justify-items: center; 26 | @include shadow; 27 | } 28 | 29 | .notification > .dismiss:hover { 30 | opacity: 0.8; 31 | } 32 | 33 | .notification > .dismiss { 34 | cursor: pointer; 35 | } 36 | 37 | .error.notification { 38 | background: themed('dangerColor'); 39 | color: themed('dangerForegroundColor'); 40 | 41 | > .dismiss { 42 | color: themed('dangerForegroundColor'); 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Habib Rehman 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 | -------------------------------------------------------------------------------- /static/scss/components/MessageList.scss: -------------------------------------------------------------------------------- 1 | @import '../themes'; 2 | 3 | @include themify() { 4 | .message-list { 5 | min-height: 100vh; 6 | display: flex; 7 | flex-direction: column; 8 | 9 | > .toolbar { 10 | background-color: themed('overlayBackgroundColor'); 11 | min-height: 53px; 12 | -webkit-app-region: drag; 13 | 14 | .toolbar-title { 15 | flex: 1; 16 | } 17 | } 18 | 19 | &.composing { 20 | > .toolbar .left-items { 21 | flex: 1; 22 | } 23 | } 24 | } 25 | 26 | .message-list-container { 27 | flex: 1 1 auto; 28 | padding: $horizontal-spacing; 29 | } 30 | 31 | .userid-area { 32 | display: flex; 33 | flex-direction: column; 34 | justify-content: center; 35 | align-items: center; 36 | background-color: themed('backgroundColor'); 37 | margin: -0.5rem 0 0.5rem; 38 | padding: 0.5rem 1rem; 39 | 40 | span { 41 | flex-shrink: 0; 42 | } 43 | 44 | .userid-input { 45 | flex-grow: 1; 46 | margin: 0 0.5rem; 47 | border: none; 48 | font-size: 14px; 49 | background: none; 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/renderer/components/Modal.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { classList } from '../lib/util' 3 | 4 | export default function Modal (props) { 5 | return ( 6 |
13 |
17 | 18 |
19 | 35 |
36 |
37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /src/renderer/components/ToolbarDropdown.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { classList } from '../lib/util' 3 | 4 | export function ToolbarDropdownButton (props) { 5 | const [infoDropdownActive, setInfoDropdownActive] = useState(false) 6 | 7 | return ( 8 |
14 | setInfoDropdownActive(!infoDropdownActive)} 17 | > 18 | {props.title} 19 | 20 |
setInfoDropdownActive(false)} 23 | > 24 | {props.children} 25 |
26 |
27 | ) 28 | } 29 | 30 | export function ToolbarDropdownUserInfo (props) { 31 | return ( 32 |
33 | {props.name} 34 | 40 |
41 | ) 42 | } 43 | 44 | export function ToolbarDropdownItem (props) { 45 | return
46 | } 47 | -------------------------------------------------------------------------------- /src/renderer/index.js: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from 'electron' 2 | import React from 'react' 3 | import ReactDOM from 'react-dom' 4 | import App from './components/App' 5 | import { NotificationProvider } from './lib/notifications' 6 | import { darkMode } from 'electron-util' 7 | // Show dark mode if it is already enabled in the operating system 8 | let isDarkMode = darkMode.isEnabled 9 | 10 | // Sets the UI theme appropriately 11 | function setTheme () { 12 | if (isDarkMode) { 13 | // Use dark theme 14 | document.body.classList.remove('theme-light') 15 | document.body.classList.add('theme-dark') 16 | } else { 17 | // Use light theme 18 | document.body.classList.remove('theme-dark') 19 | document.body.classList.add('theme-light') 20 | } 21 | } 22 | 23 | // Switches the UI theme between light and dark 24 | function switchTheme () { 25 | isDarkMode = !isDarkMode 26 | setTheme() 27 | } 28 | 29 | // Add a shortcut to switch themes 30 | window.onkeyup = function (e) { 31 | // ctrl + t 32 | if (e.ctrlKey && e.key === 't') { 33 | switchTheme() 34 | } 35 | } 36 | 37 | // Set theme accordingly 38 | setTheme() 39 | 40 | // Render the entire UI 41 | ReactDOM.render( 42 | 43 | 44 | , 45 | document.getElementById('app') 46 | ) 47 | -------------------------------------------------------------------------------- /src/renderer/lib/util.js: -------------------------------------------------------------------------------- 1 | // Makes PGP error messages user friendly 2 | export function friendlyError (error) { 3 | return error.message.slice( 4 | error.message.lastIndexOf('Error'), 5 | error.message.length 6 | ) 7 | } 8 | // Makes a deep clone of an object 9 | export function clone (obj) { 10 | return JSON.parse(JSON.stringify(obj)) 11 | } 12 | 13 | // Generates a hash code for a string 14 | export function hashCode (string) { 15 | for (var i = 0, h = 0; i < string.length; i++) 16 | h = (Math.imul(31, h) + string.charCodeAt(i)) | 0 17 | return h 18 | } 19 | 20 | // Returns the initials of a name 21 | export function initialsise (name) { 22 | let iname = name.toUpperCase().split(' ') 23 | let initials = name[0] 24 | if (iname.length > 1) { 25 | initials += iname[iname.length - 1][0] 26 | } 27 | return initials 28 | } 29 | 30 | // Turns an object into a react clasaName compatible list 31 | export function classList (classes) { 32 | if (!Array.isArray(classes)) { 33 | // Turn into an array if not already 34 | classes = Object.entries(classes) 35 | .filter(entry => entry[1]) 36 | .map(entry => entry[0]) 37 | } 38 | return classes.join(' ') 39 | } 40 | 41 | // Checks if an object is empty 42 | export function isEmpty (obj) { 43 | return Object.keys(obj).length === 0 && obj.constructor === Object 44 | } 45 | -------------------------------------------------------------------------------- /src/main/lib/State.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /** 3 | * State class 4 | * Manages the state of the app 5 | */ 6 | 7 | const STATE_DB_KEY = 'state' 8 | 9 | module.exports = class State { 10 | constructor (store) { 11 | // Ensure singleton 12 | if (!!State.instance) { 13 | return State.instance 14 | } 15 | 16 | this._store = store 17 | this._state = {} 18 | 19 | State.instance = this 20 | } 21 | 22 | // Loads all saved state from the store 23 | async init () { 24 | try { 25 | this._state = await this._store.get(STATE_DB_KEY) 26 | console.log('Loaded state') 27 | console.log(this._state) 28 | return true 29 | } catch (err) { 30 | // No self keys exist 31 | if (err.notFound) return false 32 | throw err 33 | } 34 | } 35 | 36 | // Gets the state 37 | getState () { 38 | return this._state 39 | } 40 | 41 | // Sets a state value 42 | async set (key, value) { 43 | this._state[key] = value 44 | await this._saveState() 45 | } 46 | 47 | // Gets a state value, otherwise returns default 48 | get (key, def = false) { 49 | return this._state.hasOwnProperty(key) ? this._state[key] : def 50 | } 51 | 52 | // Saves state to the store 53 | async _saveState () { 54 | await this._store.put(STATE_DB_KEY, this._state) 55 | console.log('Saved state') 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const HtmlWebPackPlugin = require('html-webpack-plugin'), 2 | MiniCssExtractPlugin = require('mini-css-extract-plugin'), 3 | path = require('path'), 4 | sassLoader = require('./scripts/sass-loader.js') 5 | 6 | module.exports = { 7 | entry: path.resolve(__dirname, 'src/renderer/index.js'), 8 | target: 'electron-renderer', 9 | output: { 10 | path: path.resolve(__dirname, 'app'), 11 | filename: 'app.js' 12 | }, 13 | devtool: 'eval-cheap-source-map', 14 | module: { 15 | rules: [ 16 | { 17 | test: /\.(js|jsx)$/, 18 | exclude: /node_modules/, 19 | use: ['babel-loader'] 20 | }, 21 | { 22 | test: /\.s[ac]ss$/i, 23 | use: [MiniCssExtractPlugin.loader, 'css-loader', sassLoader] 24 | }, 25 | { 26 | test: /\.html$/, 27 | use: { 28 | loader: 'html-loader' 29 | } 30 | }, 31 | { 32 | test: /\.(woff|woff2|eot|ttf|otf)$/, 33 | use: { 34 | loader: 'file-loader' 35 | } 36 | } 37 | ] 38 | }, 39 | plugins: [ 40 | new MiniCssExtractPlugin({ 41 | filename: 'app.css' 42 | }), 43 | new HtmlWebPackPlugin({ 44 | template: './static/index.html', 45 | filename: './index.html' 46 | }) 47 | ], 48 | devServer: { 49 | open: false, 50 | port: 9000 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /static/scss/components/Compose.scss: -------------------------------------------------------------------------------- 1 | @import '../themes'; 2 | 3 | @include themify() { 4 | .compose-input-area { 5 | flex: 1; 6 | display: flex; 7 | align-items: center; 8 | 9 | .compose-input { 10 | flex: 1; 11 | border: none; 12 | background: none; 13 | font-size: 14px; 14 | padding: 0.5rem 0.8rem; 15 | margin: 0rem; 16 | background-color: themed('transparentBackgroundColor'); 17 | border-radius: 100rem; 18 | } 19 | 20 | .compose-input::placeholder { 21 | opacity: 0.8; 22 | } 23 | } 24 | 25 | .compose-chat { 26 | font-size: 16px; 27 | flex: 1 1 auto; 28 | display: flex; 29 | align-items: center; 30 | 31 | .compose-input { 32 | background: none; 33 | } 34 | 35 | > span { 36 | opacity: 0.5; 37 | margin: 5px; 38 | } 39 | } 40 | 41 | .compose { 42 | padding: 0.5rem $horizontal-spacing; 43 | display: flex; 44 | align-items: center; 45 | position: sticky; 46 | bottom: 0px; 47 | background-color: themed('overlayBackgroundColor'); 48 | backdrop-filter: blur(20px); 49 | 50 | .toolbar-button { 51 | margin-right: $horizontal-spacing; 52 | font-size: 1.5rem; 53 | color: themed('primaryColor'); 54 | } 55 | 56 | .toolbar-button:first-child { 57 | margin-left: $horizontal-spacing; 58 | } 59 | 60 | .toolbar-button:last-child { 61 | margin-right: 0rem; 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /static/scss/components/ToolbarButton.scss: -------------------------------------------------------------------------------- 1 | @import '../themes'; 2 | 3 | @include themify() { 4 | .toolbar-button { 5 | display: inline-block; 6 | cursor: pointer; 7 | color: themed('toolbarButtonColor'); 8 | font-size: 28px; 9 | transition: filter 250ms ease-in-out; 10 | 11 | &:hover { 12 | filter: brightness(80%); 13 | } 14 | 15 | &:active { 16 | filter: brightness(60%); 17 | } 18 | } 19 | 20 | .toolbar-dropdown-button { 21 | cursor: pointer; 22 | position: relative; 23 | 24 | .dropdown-body { 25 | z-index: 1000; 26 | padding: 0.5rem 0; 27 | position: absolute; 28 | right: 0; 29 | background: themed('modalBackgroundColor'); 30 | backdrop-filter: blur(20px); 31 | border-radius: 6px; 32 | overflow: hidden; 33 | opacity: 1; 34 | transition: opacity 0.3s, transform 0.3s; 35 | @include shadow; 36 | } 37 | 38 | &:not(.active) .dropdown-body { 39 | visibility: collapse; 40 | transform: translateY(-20%); 41 | opacity: 0; 42 | } 43 | } 44 | 45 | .toolbar-dropdown-item { 46 | padding: 0.5rem 1rem; 47 | white-space: nowrap; 48 | cursor: pointer; 49 | transition: background-color 0.2s, filter 0.2s, color 0.2s; 50 | 51 | &:hover { 52 | background-color: themed('primaryColor'); 53 | color: themed('primaryColorForegroundColor'); 54 | } 55 | 56 | &:active { 57 | filter: brightness(80%); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/renderer/components/ImportIdentityModal.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import Modal from './Modal' 3 | 4 | const pgpExample = `-----BEGIN PGP PUBLIC KEY BLOCK----- 5 | 6 | mQENBFwFqjEBCAC9ZM3rjdJHmm+hOkuAQ............................... 7 | ..........................5C1n16MW5bvac4QSY/jhw08sRjLed3Q===7MVT 8 | -----END PGP PUBLIC KEY BLOCK----- 9 | 10 | -----BEGIN PGP PRIVATE KEY BLOCK----- 11 | 12 | X4doIG+e00kZncAFqeJcMy3ijjvjKypDGU2j............................ 13 | ..........GQRxAiHPLFsBr1ASV9B688YRyAf9WDJSEwfXG4eEw1/Rt99XBrm1c6 14 | -----END PGP PRIVATE KEY BLOCK-----` 15 | 16 | export default function ImportIdentityModal (props) { 17 | const [keys, setKeys] = useState('') 18 | const [passphrase, setPassphrase] = useState('') 19 | 20 | return ( 21 | 24 |

Import your PGP key

25 |

Enter your passphrase, public and private keys below

26 | 27 | } 28 | body={ 29 | 30 | 36 | setPassphrase(event.target.value)} 41 | > 42 | 43 | } 44 | action={ 45 | 48 | } 49 | {...props} 50 | /> 51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /src/main/lib/Server.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /** 3 | * Signal class 4 | * Sends and receives signals from peers and signal events from server 5 | */ 6 | 7 | const WebSocket = require('ws'), 8 | EventEmitter = require('events'), 9 | { encode } = require('querystring'), 10 | { WS_URI } = require('../../config') 11 | 12 | module.exports = class Server extends EventEmitter { 13 | constructor () { 14 | // Ensure singleton 15 | if (!!Server.instance) { 16 | return Server.instance 17 | } 18 | 19 | // Call EventEmitter constructor 20 | super() 21 | 22 | this._id = null 23 | this._ws = null 24 | 25 | // Bindings 26 | this.connect = this.connect.bind(this) 27 | this._emit = this._emit.bind(this) 28 | this.send = this.send.bind(this) 29 | 30 | Server.instance = this 31 | } 32 | 33 | // Connects to signal server 34 | connect (userId, authRequest) { 35 | this._id = userId 36 | // Build ws uri with authentication querystring data 37 | const wsAuthURI = WS_URI + '?' + encode(authRequest) 38 | return new Promise((resolve, reject) => { 39 | this._ws = new WebSocket(wsAuthURI) 40 | // Add event listeners 41 | this._ws.on('message', this._emit) 42 | this._ws.on('open', resolve) 43 | this._ws.on('error', reject) 44 | }) 45 | } 46 | 47 | // Sends signal to a peer (via server) 48 | send (type, extras = {}, cb) { 49 | const msg = JSON.stringify({ type, senderId: this._id, ...extras }) 50 | if (!cb) { 51 | return new Promise((resolve, reject) => this._ws.send(msg, null, resolve)) 52 | } 53 | this._ws.send(msg, null, cb) 54 | } 55 | 56 | // Emits a received signal event 57 | _emit (msg) { 58 | const { event, data } = JSON.parse(msg) 59 | console.log(`Signal event: ${event}`) 60 | this.emit(event, data) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/renderer/components/CreateIdentityModal.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import Modal from './Modal' 3 | import { PGP_KEY_ALGOS } from '../../consts' 4 | 5 | export default function CreateIdentityModal (props) { 6 | const [name, setName] = useState('') 7 | const [email, setEmail] = useState('') 8 | const [passphrase, setPassphrase] = useState('') 9 | const [algo, setAlgo] = useState(PGP_KEY_ALGOS[0]) 10 | 11 | return ( 12 | 15 |

Create a new PGP key

16 |

Enter your details below

17 | 18 | } 19 | body={ 20 | 21 | setName(event.target.value)} 25 | placeholder='Name' 26 | > 27 | setEmail(event.target.value)} 31 | placeholder='Email (optional)' 32 | > 33 | setPassphrase(event.target.value)} 37 | placeholder='Passphrase' 38 | > 39 | 46 | 47 | } 48 | action={ 49 | 54 | } 55 | {...props} 56 | /> 57 | ) 58 | } 59 | -------------------------------------------------------------------------------- /src/renderer/components/ChatList.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Chat from './Chat' 3 | import Toolbar, { ToolbarButton } from './Toolbar' 4 | import { 5 | ToolbarDropdownButton, 6 | ToolbarDropdownItem, 7 | ToolbarDropdownUserInfo 8 | } from './ToolbarDropdown' 9 | import { isEmpty } from '../lib/util' 10 | 11 | export default function ChatList (props) { 12 | // Set to last message there is one otherwise nothing 13 | function getLastMessage (chat) { 14 | return chat.messages.length && chat.messages[chat.messages.length - 1] 15 | } 16 | 17 | const toolbarItems = [ 18 | 23 | ] 24 | 25 | if (!isEmpty(props.profile)) { 26 | toolbarItems.push( 27 | 28 | 32 | 33 | Copy User ID 34 | 35 | 36 | Copy PGP Key 37 | 38 | 39 | ) 40 | } 41 | 42 | // Render UI 43 | return ( 44 |
45 | 46 | {/**/} 47 | {!!props.chats && 48 | props.chats.map(chat => ( 49 | props.onDeleteClick(chat.id)} 52 | onClick={() => props.onChatClick(chat.id)} 53 | id={chat.id} 54 | key={chat.id} 55 | name={chat.name} 56 | isOnline={chat.online} 57 | lastMessage={getLastMessage(chat)} 58 | /> 59 | ))} 60 |
61 | ) 62 | } 63 | -------------------------------------------------------------------------------- /src/main/lib/util.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = { 4 | waitUntil, 5 | parseAddress, 6 | isEmpty, 7 | chunk, 8 | hexToUint8, 9 | isString 10 | } 11 | 12 | // Wait until a condition is true 13 | function waitUntil (conditionFn, timeout, pollInterval = 30) { 14 | const start = Date.now() 15 | return new Promise((resolve, reject) => { 16 | ;(function wait () { 17 | if (conditionFn()) return resolve() 18 | else if (timeout && Date.now() - start >= timeout) 19 | return reject(new Error(`Timeout ${timeout} for waitUntil exceeded`)) 20 | else setTimeout(wait, pollInterval) 21 | })() 22 | }) 23 | } 24 | 25 | // Parses name/email address of format '[name] <[email]>' 26 | function parseAddress (address) { 27 | // Check if unknown 28 | address = address && address.length ? address[0] : 'Unknown' 29 | // Check if it has an email as well (follows the format) 30 | if (!address.includes('<')) { 31 | return { name: address } 32 | } 33 | 34 | let [name, email] = address.split('<').map(n => n.trim().replace(/>/g, '')) 35 | return { name, email } 36 | } 37 | 38 | // Checks if an object is empty 39 | function isEmpty (obj) { 40 | return Object.keys(obj).length === 0 && obj.constructor === Object 41 | } 42 | 43 | // Splits a buffer into chunks of a given size 44 | function chunk (buffer, chunkSize) { 45 | if (!Buffer.isBuffer(buffer)) throw new Error('Buffer is required') 46 | 47 | let result = [], 48 | i = 0, 49 | len = buffer.length 50 | 51 | while (i < len) { 52 | // If it does not equally divide then set last to whatever remains 53 | result.push(buffer.slice(i, Math.min((i += chunkSize), len))) 54 | } 55 | 56 | return result 57 | } 58 | 59 | // Converts a hex string into a Uint8Array 60 | function hexToUint8 (hex) { 61 | return Uint8Array.from(Buffer.from(hex, 'hex')) 62 | } 63 | 64 | // Checks if the given object is a string 65 | function isString (obj) { 66 | return typeof obj === 'string' || obj instanceof String 67 | } 68 | -------------------------------------------------------------------------------- /src/main/windows/main.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const main = (module.exports = { 4 | init, 5 | secondInstance, 6 | activate, 7 | send, 8 | win: null 9 | }) 10 | const { app, BrowserWindow } = require('electron'), 11 | { waitUntil } = require('../lib/util'), 12 | { LOAD_URL, MAIN_WIN_WIDTH, MAIN_WIN_HEIGHT } = require('../../config') 13 | 14 | // Create and initializes a new main window 15 | async function init () { 16 | let isWindowReady = false 17 | const win = (main.win = new BrowserWindow({ 18 | title: app.name, 19 | show: false, 20 | width: MAIN_WIN_WIDTH, 21 | height: MAIN_WIN_HEIGHT, 22 | minWidth: 700, 23 | minHeight: 400, 24 | // backgroundColor: '#00FFFFFF', 25 | frame: false, 26 | // vibrancy: 'appearance-based', 27 | titleBarStyle: 'hiddenInset', 28 | webPreferences: { 29 | enableRemoteModule: true, 30 | nodeIntegration: true, 31 | contextIsolation: false, 32 | webSecurity: false, // To allow local image loading 33 | allowRunningInsecureContent: true 34 | } 35 | })) 36 | 37 | win.on('ready-to-show', () => { 38 | isWindowReady = true 39 | win.show() 40 | }) 41 | 42 | win.on('closed', () => { 43 | // Dereference the window 44 | // For multiple windows store them in an array 45 | main.win = undefined 46 | }) 47 | 48 | await win.loadURL(LOAD_URL) 49 | // Wait until window has loaded 50 | await waitUntil(() => isWindowReady, 6000) 51 | return win 52 | } 53 | 54 | // Handles second instance of window 55 | function secondInstance () { 56 | if (main.win) { 57 | // Show existing window if it already exists 58 | if (main.win.isMinimized()) { 59 | main.win.restore() 60 | } 61 | 62 | main.win.show() 63 | } 64 | } 65 | 66 | // Activates the window 67 | function activate () { 68 | if (!main.win) { 69 | // Create the main window if it doesn't exist already 70 | main.win = init() 71 | } 72 | } 73 | 74 | // Sends an IPC message to the renderer (the window) 75 | function send (channel, ...args) { 76 | if (main.win) { 77 | main.win.webContents.send(channel, ...args) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/renderer/components/Chat.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { basename } from 'path' 3 | import moment from 'moment' 4 | import { initialsise } from '../lib/util' 5 | import { COMPOSE_CHAT_ID, CONTENT_TYPES } from '../../consts' 6 | 7 | const timeFormat = { 8 | sameDay: 'HH:mm', 9 | lastDay: '[Yesterday]', 10 | lastWeek: 'dddd', 11 | sameElse: 'L' 12 | } 13 | 14 | export default function Chat (props) { 15 | const [deleteOpacity, setDeleteOpacity] = useState(0) 16 | const { name, lastMessage, active } = props 17 | let content = null 18 | 19 | if (!lastMessage) { 20 | content = '' 21 | } else if (lastMessage.contentType === CONTENT_TYPES.TEXT) { 22 | // Text so show 23 | content = lastMessage.content 24 | } else { 25 | // File/image so show file name 26 | content = basename(lastMessage.content) 27 | } 28 | 29 | const time = lastMessage 30 | ? moment(lastMessage.timestamp).calendar(null, timeFormat) 31 | : '' 32 | let listItemClass = 'chat-list-item' 33 | if (active) { 34 | listItemClass += ' chat-list-item-active' 35 | } 36 | 37 | return ( 38 |
setDeleteOpacity(1)} 42 | onMouseLeave={() => setDeleteOpacity(0)} 43 | > 44 | {/* chat */} 45 |
46 | {props.id === COMPOSE_CHAT_ID ? '' : initialsise(name)} 47 | {props.isOnline &&
} 48 |
49 |
50 |
51 |

{name}

52 |

{content}

53 |
54 |
55 |
{time}
56 |
61 |
62 |
63 |
64 | ) 65 | } 66 | -------------------------------------------------------------------------------- /static/scss/components/Chat.scss: -------------------------------------------------------------------------------- 1 | @import '../themes'; 2 | 3 | @include themify() { 4 | .chat-list-item { 5 | display: flex; 6 | align-items: center; 7 | padding: 10px; 8 | transition: background 0.2s; 9 | } 10 | 11 | .chat-list-item:hover, 12 | .chat-list-item-active { 13 | background: themed('sidebarSelectedItemBackgroundColor'); 14 | cursor: pointer; 15 | } 16 | 17 | .chat-info { 18 | flex: 1 1 auto; 19 | display: flex; 20 | flex-direction: row; 21 | justify-content: space-between; 22 | align-items: center; 23 | } 24 | 25 | .chat-info, 26 | .chat-info-left, 27 | .chat-info-left * { 28 | white-space: nowrap; 29 | overflow: hidden; 30 | text-overflow: ellipsis; 31 | } 32 | 33 | .chat-info-right { 34 | text-align: right; 35 | } 36 | 37 | .chat-info-right > .chat-delete { 38 | color: themed('secondaryForegroundColor'); 39 | font-size: 16px; 40 | } 41 | 42 | .chat-info-right > .chat-delete:hover { 43 | color: themed('dangerColor'); 44 | } 45 | 46 | .chat-time { 47 | font-size: 14px; 48 | color: themed('secondaryForegroundColor'); 49 | } 50 | 51 | .chat-photo { 52 | width: 50px; 53 | height: 50px; 54 | border-radius: 50%; 55 | object-fit: cover; 56 | margin-right: 10px; 57 | } 58 | 59 | .chat-initials { 60 | min-width: 50px; 61 | min-height: 50px; 62 | border-radius: 50%; 63 | margin-right: 10px; 64 | margin-left: 5px; 65 | font-size: 1rem; 66 | line-height: 50px; 67 | text-align: center; 68 | background: lighten(themed('backgroundColor'), 20%); 69 | border: 1px solid themed('highlightBorderColor'); 70 | font-weight: bold; 71 | position: relative; 72 | } 73 | 74 | .chat-online { 75 | width: 12px; 76 | height: 12px; 77 | right: 2px; 78 | bottom: 2px; 79 | position: absolute; 80 | border-radius: 100%; 81 | background-color: themed('onlineColor'); 82 | } 83 | 84 | .chat-title { 85 | font-size: 15px; 86 | font-weight: bold; 87 | text-transform: capitalize; 88 | margin: 0; 89 | } 90 | 91 | .chat-snippet { 92 | font-size: 14px; 93 | color: themed('secondaryForegroundColor'); 94 | margin: 0; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/renderer/lib/notifications.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { hashCode } from './util' 3 | const Ctx = React.createContext() 4 | 5 | /** 6 | * Components 7 | *****************************/ 8 | 9 | const NotificationContainer = props => ( 10 |
11 | ) 12 | const Notification = ({ children, type, onDismiss, dismissable }) => ( 13 |
17 | {children} 18 | {dismissable && } 19 |
20 | ) 21 | 22 | /** 23 | * Provider 24 | *****************************/ 25 | 26 | export function NotificationProvider ({ children }) { 27 | const [notifications, setNotifications] = React.useState([]) 28 | 29 | // Dismiss a notification 30 | const dismiss = id => { 31 | const newNotifications = notifications.filter(n => n.id !== id) 32 | setNotifications(newNotifications) 33 | } 34 | 35 | // Dismiss 36 | const onDismiss = id => () => dismiss(id) 37 | 38 | // Clears all notifications 39 | const clear = () => { 40 | setNotifications([]) 41 | } 42 | 43 | // Show a notification 44 | const show = (content, type, dismissable = true, duration) => { 45 | type = type || '' 46 | const id = hashCode(content) 47 | const notification = { id, type, content, dismissable } 48 | setNotifications(notifications => { 49 | const alreadyShowing = notifications.find(n => n.id == id) 50 | // Do not show if already showing 51 | return alreadyShowing ? notifications : [notification, ...notifications] 52 | }) 53 | 54 | // Dismiss after duration if specified 55 | if (duration) setTimeout(onDismiss(id), duration) 56 | } 57 | 58 | return ( 59 | 60 | {children} 61 | 62 | {notifications.map(({ content, id, ...rest }) => ( 63 | 64 | {content} 65 | 66 | ))} 67 | 68 | 69 | ) 70 | } 71 | 72 | /** 73 | * Consumer 74 | *****************************/ 75 | export const useNotifications = isComponent => 76 | isComponent ? Ctx : React.useContext(Ctx) 77 | -------------------------------------------------------------------------------- /src/renderer/components/Message.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { basename } from 'path' 3 | import moment from 'moment' 4 | import { CONTENT_TYPES } from '../../consts' 5 | import { classList } from '../lib/util' 6 | 7 | const timeFormat = { 8 | sameDay: '[Today,] HH:mm', 9 | lastDay: '[Yesterday,] HH:mm', 10 | lastWeek: 'dddd [,] HH:mm', 11 | sameElse: 'dddd, D MMMM, YYYY HH:mm' 12 | } 13 | 14 | const LINK_REGEX = /^https?:/i 15 | const SPACES_REGEX = /\s+/ 16 | 17 | export default function Message (props) { 18 | let contentRender = null 19 | const { message, isMine, startsSequence, endsSequence, showTimestamp } = props 20 | const { timestamp, contentType, content } = message 21 | const friendlyTimestamp = moment(timestamp).calendar(null, timeFormat) 22 | 23 | function parseText (text) { 24 | // Parse links 25 | return text.split(SPACES_REGEX).map((part, index) => 26 | LINK_REGEX.test(part) ? ( 27 |
props.onLinkClick(part)}> 28 | {part} 29 | 30 | ) : ( 31 | ` ${part} ` 32 | ) 33 | ) 34 | } 35 | 36 | switch (contentType) { 37 | case CONTENT_TYPES.IMAGE: 38 | // Render as image 39 | const imgSrc = `file:///${content}` 40 | contentRender = ( 41 | 47 | ) 48 | break 49 | case CONTENT_TYPES.FILE: 50 | // Render as file 51 | const fileName = basename(content) 52 | const title = `${fileName} - ${friendlyTimestamp}` 53 | contentRender = ( 54 |
props.onFileClick(content)} 58 | > 59 | 60 |
61 | {fileName} 62 |
63 | ) 64 | break 65 | default: 66 | // Render as text by default 67 | contentRender = ( 68 |
69 | {parseText(content)} 70 |
71 | ) 72 | } 73 | 74 | return ( 75 |
83 | {showTimestamp &&
{friendlyTimestamp}
} 84 | 85 |
{contentRender}
86 |
87 | ) 88 | } 89 | -------------------------------------------------------------------------------- /src/main/lib/Queue.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /** 3 | * Queue class 4 | * (Message) task queue 5 | */ 6 | const EventEmitter = require('events') 7 | 8 | module.exports = class Queue extends EventEmitter { 9 | constructor (timeout) { 10 | // Call EventEmitter constructor 11 | super() 12 | 13 | this._queue = [] 14 | this._pendingCount = 0 15 | this._processing = -1 16 | this._timeout = timeout 17 | // this._responseTimeout = responseTimeout 18 | this._idle = true 19 | } 20 | 21 | // Adds a task 22 | // TODO: Add timeout interval for task hangup 23 | add (fn, id) { 24 | let timer 25 | let removeWhenDone = true 26 | 27 | if (id) { 28 | // If an id not passed then remove when done (not tracked by caller) 29 | removeWhenDone = false 30 | } 31 | 32 | const run = async () => { 33 | console.log('Running task', id) 34 | 35 | try { 36 | // const promise = fn.apply(null, args) 37 | await fn() 38 | // Set timeout for response 39 | // timer = setTimeout(() => { 40 | // throw new Error('Timeout') 41 | // }, this._timeout) 42 | console.log('Finished task', id) 43 | } catch (error) { 44 | this._error(id, error) 45 | } 46 | 47 | // Trigger next when done 48 | this._next() 49 | } 50 | 51 | this._queue.push({ id, run, timer, removeWhenDone }) 52 | this._pendingCount++ 53 | 54 | if (this._idle) { 55 | // Start processing 56 | console.log('Idle, start', id) 57 | this._next() 58 | } 59 | } 60 | 61 | // Remove task by id 62 | remove (id) { 63 | const index = this._queue.findIndex(task => task.id === id) 64 | if (index < 0) return false 65 | return this._remove(index) 66 | } 67 | 68 | // Processes next task in queue 69 | async _next () { 70 | if (!this._pendingCount) return (this._idle = true) // Finished processing 71 | console.log(this._queue, this._processing, this._pendingCount) 72 | this._idle = false 73 | this._pendingCount-- 74 | if (this._processing > 0) { 75 | const { removeWhenDone } = this._queue[this._processing] 76 | if (removeWhenDone) this._remove(this._processing) 77 | } 78 | // Run task 79 | this._queue[++this._processing].run() 80 | } 81 | 82 | // Removes task at given position in queue 83 | _remove (index) { 84 | console.log('Removed task at index', index) 85 | // clearTimeout(this._queue[index].timer) 86 | return delete this._queue[index] 87 | } 88 | 89 | // When a task fails 90 | _error (id, error) { 91 | this.emit('error', id, error) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /static/scss/components/Message.scss: -------------------------------------------------------------------------------- 1 | @import '../themes'; 2 | 3 | @include themify() { 4 | .message { 5 | display: flex; 6 | flex-direction: column; 7 | animation: showMessage 0.3s forwards; 8 | } 9 | 10 | @keyframes showMessage { 11 | from { 12 | transform: translateY(10%); 13 | opacity: 0.5; 14 | } 15 | to { 16 | transform: none; 17 | opacity: 1; 18 | } 19 | } 20 | 21 | .message .timestamp { 22 | display: flex; 23 | justify-content: center; 24 | color: themed('secondaryForegroundColor'); 25 | font-weight: 600; 26 | font-size: 12px; 27 | margin: 10px 0px; 28 | text-transform: uppercase; 29 | } 30 | 31 | .message .bubble-container { 32 | font-size: 14px; 33 | display: flex; 34 | word-wrap: break-word; 35 | } 36 | 37 | .message.mine .bubble-container { 38 | justify-content: flex-end; 39 | } 40 | 41 | .message.start .bubble-container .bubble { 42 | /* margin-top: 10px; */ 43 | border-top-left-radius: 20px; 44 | } 45 | 46 | .message.end .bubble-container .bubble { 47 | border-bottom-left-radius: 20px; 48 | /* margin-bottom: 10px; */ 49 | } 50 | 51 | .message.mine.start .bubble-container .bubble { 52 | margin-top: 10px; 53 | border-top-right-radius: 20px; 54 | } 55 | 56 | .message.mine.end .bubble-container .bubble { 57 | border-bottom-right-radius: 20px; 58 | margin-bottom: 10px; 59 | } 60 | 61 | .message .bubble-container .bubble { 62 | margin: 1px 0px; 63 | background: themed('backgroundColor'); 64 | padding: 10px 15px; 65 | border-radius: 20px; 66 | max-width: 75%; 67 | border-top-left-radius: 2px; 68 | border-bottom-left-radius: 2px; 69 | border-top-right-radius: 20px; 70 | border-bottom-right-radius: 20px; 71 | } 72 | 73 | .message .bubble-container .bubble.file { 74 | background: none !important; 75 | border: 2px solid themed('backgroundColor'); 76 | color: themed('primaryForegroundColor') !important; 77 | padding: 20px; 78 | text-align: center; 79 | width: 130px; 80 | white-space: nowrap; 81 | overflow: hidden; 82 | text-overflow: ellipsis; 83 | cursor: pointer; 84 | } 85 | 86 | .message .bubble-container .bubble.file > i { 87 | font-size: 35px; 88 | } 89 | 90 | .message .bubble-container img.bubble { 91 | padding: 0px !important; 92 | object-fit: contain; 93 | } 94 | 95 | .message.mine .bubble-container .bubble { 96 | background: themed('primaryColor'); 97 | color: themed('primaryColorForegroundColor'); 98 | border-top-left-radius: 20px; 99 | border-bottom-left-radius: 20px; 100 | border-top-right-radius: 2px; 101 | border-bottom-right-radius: 2px; 102 | user-select: text; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/main/lib/Chats.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /** 3 | * Chats class 4 | * Manages chats 5 | */ 6 | 7 | const CHATS_DB_KEY = 'chats' 8 | 9 | module.exports = class Chats { 10 | constructor (store) { 11 | // Ensure singleton 12 | if (!!Chats.instance) { 13 | return Chats.instance 14 | } 15 | 16 | this._store = store 17 | this._chats = {} 18 | 19 | Chats.instance = this 20 | } 21 | 22 | // Loads all saved chats from the store 23 | async init () { 24 | try { 25 | this._chats = await this._store.get(CHATS_DB_KEY) 26 | console.log('Loaded chats') 27 | console.log(this._chats) 28 | return true 29 | } catch (err) { 30 | // No self keys exist 31 | if (err.notFound) return false 32 | throw err 33 | } 34 | } 35 | 36 | // Gets the id of the most recent chat or false if none 37 | getLatestId () { 38 | const chats = Object.values(this._chats) 39 | if (!chats.length) return false 40 | const mostRecentChat = chats.reduce((prevChat, chat) => { 41 | // Ensure it has messages 42 | if (!chat.messages.length) return prevChat 43 | // Parse most recent message times as Unix timestamp 44 | let prevChatTime = new Date(prevChat.messages[0].timestamp).getTime() 45 | let chatTime = new Date(chat.messages[0].timestamp).getTime() 46 | // Select most recent 47 | return prevChatTime > chatTime ? prevChat : chat 48 | }) 49 | return mostRecentChat && mostRecentChat.id 50 | } 51 | 52 | // Gets the chats 53 | getAll () { 54 | return this._chats 55 | } 56 | 57 | // Checks if any chats exist 58 | exist () { 59 | return Object.keys(this._chats).length > 0 60 | } 61 | 62 | // Checks if a chat exists 63 | has (id) { 64 | return !!this._chats[id] 65 | } 66 | 67 | // Adds a chat 68 | async add (id, publicKeyArmored, address) { 69 | this._chats[id] = { 70 | id, 71 | publicKeyArmored, 72 | ...address, 73 | messages: [] 74 | } 75 | await this._saveAll() 76 | } 77 | 78 | // Deletes a chat 79 | async delete (id) { 80 | delete this._chats[id] 81 | await this._saveAll() 82 | console.log('Deleted chat', id) 83 | } 84 | 85 | // Adds a message 86 | addMessage (id, message) { 87 | this._chats[id].messages.push(message) 88 | this._saveAll() 89 | } 90 | 91 | // Set a chat as online 92 | setOnline (id) { 93 | return (this._chats[id].online = true) 94 | } 95 | 96 | // Set a chat as offline 97 | setOffline (id) { 98 | return this.has(id) && (this._chats[id].online = false) 99 | } 100 | 101 | // Deletes all the chat messages 102 | async deleteAllMessages () { 103 | for (const chat in this._chats) { 104 | if (!this._chats.hasOwnProperty(chat)) continue 105 | this._chats[chat].messages = [] 106 | } 107 | await this._saveAll() 108 | } 109 | 110 | // Saves chats to the store 111 | async _saveAll () { 112 | await this._store.put(CHATS_DB_KEY, this._chats) 113 | console.log('Saved chats') 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/main/menu.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const path = require('path') 3 | const { app, Menu, shell } = require('electron') 4 | const { 5 | is, 6 | appMenu, 7 | aboutMenuItem, 8 | openUrlMenuItem, 9 | openNewGitHubIssue, 10 | debugInfo 11 | } = require('electron-util') 12 | 13 | const showPreferences = () => { 14 | // Show the app's preferences here 15 | } 16 | 17 | const helpSubmenu = [ 18 | openUrlMenuItem({ 19 | label: 'Website', 20 | url: 'https://github.com/hr/ciphora' 21 | }), 22 | openUrlMenuItem({ 23 | label: 'Source Code', 24 | url: 'https://github.com/hr/ciphora' 25 | }), 26 | { 27 | label: 'Report an Issue…', 28 | click () { 29 | const body = ` 30 | 31 | 32 | 33 | --- 34 | 35 | ${debugInfo()}` 36 | 37 | openNewGitHubIssue({ 38 | user: 'hr', 39 | repo: 'ciphora', 40 | body 41 | }) 42 | } 43 | } 44 | ] 45 | 46 | if (!is.macos) { 47 | helpSubmenu.push( 48 | { 49 | type: 'separator' 50 | }, 51 | aboutMenuItem({ 52 | icon: path.join(__dirname, 'build', 'icon.png'), 53 | text: 'Created by Habib Rehman' 54 | }) 55 | ) 56 | } 57 | 58 | const debugSubmenu = [ 59 | { 60 | label: 'Show App Data', 61 | click () { 62 | shell.openItem(app.getPath('userData')) 63 | } 64 | }, 65 | { 66 | type: 'separator' 67 | }, 68 | { 69 | label: 'Delete Chat Messages', 70 | click () { 71 | app.emit('delete-messages') 72 | } 73 | }, 74 | { 75 | label: 'Delete App Data', 76 | click () { 77 | shell.moveItemToTrash(app.getPath('userData')) 78 | app.relaunch() 79 | app.quit() 80 | } 81 | } 82 | ] 83 | 84 | const macosTemplate = [ 85 | appMenu([ 86 | { 87 | label: 'Preferences…', 88 | accelerator: 'Command+,', 89 | click () { 90 | showPreferences() 91 | } 92 | } 93 | ]), 94 | { 95 | role: 'fileMenu', 96 | submenu: [ 97 | { 98 | label: 'New Message', 99 | click () {} 100 | }, 101 | { 102 | type: 'separator' 103 | }, 104 | { 105 | role: 'close' 106 | } 107 | ] 108 | }, 109 | { 110 | role: 'editMenu' 111 | }, 112 | { 113 | role: 'viewMenu' 114 | }, 115 | { 116 | role: 'windowMenu' 117 | }, 118 | { 119 | role: 'help', 120 | submenu: helpSubmenu 121 | } 122 | ] 123 | 124 | // Linux and Windows 125 | const otherTemplate = [ 126 | { 127 | role: 'fileMenu', 128 | submenu: [ 129 | { 130 | label: 'Custom' 131 | }, 132 | { 133 | type: 'separator' 134 | }, 135 | { 136 | label: 'Settings', 137 | accelerator: 'Control+,', 138 | click () { 139 | showPreferences() 140 | } 141 | }, 142 | { 143 | type: 'separator' 144 | }, 145 | { 146 | role: 'quit' 147 | } 148 | ] 149 | }, 150 | { 151 | role: 'editMenu' 152 | }, 153 | { 154 | role: 'viewMenu' 155 | }, 156 | { 157 | role: 'help', 158 | submenu: helpSubmenu 159 | } 160 | ] 161 | 162 | const template = process.platform === 'darwin' ? macosTemplate : otherTemplate 163 | 164 | if (is.development) { 165 | template.push({ 166 | label: 'Debug', 167 | submenu: debugSubmenu 168 | }) 169 | } 170 | 171 | module.exports = Menu.buildFromTemplate(template) 172 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 |

2 |
3 | Ciphora 4 |
5 | Ciphora 6 |
7 |
8 |

9 | 10 |

A decentralized end-to-end encrypted messaging app.

11 |

12 | 13 | Download latest release 15 | 16 |

17 | 18 | A peer-to-peer end-to-end encrypted messaging app. Implements the secure [signal 19 | protocol](https://signal.org/docs/specifications/doubleratchet/) for the 20 | end-to-end encryption of messages and 21 | [PGP](https://en.wikipedia.org/wiki/Pretty_Good_Privacy) for identity 22 | verification and authentication. This approach not only protects against 23 | [man-in-the-middle 24 | attacks](https://en.wikipedia.org/wiki/Man-in-the-middle_attack) but removes the 25 | need for in-person verification like with other E2E encryption apps (WhatsApp, 26 | Signal,...) where identity keypairs are generated on a per-device basis and each 27 | has to be verified manually in-person. 28 | 29 | Learn more at https://habibrehman.com/work/ciphora 30 | 31 |
32 |

33 | 34 | 35 | 36 |

37 |

38 | 39 | 40 | 41 |

42 | 43 | ## Features 44 | - [x] End-to-end encrypted messaging 45 | - [x] Peer-to-peer messaging 46 | - [x] Sending images 47 | - [x] Sending files 48 | - [x] Dark Mode 49 | - [ ] Offline messaging 50 | - [ ] Local encryption 51 | 52 | You are welcome to open pull requests to help implement the features still to 53 | do! 54 | 55 | ## Install 56 | 57 | _macOS 10.10+, Linux, and Windows 7+ are supported (64-bit only)._ 58 | 59 | **macOS** 60 | 61 | [**Download**](https://github.com/hr/ciphora/releases/latest) the `.dmg` file. 62 | 63 | **Linux** 64 | 65 | [**Download**](https://github.com/hr/ciphora/releases/latest) the `.AppImage` or `.deb` file. 66 | 67 | _The AppImage needs to be [made executable](http://discourse.appimage.org/t/how-to-make-an-appimage-executable/80) after download._ 68 | 69 | **Windows** 70 | 71 | [**Download**](https://github.com/hr/ciphora/releases/latest) the `.exe` file. 72 | 73 | 74 | ## Dev 75 | 76 | Needs the Ciphora Server as well (https://github.com/HR/ciphora-server/) 77 | 78 | ### Setup 79 | 80 | Clone the repos 81 | 82 | ``` 83 | $ git clone --recurse-submodules https://github.com/HR/ciphora.git 84 | $ git clone https://github.com/HR/ciphora-server.git 85 | ``` 86 | 87 | Install deps for both repos 88 | 89 | ``` 90 | $ yarn 91 | ``` 92 | 93 | ### Run 94 | 95 | For faster dev, run the bundler (webpack) 96 | 97 | ``` 98 | $ yarn run bundler 99 | ``` 100 | 101 | In a new tty, run the app 102 | 103 | ``` 104 | $ gulp 105 | ``` 106 | 107 | To test the app locally with another app, just run a second instance in a new 108 | tty 109 | 110 | ``` 111 | $ gulp 112 | ``` 113 | 114 | N.B. on macOS, you may be prompted to allow incoming connections everytime you 115 | run it. Unfortunately the only way to make that go away currently is to disable 116 | your firewall temporarily. 117 | 118 | ### Publish 119 | 120 | ``` 121 | $ npm run release 122 | ``` 123 | 124 | After Travis finishes building your app, open the release draft it created and 125 | click "Publish". 126 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ciphora", 3 | "productName": "Ciphora", 4 | "version": "0.0.1", 5 | "private": true, 6 | "description": "A decentralized end-to-end encrypted messaging app", 7 | "license": "MIT", 8 | "repository": "HR/ciphora", 9 | "homepage": "https://github.com/HR/ciphora", 10 | "author": { 11 | "name": "Habib Rehman", 12 | "url": "https://github.com/HR" 13 | }, 14 | "main": "./src/main/index.js", 15 | "scripts": { 16 | "postinstall": "electron-builder install-app-deps", 17 | "rebuild": "electron-rebuild -f .", 18 | "test": "npm run lint", 19 | "start": "npm run bundler & (sleep .5 && gulp)", 20 | "bundler": "BROWSER=none webpack serve --mode development", 21 | "bundle": "webpack --mode production", 22 | "electron": "electron .", 23 | "pack": "electron-builder --dir", 24 | "build": "electron-builder --macos --linux --windows", 25 | "build:mac": "electron-builder -m", 26 | "build:lin": "electron-builder -l --x64 --ia32", 27 | "build:win": "electron-builder -w --x64 --ia32", 28 | "release": "np" 29 | }, 30 | "dependencies": { 31 | "brake": "^1.0.1", 32 | "electron-context-menu": "^3.1.1", 33 | "electron-debug": "^3.2.0", 34 | "electron-unhandled": "^3.0.2", 35 | "electron-util": "^0.16.0", 36 | "encoding-down": "^7.0.0", 37 | "futoin-hkdf": "^1.3.3", 38 | "keytar": "^7.7.0", 39 | "leveldown": "^6.0.0", 40 | "levelup": "^5.0.1", 41 | "moment": "^2.29.1", 42 | "openpgp": "^4.10.10", 43 | "react": "^17.0.2", 44 | "react-dom": "^17.0.2", 45 | "tweetnacl": "^1.0.3", 46 | "update-electron-app": "^2.0.1", 47 | "wrtc": "^0.4.7", 48 | "ws": "^7.5.0" 49 | }, 50 | "devDependencies": { 51 | "@babel/core": "^7.14.6", 52 | "@babel/plugin-proposal-class-properties": "^7.14.5", 53 | "@babel/plugin-transform-runtime": "^7.14.5", 54 | "@babel/preset-env": "^7.14.5", 55 | "@babel/preset-react": "^7.14.5", 56 | "@babel/runtime": "^7.14.6", 57 | "babel-loader": "^8.2.2", 58 | "css-loader": "^5.2.6", 59 | "electron": "^13.1.3", 60 | "electron-builder": "^22.11.7", 61 | "electron-rebuild": "^2.3.5", 62 | "file-loader": "^6.2.0", 63 | "glob": "^7.1.7", 64 | "gulp": "^4.0.2", 65 | "gulp-watch": "^5.0.1", 66 | "html-loader": "^2.1.2", 67 | "html-webpack-plugin": "^5.3.1", 68 | "mini-css-extract-plugin": "^1.6.0", 69 | "node-sass": "^6.0.0", 70 | "np": "^7.5.0", 71 | "sass-loader": "^12.1.0", 72 | "style-loader": "^2.0.0", 73 | "webpack": "^5.40.0", 74 | "webpack-cli": "^4.7.2", 75 | "webpack-dev-server": "^3.11.2" 76 | }, 77 | "eslintConfig": { 78 | "extends": "react-app" 79 | }, 80 | "xo": { 81 | "envs": [ 82 | "node", 83 | "browser" 84 | ] 85 | }, 86 | "np": { 87 | "publish": false, 88 | "releaseDraft": false 89 | }, 90 | "build": { 91 | "appId": "com.hr.ciphora", 92 | "mac": { 93 | "category": "public.app-category.social-networking", 94 | "darkModeSupport": true 95 | }, 96 | "dmg": { 97 | "iconSize": 160, 98 | "contents": [ 99 | { 100 | "x": 180, 101 | "y": 170 102 | }, 103 | { 104 | "x": 480, 105 | "y": 170, 106 | "type": "link", 107 | "path": "/Applications" 108 | } 109 | ] 110 | }, 111 | "linux": { 112 | "target": [ 113 | "AppImage", 114 | "deb" 115 | ], 116 | "category": "Network;Chat" 117 | } 118 | }, 119 | "browserslist": { 120 | "production": [ 121 | ">0.2%", 122 | "not dead", 123 | "not op_mini all" 124 | ], 125 | "development": [ 126 | "last 1 chrome version", 127 | "last 1 firefox version", 128 | "last 1 safari version" 129 | ] 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /static/scss/components/Modal.scss: -------------------------------------------------------------------------------- 1 | @import '../themes'; 2 | 3 | @include themify() { 4 | $select-arrow: 'data:image/svg+xml,%3Csvg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 512 512" xml:space="preserve"%3E%3Cpath fill="%23' + 5 | str-slice(to-string(themed('primaryForegroundColor')), 2, 4) + 6 | '" d="M256,294.1L383,167c9.4-9.4,24.6-9.4,33.9,0s9.3,24.6,0,34L273,345c-9.1,9.1-23.7,9.3-33.1,0.7L95,201.1 c-4.7-4.7-7-10.9-7-17c0-6.1,2.3-12.3,7-17c9.4-9.4,24.6-9.4,33.9,0L256,294.1z"/%3E%3C/svg%3E%0A'; 7 | $modal-margin: 1.4rem; 8 | 9 | .modal-cover { 10 | position: fixed; 11 | display: block; 12 | top: 0; 13 | bottom: 0; 14 | left: 0; 15 | right: 0; 16 | background: themed('modalOverlayColor'); 17 | width: 100%; 18 | height: 100%; 19 | z-index: 50; 20 | visibility: collapse; 21 | transition: visibility 0.3s, opacity 0.3s; 22 | } 23 | 24 | .modal:not(.modal-active) .modal-cover { 25 | opacity: 0; 26 | } 27 | 28 | .modal-active .modal-cover { 29 | z-index: 1024; 30 | visibility: visible; 31 | } 32 | 33 | .modal-outer-container { 34 | pointer-events: none; 35 | position: fixed; 36 | top: 0; 37 | bottom: 0; 38 | left: 0; 39 | right: 0; 40 | width: 100%; 41 | height: 100%; 42 | z-index: 1050; 43 | display: flex; 44 | flex-direction: row; 45 | align-items: center; 46 | justify-content: center; 47 | visibility: collapse; 48 | transition: visibility 0.3s; 49 | } 50 | 51 | .modal-active .modal-outer-container { 52 | visibility: visible; 53 | } 54 | 55 | .modal-inner-container { 56 | padding: 0; 57 | pointer-events: all; 58 | position: relative; 59 | border-radius: 8px; 60 | overflow: hidden; 61 | background: themed('modalBackgroundColor'); 62 | backdrop-filter: blur(20px); 63 | width: 550px; 64 | min-height: 350px; 65 | box-shadow: 0 0 60px 5px themed('modalShadowColor'); 66 | text-align: center; 67 | transition: opacity 0.3s, transform 0.3s; 68 | } 69 | 70 | .modal:not(.modal-active) .modal-inner-container { 71 | transform: translateY(50%); 72 | opacity: 0; 73 | } 74 | 75 | .modal-header { 76 | padding: $modal-margin; 77 | border-bottom: 1px solid themed('highlightBorderColor'); 78 | -webkit-app-region: drag; 79 | } 80 | 81 | .modal-header h1 { 82 | font-size: 1.8rem; 83 | margin-top: 6px; 84 | margin-bottom: 17px; 85 | } 86 | 87 | .modal-header p { 88 | font-size: 1rem; 89 | margin: 0; 90 | } 91 | 92 | .modal-header i { 93 | font-size: 80px; 94 | } 95 | 96 | .modal-body { 97 | margin: $modal-margin; 98 | text-align: left; 99 | display: flex; 100 | flex-direction: column; 101 | align-items: center; 102 | } 103 | 104 | .modal-body button { 105 | flex: 0; 106 | } 107 | 108 | .modal-body hr.divider { 109 | overflow: visible; 110 | padding: 0; 111 | margin-top: 30px; 112 | margin-bottom: 4px; 113 | border: none; 114 | border-top: 1px solid themed('backgroundColor'); 115 | width: 200px; 116 | } 117 | 118 | .modal-body hr.divider:after { 119 | content: 'or'; 120 | border-radius: 100rem; 121 | display: inline-block; 122 | position: relative; 123 | top: -15px; 124 | padding: 5px 10px; 125 | background: themed('backgroundColor'); 126 | } 127 | 128 | .modal-inner-container .cancel { 129 | position: absolute; 130 | right: 20px; 131 | top: 5px; 132 | font-size: 2rem; 133 | padding: 4px; 134 | } 135 | 136 | .modal-body textarea, 137 | .modal-body input, 138 | .modal-body select { 139 | align-self: stretch; 140 | font-size: 0.9rem; 141 | background: themed('backgroundColor'); 142 | color: themed('primaryForegroundColor'); 143 | border: none; 144 | padding: 15px; 145 | margin-bottom: 10px; 146 | border-radius: 0.2em; 147 | } 148 | 149 | .modal-body select { 150 | background-image: url($select-arrow); 151 | background-repeat: no-repeat, repeat; 152 | background-position: right 0.7em top 50%, 0 0; 153 | background-size: 1rem auto, 100%; 154 | } 155 | 156 | .modal-body textarea::placeholder, 157 | .modal-body input::placeholder { 158 | color: themed('primaryForegroundColor'); 159 | } 160 | 161 | .modal-body textarea { 162 | font-family: monospace; 163 | resize: none; 164 | } 165 | 166 | .modal-inner-container .error-message { 167 | color: themed('dangerColor'); 168 | margin: 0; 169 | margin-top: $modal-margin * 0.3; 170 | } 171 | 172 | .modal-inner-container .message { 173 | color: themed('primaryColor'); 174 | margin: 0; 175 | margin-bottom: 10px; 176 | } 177 | 178 | .modal-action { 179 | margin: $modal-margin; 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /src/renderer/components/MessageList.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState, useRef } from 'react' 2 | import Compose from './Compose' 3 | import Toolbar, { ToolbarButton } from './Toolbar' 4 | import { 5 | ToolbarDropdownButton, 6 | ToolbarDropdownItem, 7 | ToolbarDropdownUserInfo 8 | } from './ToolbarDropdown' 9 | import Message from './Message' 10 | import { classList } from '../lib/util' 11 | import { CONTENT_TYPES } from '../../consts' 12 | import moment from 'moment' 13 | 14 | export default function MessageList (props) { 15 | const [message, setMessage] = useState('') 16 | const [id, setId] = useState('') 17 | 18 | const messagesEndRef = useRef(null) 19 | 20 | // Scrolls to the bottom 21 | function scrollToBottom () { 22 | messagesEndRef.current.scrollIntoView({ block: 'end', behavior: 'smooth' }) 23 | } 24 | 25 | // Scroll to the bottom everytime a new message is sent/received to show it 26 | useEffect(scrollToBottom, [props.chat]) 27 | 28 | // Invokes the passed function (fn) when enter press detected 29 | function onEnterPress (fn) { 30 | return event => { 31 | if (event.key === 'Enter') { 32 | fn() 33 | } 34 | } 35 | } 36 | 37 | // Dynamically generates the message list for the UI from the actual list 38 | function renderMessages () { 39 | const { messages, id } = props.chat 40 | 41 | let messageList = [] 42 | 43 | for (let i = 0; i < messages.length; i++) { 44 | let previous = messages[i - 1] 45 | let current = messages[i] 46 | let next = messages[i + 1] 47 | let isMine = current.sender !== id 48 | let currentMoment = moment(current.timestamp) 49 | let prevBySameSender = false 50 | let nextBySameSender = false 51 | let startsSequence = true 52 | let endsSequence = true 53 | let showTimestamp = true 54 | 55 | if (previous) { 56 | let previousMoment = moment(previous.timestamp) 57 | let previousDuration = moment.duration( 58 | currentMoment.diff(previousMoment) 59 | ) 60 | prevBySameSender = previous.sender === current.sender 61 | 62 | if (prevBySameSender && previousDuration.as('hours') < 1) { 63 | startsSequence = false 64 | } 65 | 66 | if (previousDuration.as('hours') < 1) { 67 | showTimestamp = false 68 | } 69 | } 70 | 71 | if (next) { 72 | let nextMoment = moment(next.timestamp) 73 | let nextDuration = moment.duration(nextMoment.diff(currentMoment)) 74 | nextBySameSender = next.sender === current.sender 75 | 76 | if (nextBySameSender && nextDuration.as('hours') < 1) { 77 | endsSequence = false 78 | } 79 | } 80 | 81 | messageList.push( 82 | 93 | ) 94 | } 95 | 96 | return messageList 97 | } 98 | 99 | let toolbar = ( 100 | 108 | 112 | 113 | Copy User ID 114 | 115 | 116 | Copy PGP Key 117 | 118 | 119 | Delete Chat 120 | 121 | 122 | ) 123 | } 124 | /> 125 | ) 126 | 127 | let placeholder = props.chat 128 | ? 'Type a message' 129 | : 'Compose a new chat to start messaging' 130 | 131 | if (props.composing) { 132 | toolbar = ( 133 | 136 | To: 137 |
138 | setId(event.target.value)} 145 | onKeyDown={onEnterPress(() => props.onComposeChat(id))} 146 | onPaste={event => { 147 | props.onComposeChat(event.clipboardData.getData('text/plain')) 148 | }} 149 | /> 150 |
151 |
152 | } 153 | /> 154 | ) 155 | 156 | placeholder = 'Add the recipient to start messaging' 157 | } 158 | 159 | return ( 160 |
166 | {toolbar} 167 | 168 |
169 | {!props.composing && props.chat && renderMessages()} 170 |
171 | 172 | setMessage(event.target.value)} 177 | onKeyDown={onEnterPress(() => { 178 | // Send up 179 | props.onComposeMessage(message) 180 | // Clear message input 181 | setMessage('') 182 | })} 183 | rightitems={[ 184 | props.onSendFileClick(CONTENT_TYPES.IMAGE)} 186 | key='image' 187 | icon='ion-ios-image' 188 | />, 189 | props.onSendFileClick(CONTENT_TYPES.FILE)} 191 | key='file' 192 | icon='ion-ios-document' 193 | /> 194 | ]} 195 | /> 196 |
197 |
198 | ) 199 | } 200 | -------------------------------------------------------------------------------- /src/main/lib/Peers.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /** 3 | * Peers class 4 | * Manages peer connections and communication 5 | */ 6 | 7 | const stream = require('stream'), 8 | EventEmitter = require('events'), 9 | path = require('path'), 10 | util = require('util'), 11 | fs = require('fs'), 12 | brake = require('brake'), 13 | wrtc = require('wrtc'), 14 | moment = require('moment'), 15 | Peer = require('./simple-peer'), 16 | Queue = require('./Queue'), 17 | { CONTENT_TYPES } = require('../../consts'), 18 | { MEDIA_DIR } = require('../../config'), 19 | // http://viblast.com/blog/2015/2/5/webrtc-data-channel-message-size/ 20 | MESSAGE_CHUNK_SIZE = 16 * 1024, // (16kb) 21 | MESSAGE_STREAM_RATE = 50 // ms 22 | const { mkdir } = fs.promises 23 | const pipeline = util.promisify(stream.pipeline) 24 | 25 | module.exports = class Peers extends EventEmitter { 26 | constructor (signal, crypto, chats) { 27 | // Ensure singleton 28 | if (!!Peers.instance) { 29 | return Peers.instance 30 | } 31 | 32 | // Call EventEmitter constructor 33 | super() 34 | 35 | this._peers = {} 36 | this._requests = {} 37 | this._signal = signal 38 | this._crypto = crypto 39 | this._chats = chats 40 | this._sendingQueue = new Queue() 41 | this._receivingQueue = new Queue() 42 | 43 | // Bindings 44 | this._addPeer = this._addPeer.bind(this) 45 | this._onSignalRequest = this._onSignalRequest.bind(this) 46 | this._onSignalAccept = this._onSignalAccept.bind(this) 47 | this._onSignal = this._onSignal.bind(this) 48 | this._onSignalReceiverOffline = this._onSignalReceiverOffline.bind(this) 49 | 50 | // Add queue event listeners 51 | this._sendingQueue.on('error', (...args) => 52 | this.emit('send-error', ...args) 53 | ) 54 | this._receivingQueue.on('error', (...args) => 55 | this.emit('receive-error', ...args) 56 | ) 57 | 58 | // Add signal event listeners 59 | this._signal.on('signal-request', this._onSignalRequest) 60 | this._signal.on('signal-accept', this._onSignalAccept) 61 | this._signal.on('signal', this._onSignal) 62 | this._signal.on('unknown-receiver', this._onSignalReceiverOffline) 63 | 64 | Peers.instance = this 65 | } 66 | 67 | // Connects to given peer 68 | connect (userId) { 69 | // Start connection 70 | const signalRequest = (this._requests[userId] = { 71 | receiverId: userId, 72 | timestamp: new Date().toISOString() 73 | }) 74 | // Send a signal request to peer 75 | this._signal.send('signal-request', signalRequest) 76 | console.log('Connecting with', userId) 77 | } 78 | 79 | // Disconnects from given peer 80 | disconnect (userId) { 81 | this._removePeer(userId) 82 | console.log('Disconnected from', userId) 83 | } 84 | 85 | // Checks if given peer has been added 86 | has (id) { 87 | return this._peers.hasOwnProperty(id) 88 | } 89 | 90 | // Checks if given peer is connected 91 | isConnected (id) { 92 | return this._peers[id] && this._peers[id]._isConnected 93 | } 94 | 95 | // Queues a chat message to be sent to given peer 96 | send (userId, ...args) { 97 | this._sendingQueue.add(() => this._send('message', ...args), userId) 98 | } 99 | 100 | // Handles signal requests 101 | _onSignalRequest ({ senderId, timestamp }) { 102 | console.log('Signal request received') 103 | // Ensure chat exists with signal sender 104 | if (!this._chats.has(senderId)) return console.log('Rejected as no chat') 105 | 106 | const request = this._requests[senderId] 107 | // If a request to the sender has not already been sent then just accept it 108 | // Add receiver to receive signal 109 | if (!request) { 110 | this._addReceiver(senderId) 111 | this._signal.send('signal-accept', { receiverId: senderId }) 112 | console.log('Signal request not sent to sender so accepted') 113 | return 114 | } 115 | 116 | // Parse request times 117 | const requestTime = moment(request.timestamp) 118 | const receivedRequestTime = moment(timestamp) 119 | 120 | // If received request was sent before own request then accept it 121 | // Add receiver to receive signal and forget own request 122 | // Avoids race condition when both peers send signal-requests 123 | if (receivedRequestTime.isBefore(requestTime)) { 124 | this._addReceiver(senderId) 125 | this._signal.send('signal-accept', { receiverId: senderId }) 126 | delete this._requests[senderId] 127 | console.log('Signal request sent before own so accepted') 128 | } 129 | 130 | // Otherwise don't do anything (wait for signal-accept as the sender) 131 | } 132 | 133 | // Handles accepted signal requests 134 | _onSignalAccept ({ senderId }) { 135 | console.log('Signal request accepted') 136 | // Start signalling 137 | this._addSender(senderId) 138 | delete this._requests[senderId] 139 | } 140 | 141 | // Handles new signals 142 | _onSignal ({ senderId, data }) { 143 | // Ensure peer to signal exists 144 | if (!this._peers[senderId]) { 145 | throw new Error(`Peer ${senderId} not yet added`) 146 | } 147 | 148 | this._peers[senderId].signal(data) 149 | } 150 | 151 | // Handles offline receivers 152 | _onSignalReceiverOffline ({ receiverId }) { 153 | if (this._requests[receiverId]) { 154 | console.log('Signal receiver offline') 155 | // Delete request to allow offline peer to connect if it comes online 156 | delete this._requests[receiverId] 157 | } 158 | } 159 | 160 | // Removes given peer by id 161 | _removePeer (id) { 162 | if (this._peers[id]) { 163 | this._peers[id].destroy() 164 | delete this._peers[id] 165 | } 166 | } 167 | 168 | // Adds sender to initiate a connection with receiving peer 169 | _addSender (...args) { 170 | this._addPeer(true, ...args) 171 | } 172 | 173 | // Adds a receiver to Initiate a connection with sending peer 174 | _addReceiver (...args) { 175 | this._addPeer(false, ...args) 176 | } 177 | 178 | // Initiates a connection with the given peer and sets up communication 179 | _addPeer (initiator, userId) { 180 | const peer = (this._peers[userId] = new Peer({ 181 | initiator, 182 | wrtc: wrtc, 183 | reconnectTimer: 1000 184 | })) 185 | const type = initiator ? 'Sender' : 'Receiver' 186 | peer._isConnected = false 187 | 188 | peer.on('signal', data => { 189 | // Trickle signal data to the peer 190 | this._signal.send('signal', { 191 | receiverId: userId, 192 | data 193 | }) 194 | console.log(type, 'got signal and sent') 195 | }) 196 | 197 | peer.on('connect', async () => { 198 | peer._isConnected = true 199 | // Initialises a chat session 200 | const keyMessage = await this._crypto.initSession(userId) 201 | // Send the master secret public key with signature to the user 202 | this._send('key', userId, keyMessage, false) 203 | 204 | this.emit('connect', userId, initiator) 205 | }) 206 | 207 | peer.on('close', () => { 208 | peer._isConnected = false 209 | this.emit('disconnect', userId) 210 | }) 211 | 212 | peer.on('error', err => this.emit('error', userId, err)) 213 | 214 | peer.on('data', data => 215 | // Queue to receive 216 | this._receivingQueue.add(() => 217 | this._onMessage(userId, data.toString('utf8')) 218 | ) 219 | ) 220 | 221 | peer.on('datachannel', (datachannel, id) => 222 | // Queue to receive 223 | this._receivingQueue.add(() => 224 | this._onDataChannel(userId, datachannel, id) 225 | ) 226 | ) 227 | } 228 | 229 | // Handles new messages 230 | async _onMessage (userId, data) { 231 | // Try to deserialize message 232 | console.log('------> Got new message', data) 233 | const { type, ...message } = JSON.parse(data) 234 | 235 | if (type === 'key') { 236 | // Start a new crypto session with received key 237 | this._crypto.startSession(userId, message) 238 | return 239 | } 240 | 241 | if (message.contentType === CONTENT_TYPES.TEXT) { 242 | // Decrypt received text message 243 | const { decryptedMessage } = await this._crypto.decrypt(userId, message) 244 | // Ignore if validation failed 245 | if (!decryptedMessage) return 246 | this.emit(type, userId, decryptedMessage) 247 | return 248 | } 249 | } 250 | 251 | // Handles new data channels (file streams) 252 | async _onDataChannel (userId, receivingStream, id) { 253 | console.log('------> Got new datachannel', id) 254 | const { type, ...message } = JSON.parse(id) 255 | let { decryptedMessage, contentDecipher } = await this._crypto.decrypt( 256 | userId, 257 | message, 258 | true 259 | ) 260 | // Ignore if validation failed 261 | if (!decryptedMessage) return 262 | const mediaDir = path.join(MEDIA_DIR, userId, message.contentType) 263 | // Recursively make media directory 264 | await mkdir(mediaDir, { recursive: true }) 265 | const contentPath = path.join(mediaDir, decryptedMessage.content) 266 | console.log('Writing to', contentPath) 267 | const contentWriteStream = fs.createWriteStream(contentPath) 268 | // Stream content 269 | await pipeline(receivingStream, contentDecipher, contentWriteStream) 270 | decryptedMessage.content = contentPath 271 | this.emit(type, userId, decryptedMessage) 272 | } 273 | 274 | // Sends a message to given peer 275 | async _send (type, receiverId, message, encrypt, contentPath) { 276 | // TODO: Queue message if not connected / no session for later 277 | if (!this.isConnected(receiverId)) return false 278 | 279 | const peer = this._peers[receiverId] 280 | 281 | if (encrypt) { 282 | // Encrypt message 283 | var { encryptedMessage, contentCipher } = await this._crypto.encrypt( 284 | receiverId, 285 | message, 286 | contentPath 287 | ) 288 | message = encryptedMessage 289 | } 290 | 291 | const serializedMessage = JSON.stringify({ 292 | type, 293 | ...message 294 | }) 295 | 296 | // Simply send message if no file to stream 297 | if (!contentPath) { 298 | peer.write(serializedMessage) 299 | console.log(type, 'sent', message) 300 | return 301 | } 302 | 303 | // Stream file 304 | console.log('Streaming', message, contentPath) 305 | const contentReadStream = fs.createReadStream(contentPath) 306 | const sendingStream = peer.createDataChannel(serializedMessage) 307 | await pipeline( 308 | contentReadStream, 309 | contentCipher, 310 | // Throttle stream (backpressure) 311 | brake(MESSAGE_CHUNK_SIZE, { period: MESSAGE_STREAM_RATE }), 312 | sendingStream 313 | ) 314 | } 315 | } 316 | -------------------------------------------------------------------------------- /src/renderer/components/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Messenger from './Messenger' 3 | import ChatList from './ChatList' 4 | import MessageList from './MessageList' 5 | import SetupIdentityModal from './SetupIdentityModal' 6 | import ImportIdentityModal from './ImportIdentityModal' 7 | import CreateIdentityModal from './CreateIdentityModal' 8 | import { useNotifications } from '../lib/notifications' 9 | import { clone, friendlyError } from '../lib/util' 10 | import { COMPOSE_CHAT_ID, CONTENT_TYPES } from '../../consts' 11 | import { ipcRenderer, remote, shell, clipboard } from 'electron' 12 | import '../../../static/scss/index.scss' 13 | 14 | const { dialog } = remote 15 | // Notification ref 16 | let notifications = null 17 | // Initial modal state used to reset modals 18 | const initModalsState = { 19 | setupIdentity: false, 20 | importIdentity: false, 21 | createIdentity: false, 22 | modalMessage: { 23 | text: '', 24 | error: false 25 | } 26 | } 27 | // Validation regular expressions 28 | const CIPHORA_ID_REGEX = /^[0-9a-fA-F]{40}$/ 29 | const WORDS_REGEX = /\S/ 30 | const PUBLIC_KEY_REGEX = /-----BEGIN PGP PUBLIC KEY BLOCK-----(.|\n|\r|\r\n)+-----END PGP PUBLIC KEY BLOCK-----/ 31 | const PRIVATE_KEY_REGEX = /-----BEGIN PGP PRIVATE KEY BLOCK-----(.|\n|\r|\r\n)+-----END PGP PRIVATE KEY BLOCK-----/m 32 | // Open file dialog filters 33 | let FILTERS = {} 34 | FILTERS[CONTENT_TYPES.IMAGE] = [ 35 | { 36 | name: 'Images', 37 | // Supported by 'img' tag 38 | extensions: ['jpg', 'jpeg', 'svg', 'png', 'apng', 'gif'] 39 | } 40 | ] 41 | FILTERS[CONTENT_TYPES.FILE] = [{ name: 'All Files', extensions: ['*'] }] 42 | // Root component 43 | export default class App extends React.Component { 44 | static contextType = useNotifications(true) 45 | constructor (props) { 46 | super(props) 47 | this.state = { 48 | chats: {}, 49 | profile: {}, 50 | activeChatId: '', 51 | composing: false, 52 | ...clone(initModalsState) 53 | } 54 | 55 | // Bindings 56 | this.closeModals = this.closeModals.bind(this) 57 | this.openModal = this.openModal.bind(this) 58 | this.importIdentityHandler = this.importIdentityHandler.bind(this) 59 | this.createIdentityHandler = this.createIdentityHandler.bind(this) 60 | this.composeChatHandler = this.composeChatHandler.bind(this) 61 | this.deleteChatHandler = this.deleteChatHandler.bind(this) 62 | this.activateChat = this.activateChat.bind(this) 63 | this.composeMessageHandler = this.composeMessageHandler.bind(this) 64 | this.updateState = this.updateState.bind(this) 65 | this.showModalMessage = this.showModalMessage.bind(this) 66 | this.showModalError = this.showModalError.bind(this) 67 | this.createComposeChat = this.createComposeChat.bind(this) 68 | this.deleteComposeChat = this.deleteComposeChat.bind(this) 69 | this.sendFileHandler = this.sendFileHandler.bind(this) 70 | 71 | // Add event listeners 72 | ipcRenderer.on('open-modal', (event, modal) => this.openModal(modal)) 73 | ipcRenderer.on('modal-error', (event, err) => this.showModalError(err)) 74 | ipcRenderer.on('update-state', this.updateState) 75 | } 76 | 77 | componentDidMount () { 78 | // Init notifications via the context 79 | notifications = this.context 80 | // Let main process show notifications 81 | ipcRenderer.on('notify', (event, ...args) => notifications.show(...args)) 82 | // Load state from main if not already loaded 83 | ipcRenderer.send('do-update-state') 84 | } 85 | 86 | // Activates the selected chat 87 | activateChat (chatId) { 88 | // Check if clicked chat already active 89 | if (chatId === this.state.activeChatId) { 90 | return 91 | } 92 | // Remove compose chat when user moves to another chat 93 | if (this.state.activeChatId === COMPOSE_CHAT_ID) { 94 | this.deleteComposeChat() 95 | } 96 | 97 | this.setState({ activeChatId: chatId }) 98 | ipcRenderer.send('activate-chat', chatId) 99 | } 100 | 101 | // Updates internal state thereby updating the UI 102 | updateState (event, state, resetState) { 103 | let newState = { ...state } 104 | if (resetState) { 105 | // Reset state 106 | this.closeModals() 107 | notifications.clear() 108 | newState.composing = false 109 | } 110 | this.setState(newState) 111 | } 112 | 113 | // Closes all the modals 114 | closeModals () { 115 | this.setState({ 116 | ...clone(initModalsState) 117 | }) 118 | } 119 | 120 | // Shows the specified modal 121 | openModal (name) { 122 | let newModalState = clone(initModalsState) 123 | newModalState[name] = true 124 | this.setState(newModalState) 125 | } 126 | 127 | showModalError (text) { 128 | this.showModalMessage(text, true) 129 | } 130 | 131 | showModalMessage (text, error = false) { 132 | this.setState({ 133 | modalMessage: { text, error } 134 | }) 135 | } 136 | 137 | // Handles importing a new PGP key 138 | importIdentityHandler (params) { 139 | const { keys, passphrase } = params 140 | let pub = keys.match(PUBLIC_KEY_REGEX) 141 | let priv = keys.match(PRIVATE_KEY_REGEX) 142 | 143 | // Ensure valid PGP public key and private key passed 144 | if (!pub || !priv) { 145 | this.showModalError('Missing or invalid details') 146 | return 147 | } 148 | 149 | ipcRenderer 150 | .invoke('import-pgp', { 151 | passphrase, 152 | publicKeyArmored: pub[0], 153 | privateKeyArmored: priv[0] 154 | }) 155 | .then(() => this.closeModals()) 156 | .catch(error => this.showModalError(friendlyError(error))) 157 | } 158 | 159 | // Handles creating a new PGP key 160 | createIdentityHandler (params) { 161 | // Check if all required params supplied 162 | if (!params.name || !params.passphrase || !params.algo) { 163 | this.showModalError('Missing details') 164 | return 165 | } 166 | // Remove email if not supplied 167 | if (!params.email) delete params.email 168 | this.showModalMessage('Generating keys...') 169 | 170 | ipcRenderer 171 | .invoke('create-pgp', params) 172 | .then(() => this.closeModals()) 173 | .catch(error => this.showModalError(friendlyError(error))) 174 | } 175 | 176 | // Handles composing new chats 177 | composeChatHandler (id) { 178 | // Validate id 179 | let [ciphoraId] = id.match(CIPHORA_ID_REGEX) || [] 180 | let [publicKey] = id.match(PUBLIC_KEY_REGEX) || [] 181 | 182 | // Ensure id is either a valid CiphoraId or PGP public key 183 | if (!ciphoraId && !publicKey) { 184 | notifications.show('Invalid CiphoraId or PGP key', 'error', true, 3000) 185 | return 186 | } 187 | 188 | // TODO: replace with progress bar under compose 189 | // Show persistent composing notification 190 | notifications.show('Composing chat...', null, false) 191 | 192 | ipcRenderer.send('add-chat', ciphoraId, publicKey) 193 | } 194 | 195 | // Creates a new chat placeholder for the chat the user is composing 196 | createComposeChat () { 197 | // Already composing 198 | if (this.state.composing) return 199 | const id = COMPOSE_CHAT_ID 200 | // Create a dummy chat 201 | let chats = {} 202 | chats[id] = { 203 | id, 204 | name: 'New Chat', 205 | messages: [] 206 | } 207 | // Add to the front 208 | chats = { ...chats, ...this.state.chats } 209 | this.setState({ composing: true, chats, activeChatId: id }) 210 | } 211 | 212 | // Deletes the new chat placeholder 213 | deleteComposeChat () { 214 | let { chats } = this.state 215 | delete chats[COMPOSE_CHAT_ID] 216 | const nextChat = Object.values(chats)[0] 217 | const activeChatId = nextChat ? nextChat.id : '' 218 | this.setState({ composing: false, chats, activeChatId }) 219 | } 220 | 221 | // Handles chat deletion 222 | deleteChatHandler (id) { 223 | if (id === COMPOSE_CHAT_ID) { 224 | this.deleteComposeChat() 225 | return 226 | } 227 | ipcRenderer.send('delete-chat', id) 228 | } 229 | 230 | // Handles sending a message 231 | composeMessageHandler (message) { 232 | // Ensure message is not empty 233 | if (!message || !WORDS_REGEX.test(message)) return 234 | 235 | ipcRenderer.send( 236 | 'send-message', 237 | CONTENT_TYPES.TEXT, 238 | message, 239 | this.state.activeChatId 240 | ) 241 | } 242 | 243 | // Handles sending a file 244 | async sendFileHandler (type) { 245 | const title = `Select the ${type} to send` 246 | // Filter based on type selected 247 | const filters = FILTERS[type] 248 | const { canceled, filePaths } = await dialog.showOpenDialog( 249 | remote.getCurrentWindow(), 250 | { 251 | properties: ['openFile'], 252 | title, 253 | filters 254 | } 255 | ) 256 | // Ignore if user cancelled 257 | if (canceled || !filePaths) return 258 | console.log(filters, filePaths) 259 | ipcRenderer.send( 260 | 'send-message', 261 | type, 262 | filePaths[0], 263 | this.state.activeChatId 264 | ) 265 | } 266 | 267 | // Render the App UI 268 | render () { 269 | const activeChat = 270 | this.state.activeChatId && this.state.chats[this.state.activeChatId] 271 | return ( 272 |
273 | this.openModal('importIdentity')} 276 | onCreateIdentityClick={() => this.openModal('createIdentity')} 277 | /> 278 | this.openModal('setupIdentity')} 281 | onImportClick={this.importIdentityHandler} 282 | message={this.state.modalMessage} 283 | /> 284 | this.openModal('setupIdentity')} 287 | onCreateClick={this.createIdentityHandler} 288 | message={this.state.modalMessage} 289 | /> 290 | clipboard.writeText(this.state.profile.id)} 300 | onCopyPGPClick={() => ipcRenderer.send('copy-pgp')} 301 | /> 302 | } 303 | content={ 304 | shell.openItem(filePath)} 312 | onLinkClick={url => shell.openExternal(url)} 313 | onDeleteClick={this.deleteChatHandler} 314 | onCopyIdClick={() => clipboard.writeText(this.state.activeChatId)} 315 | onCopyPGPClick={() => 316 | ipcRenderer.send('copy-pgp', this.state.activeChatId) 317 | } 318 | /> 319 | } 320 | /> 321 |
322 | ) 323 | } 324 | } 325 | -------------------------------------------------------------------------------- /src/main/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /** 3 | * Main App 4 | *****************************/ 5 | const { app, Menu, ipcMain, clipboard } = require('electron'), 6 | { basename } = require('path'), 7 | { is } = require('electron-util'), 8 | unhandled = require('electron-unhandled'), 9 | debug = require('electron-debug'), 10 | contextMenu = require('electron-context-menu'), 11 | leveldown = require('leveldown'), 12 | levelup = require('levelup'), 13 | encode = require('encoding-down'), 14 | packageJson = require('../../package.json'), 15 | Crypto = require('./lib/Crypto'), 16 | Server = require('./lib/Server'), 17 | Peers = require('./lib/Peers'), 18 | Chats = require('./lib/Chats'), 19 | State = require('./lib/State'), 20 | { CONTENT_TYPES } = require('../consts'), 21 | menu = require('./menu'), 22 | windows = require('./windows'), 23 | { DB_PATH } = require('../config') 24 | 25 | unhandled() 26 | // debug() 27 | contextMenu() 28 | 29 | app.setAppUserModelId(packageJson.build.appId) 30 | let dbPath = DB_PATH 31 | 32 | if (!is.development) { 33 | // Prevent multiple instances of the app 34 | if (!app.requestSingleInstanceLock()) { 35 | app.quit() 36 | } 37 | 38 | // Someone tried to run a second instance, so focus the main window 39 | app.on('second-instance', windows.main.secondInstance) 40 | } else { 41 | // Allow multiple instances of the app in dev 42 | if (!app.requestSingleInstanceLock()) { 43 | console.info('Second instance') 44 | dbPath += '2' 45 | } 46 | } 47 | 48 | app.on('window-all-closed', () => { 49 | if (!is.macos) { 50 | app.quit() 51 | } 52 | }) 53 | 54 | app.on('activate', windows.main.activate) 55 | 56 | /** 57 | * Main 58 | *****************************/ 59 | ;(async () => { 60 | console.log('\n\n\n**********************************************> New run\n') 61 | 62 | await app.whenReady() 63 | Menu.setApplicationMenu(menu) 64 | 65 | let profile 66 | const db = await levelup(encode(leveldown(dbPath), { valueEncoding: 'json' })) 67 | const crypto = new Crypto(db) 68 | const state = new State(db) 69 | const chats = new Chats(db) 70 | const server = new Server() 71 | const peers = new Peers(server, crypto, chats) 72 | 73 | /** 74 | * App events 75 | *****************************/ 76 | app.on('delete-messages', deleteMessagesHandler) 77 | 78 | /** 79 | * Server events 80 | *****************************/ 81 | // When a new chat request is received from a user 82 | server.on('chat-request', chatRequestHandler) 83 | // When the chat request is accepted from the user 84 | server.on('chat-accept', chatAcceptHandler) 85 | // When the user for a message cannot be found 86 | server.on('unknown-receiver', receiverNotFoundHandler) 87 | 88 | /** 89 | * Peers events 90 | *****************************/ 91 | // When a new connection with a user is established 92 | peers.on('connect', peerConnectHandler) 93 | // When a connection with a user is closed 94 | peers.on('disconnect', peerDisconnectHandler) 95 | // When a connection error with a user occurs 96 | peers.on('error', peerErrorHandler) 97 | // When a new message from a user is received 98 | peers.on('message', peerMessageHandler) 99 | 100 | /** 101 | * IPC events 102 | *****************************/ 103 | // When a message is sent by the user 104 | ipcMain.on('send-message', sendMessageHandler) 105 | // When the user adds a new chat with a new recipient 106 | ipcMain.on('add-chat', addChatHandler) 107 | // When user deletes a chat 108 | ipcMain.on('delete-chat', deleteChatHandler) 109 | // When user wants to copy a PGP key 110 | ipcMain.on('copy-pgp', copyPGPHandler) 111 | // When user selects a chat 112 | ipcMain.handle('activate-chat', async (event, chatId) => 113 | state.set('lastActiveChat', chatId) 114 | ) 115 | // When user wants to generate a new PGP key 116 | ipcMain.handle('create-pgp', async (event, params) => 117 | crypto.generateKey(params) 118 | ) 119 | // When user wants to import a new PGP key 120 | ipcMain.handle('import-pgp', async (event, params) => 121 | crypto.importKey(params) 122 | ) 123 | 124 | /** 125 | * Init 126 | *****************************/ 127 | // Init PGP keys, state and chats and main window in parallel 128 | const [keyExists] = await Promise.all([ 129 | crypto.init(), 130 | chats.init(), 131 | state.init(), 132 | windows.main.init() 133 | ]) 134 | 135 | // Check if user's PGP key exists 136 | if (!keyExists) { 137 | // Launch identity setup 138 | windows.main.send('open-modal', 'setupIdentity') 139 | // Wait until setup is complete i.e. PGP key has been generated/imported 140 | await crypto.whenReady() 141 | } 142 | 143 | // Get the profile 144 | profile = crypto.getUserInfo() 145 | console.info('Profile:', profile) 146 | 147 | // Get last active chat 148 | const activeChatId = state.get('lastActiveChat', chats.getLatestId() || '') 149 | // Populate UI 150 | windows.main.send('update-state', { 151 | chats: chats.getAll(), 152 | activeChatId, 153 | profile 154 | }) 155 | ipcMain.on('do-update-state', async event => 156 | windows.main.send('update-state', { 157 | chats: chats.getAll(), 158 | activeChatId, 159 | profile 160 | }) 161 | ) 162 | 163 | try { 164 | // Authenticate with and connect to the signal server 165 | const authRequest = await crypto.generateAuthRequest() 166 | await server.connect(profile.id, authRequest) 167 | console.log('Connected to server') 168 | } catch (error) { 169 | // Notify user of it 170 | windows.main.send( 171 | 'notify', 172 | 'Failed to connect to the server', 173 | 'error', 174 | true, 175 | 4000 176 | ) 177 | } 178 | 179 | // Establish connections with all chat peers 180 | Object.values(chats.getAll()) 181 | .filter(chat => !peers.has(chat.id)) // Ignore ones already connecting to 182 | .forEach(chat => peers.connect(chat.id)) 183 | 184 | /** 185 | * Handlers 186 | *****************************/ 187 | /* App handlers */ 188 | async function deleteMessagesHandler () { 189 | await chats.deleteAllMessages() 190 | windows.main.send('update-state', { chats: chats.getAll() }) 191 | } 192 | 193 | /* Server handlers */ 194 | async function chatRequestHandler ({ senderPublicKey: publicKeyArmored }) { 195 | console.log('Chat request received') 196 | // TODO: Check id against block/removed list and add to chats 197 | const { id, address } = await crypto.getPublicKeyInfoOf(publicKeyArmored) 198 | if (!chats.has(id)) { 199 | // Add chat if not already added 200 | await chats.add(id, publicKeyArmored, address) 201 | await crypto.addKey(id, publicKeyArmored) 202 | windows.main.send('update-state', { chats: chats.getAll() }) 203 | } 204 | // Accept chat request by default 205 | server.send('chat-accept', { 206 | senderPublicKey: crypto.getPublicKey(), 207 | receiverId: id 208 | }) 209 | console.log('Chat request accepted') 210 | } 211 | async function chatAcceptHandler ({ senderId, senderPublicKey }) { 212 | console.log('Chat request accepted') 213 | const { address } = await crypto.getPublicKeyInfoOf(senderPublicKey) 214 | // Add chat 215 | await chats.add(senderId, senderPublicKey, address) 216 | await crypto.addKey(senderId, senderPublicKey) 217 | // Update UI 218 | windows.main.send( 219 | 'update-state', 220 | { chats: chats.getAll(), activeChatId: senderId }, 221 | true 222 | ) 223 | // Establish a connection 224 | peers.connect(senderId) 225 | } 226 | function receiverNotFoundHandler ({ type, receiverId }) { 227 | if (type === 'chat-request') { 228 | windows.main.send( 229 | 'notify', 230 | 'Recipient not on Ciphora or is offline', 231 | 'error', 232 | true, 233 | 4000 234 | ) 235 | } 236 | } 237 | 238 | /* Peers handlers */ 239 | async function peerConnectHandler (userId) { 240 | console.log('Connected with', userId) 241 | // Set user as online 242 | chats.setOnline(userId) 243 | // Update UI 244 | windows.main.send('update-state', { chats: chats.getAll() }) 245 | } 246 | async function peerDisconnectHandler (userId) { 247 | console.log('Disconnected with', userId) 248 | chats.setOffline(userId) 249 | // Update UI 250 | windows.main.send('update-state', { chats: chats.getAll() }) 251 | } 252 | function peerErrorHandler (userId, err) { 253 | console.log('Error connecting with peer', userId) 254 | console.error(err) 255 | } 256 | async function peerMessageHandler (senderId, message) { 257 | console.log('Got message', message) 258 | chats.addMessage(senderId, message) 259 | windows.main.send('update-state', { chats: chats.getAll() }) 260 | } 261 | 262 | /* IPC handlers */ 263 | async function sendMessageHandler (event, contentType, content, receiverId) { 264 | // Construct message 265 | let contentPath 266 | let message = { 267 | sender: profile.id, 268 | content, 269 | contentType, // mime-type of message 270 | timestamp: new Date().toISOString() 271 | } 272 | 273 | // Set the id of the message to its hash 274 | message.id = crypto.hash(JSON.stringify(message)) 275 | console.log('Adding message', message) 276 | // TODO: Copy media to media dir 277 | // Optimistically update UI 278 | chats.addMessage(receiverId, { ...message }) 279 | windows.main.send('update-state', { chats: chats.getAll() }) 280 | 281 | if ( 282 | contentType === CONTENT_TYPES.IMAGE || 283 | contentType === CONTENT_TYPES.FILE 284 | ) { 285 | contentPath = content 286 | // Set to file name 287 | message.content = basename(contentPath) 288 | // Hash content for verification 289 | message.contentHash = await crypto.hashFile(contentPath) 290 | } 291 | 292 | // Send the message 293 | peers.send(message.id, receiverId, message, true, contentPath) 294 | } 295 | async function addChatHandler (event, ciphoraId, publicKeyArmored) { 296 | if (!ciphoraId) { 297 | // ciphoraId, i.e. the userId, is not given so try to extract it from the 298 | // given PGP publicKey 299 | try { 300 | const { id } = await crypto.getPublicKeyInfoOf(publicKeyArmored) 301 | ciphoraId = id 302 | } catch (err) { 303 | windows.main.send('notify', 'Invalid PGP key', 'error', true, 4000) 304 | } 305 | } 306 | // Normalise the userId 307 | ciphoraId = ciphoraId.toLowerCase() 308 | // Ensure it hasn't been already added or is own 309 | if (chats.has(ciphoraId) || ciphoraId === profile.id) { 310 | windows.main.send('notify', 'Already added', null, true, 4000) 311 | return 312 | } 313 | 314 | // Send a chat request message to the recipient 315 | server.send('chat-request', { 316 | senderPublicKey: crypto.getPublicKey(), 317 | receiverId: ciphoraId 318 | }) 319 | console.log('Chat request sent') 320 | } 321 | async function deleteChatHandler (event, chatId) { 322 | // Delete chat, keys and disconnect in parallel 323 | await Promise.all([ 324 | chats.delete(chatId), 325 | crypto.deleteKey(chatId), 326 | peers.disconnect(chatId) 327 | ]) 328 | // Update UI 329 | windows.main.send( 330 | 'update-state', 331 | { chats: chats.getAll(), activeChatId: chats.getLatestId() }, 332 | true 333 | ) 334 | } 335 | async function copyPGPHandler (event, chatId) { 336 | if (chatId) { 337 | // Copy PGP of given userId 338 | clipboard.writeText(crypto.getChatPublicKey(chatId)) 339 | return 340 | } 341 | // Otherwise, copy own PGP key 342 | clipboard.writeText(crypto.getKey().join('\n')) 343 | } 344 | })() 345 | -------------------------------------------------------------------------------- /src/main/lib/Crypto.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /** 3 | * Crypto class 4 | * Manages all keys and provides all crypto functionality 5 | */ 6 | 7 | const crypto = require('crypto'), 8 | fs = require('fs'), 9 | keytar = require('keytar'), 10 | pgp = require('openpgp'), 11 | hkdf = require('futoin-hkdf'), 12 | // TODO: Replace with crypto.diffieHellman once nodejs#26626 lands on v12 LTS 13 | { box } = require('tweetnacl'), 14 | { waitUntil, parseAddress, chunk, isEmpty, hexToUint8 } = require('./util'), 15 | SELFKEY_DB_KEY = 'selfKey', 16 | CHATKEYS_DB_KEY = 'chatKeys', 17 | SERVICE = 'ciphora', 18 | CIPHER = 'aes-256-cbc', 19 | RATCHET_KEYS_LEN = 64, 20 | RATCHET_KEYS_HASH = 'SHA-256', 21 | MESSAGE_KEY_LEN = 80, 22 | MESSAGE_CHUNK_LEN = 32, 23 | MESSAGE_KEY_SEED = 1, // 0x01 24 | CHAIN_KEY_SEED = 2, // 0x02 25 | RACHET_MESSAGE_COUNT = 10 // Rachet after this no of messages sent 26 | 27 | // Enable compression by default 28 | pgp.config.compression = pgp.enums.compression.zlib 29 | 30 | module.exports = class Crypto { 31 | constructor (store) { 32 | // Ensure singleton 33 | if (!!Crypto.instance) { 34 | return Crypto.instance 35 | } 36 | 37 | this._store = store 38 | this._chatKeys = {} 39 | this._selfKey = {} 40 | this._armoredChatKeys = {} 41 | this._armoredSelfKey = {} 42 | 43 | // Bindings 44 | this.init = this.init.bind(this) 45 | 46 | Crypto.instance = this 47 | } 48 | 49 | // Initialise chat PGP keys and load own from store 50 | async init () { 51 | // Load chat keys if they exist 52 | try { 53 | this._armoredChatKeys = await this._store.get(CHATKEYS_DB_KEY) 54 | if (this._armoredChatKeys) { 55 | const loads = Object.values(this._armoredChatKeys).map(key => 56 | this.addKey(key.id, key.publicKeyArmored, false) 57 | ) 58 | await Promise.all(loads) 59 | console.log('Initialised chat keys', this._chatKeys) 60 | } 61 | } catch (err) { 62 | // Ignore not found err 63 | if (!err.notFound) throw err 64 | } 65 | // Load self key if it exists 66 | try { 67 | this._armoredSelfKey = await this._store.get(SELFKEY_DB_KEY) 68 | // Get the PGP private key passphrase from the OS's keychain 69 | const passphrase = await keytar.getPassword( 70 | SERVICE, 71 | this._armoredSelfKey.user.id 72 | ) 73 | if (!passphrase) return false 74 | await this._initKey(passphrase) 75 | console.log('Loaded self keys') 76 | return true 77 | } catch (err) { 78 | // No self keys exist 79 | if (err.notFound) return false 80 | throw err 81 | } 82 | } 83 | 84 | // Waits until own PGP key has been generated/imported if no own PGP key 85 | async whenReady () { 86 | await waitUntil(() => !isEmpty(this._selfKey)) 87 | } 88 | 89 | // Gets own user info 90 | getUserInfo () { 91 | return this._armoredSelfKey.user 92 | } 93 | 94 | // Gets own PGP public key 95 | getPublicKey () { 96 | return this._armoredSelfKey.publicKeyArmored 97 | } 98 | 99 | // Gets own PGP key info (id, name,...) 100 | getPublicKeyInfo () { 101 | const { id } = this._armoredSelfKey 102 | const address = parseAddress(this._selfKey.publicKey.getUserIds()) 103 | return { id, ...address } 104 | } 105 | 106 | // Gets chat PGP public key 107 | getChatPublicKey (id) { 108 | return this._armoredChatKeys[id].publicKeyArmored 109 | } 110 | 111 | // Extracts the id (fingerprint) and address of the given PGP key 112 | async getPublicKeyInfoOf (publicKeyArmored) { 113 | const { 114 | keys: [publicKey] 115 | } = await pgp.key.readArmored(publicKeyArmored) 116 | const id = publicKey.getFingerprint() 117 | const address = parseAddress(publicKey.getUserIds()) 118 | return { id, address } 119 | } 120 | 121 | // Imports a new chat PGP key 122 | async addKey (id, publicKeyArmored, save = true) { 123 | const { 124 | keys: [publicKey] 125 | } = await pgp.key.readArmored(publicKeyArmored) 126 | this._chatKeys[id] = { publicKey } 127 | if (!save) return 128 | this._armoredChatKeys[id] = { id, publicKeyArmored } 129 | await this._saveChatKeys() 130 | console.log('Added key', this._chatKeys) 131 | } 132 | 133 | // Deletes a chat PGP key 134 | async deleteKey (id) { 135 | delete this._chatKeys[id] 136 | delete this._armoredChatKeys[id] 137 | await this._saveChatKeys() 138 | console.log('Deleted key', id) 139 | } 140 | 141 | // Imports the given PGP public key and private key as own PGP key 142 | async importKey ({ passphrase, publicKeyArmored, privateKeyArmored }) { 143 | this._armoredSelfKey = { publicKeyArmored, privateKeyArmored } 144 | await this._initKey(passphrase) 145 | await this._saveKey(passphrase) 146 | console.log('Imported self key') 147 | } 148 | 149 | // Generates new PGP key and sets it up as own PGP key 150 | async generateKey ({ passphrase, algo, ...userIds }) { 151 | const [type, variant] = algo.split('-') 152 | let genAlgo 153 | try { 154 | // Parse the type of key generation algorithm selected 155 | switch (type) { 156 | case 'rsa': 157 | genAlgo = { 158 | rsaBits: parseInt(variant) // RSA key length 159 | } 160 | break 161 | case 'ecc': 162 | genAlgo = { 163 | curve: variant // ECC curve name 164 | } 165 | break 166 | default: 167 | throw new Error('Unrecognised key generation algorithm') 168 | } 169 | 170 | // Generate new PGP key with the details supplied 171 | const { key, ...keyData } = await pgp.generateKey({ 172 | userIds: [userIds], 173 | ...genAlgo, 174 | passphrase 175 | }) 176 | this._armoredSelfKey = keyData 177 | await this._initKey(passphrase) 178 | await this._saveKey(passphrase) 179 | console.log('Generated self key') 180 | } catch (err) { 181 | return err 182 | } 183 | } 184 | 185 | // Signs a message with own PGP key 186 | async sign (message) { 187 | const { privateKey } = this._selfKey 188 | const { signature } = await pgp.sign({ 189 | message: pgp.cleartext.fromText(message), 190 | privateKeys: [privateKey], 191 | detached: true 192 | }) 193 | console.log('PGP signed message') 194 | return signature 195 | } 196 | 197 | // Verifies a message with user's PGP key 198 | async verify (id, message, signature) { 199 | // Get user's public key 200 | const { publicKey } = this._chatKeys[id] 201 | // Fail verification if all params are not supplied 202 | if (!message || !publicKey || !signature) return false 203 | const verified = await pgp.verify({ 204 | message: pgp.cleartext.fromText(message), 205 | signature: await pgp.signature.readArmored(signature), 206 | publicKeys: [publicKey] 207 | }) 208 | console.log('PGP verified message') 209 | return verified.signatures[0].valid 210 | } 211 | 212 | // Generates a server connection authentication request 213 | async generateAuthRequest () { 214 | const timestamp = new Date().toISOString() 215 | const signature = await this.sign(timestamp) 216 | const publicKey = this._armoredSelfKey.publicKeyArmored 217 | return { publicKey, timestamp, signature } 218 | } 219 | 220 | // Saves own PGP key to the store 221 | async _saveKey (passphrase) { 222 | // Save the PGP passphrase for the private in the OS's keychain 223 | // Use user id as account name 224 | await keytar.setPassword(SERVICE, this._armoredSelfKey.user.id, passphrase) 225 | // Save in store 226 | await this._store.put(SELFKEY_DB_KEY, this._armoredSelfKey) 227 | console.log('Saved self keys') 228 | } 229 | 230 | // Saves the chat PGP keys to the store 231 | async _saveChatKeys () { 232 | await this._store.put(CHATKEYS_DB_KEY, this._armoredChatKeys) 233 | console.log('Saved chat keys') 234 | } 235 | 236 | // Initialises own PGP key and decrypts its private key 237 | async _initKey (passphrase) { 238 | const { publicKeyArmored, privateKeyArmored, user } = this._armoredSelfKey 239 | const { 240 | keys: [publicKey] 241 | } = await pgp.key.readArmored(publicKeyArmored) 242 | const { 243 | keys: [privateKey] 244 | } = await pgp.key.readArmored(privateKeyArmored) 245 | 246 | await privateKey.decrypt(passphrase) 247 | // Set user info if not already set 248 | if (!user) { 249 | this._armoredSelfKey.user = { 250 | id: publicKey.getFingerprint(), 251 | ...parseAddress(publicKey.getUserIds()) 252 | } 253 | } 254 | // Init key 255 | this._selfKey = { 256 | publicKey, 257 | privateKey 258 | } 259 | 260 | console.log('Initialised self key') 261 | } 262 | 263 | // Returns a hash digest of the given data 264 | hash (data, enc = 'hex', alg = 'sha256') { 265 | return crypto 266 | .createHash(alg) 267 | .update(data) 268 | .digest(enc) 269 | } 270 | 271 | // Returns a hash digest of the given file 272 | hashFile (path, enc = 'hex', alg = 'sha256') { 273 | return new Promise((resolve, reject) => 274 | fs 275 | .createReadStream(path) 276 | .on('error', reject) 277 | .pipe(crypto.createHash(alg).setEncoding(enc)) 278 | .once('finish', function () { 279 | resolve(this.read()) 280 | }) 281 | ) 282 | } 283 | 284 | // Hash Key Derivation Function (based on HMAC) 285 | _HKDF (input, salt, info, length = RATCHET_KEYS_LEN) { 286 | // input = input instanceof Uint8Array ? Buffer.from(input) : input 287 | // salt = salt instanceof Uint8Array ? Buffer.from(salt) : salt 288 | return hkdf(input, length, { 289 | salt, 290 | info, 291 | hash: RATCHET_KEYS_HASH 292 | }) 293 | } 294 | 295 | // Hash-based Message Authentication Code 296 | _HMAC (key, data, enc = 'utf8', algo = 'sha256') { 297 | return crypto 298 | .createHmac(algo, key) 299 | .update(data) 300 | .digest(enc) 301 | } 302 | 303 | // Generates a new Curve25519 key pair 304 | _generateRatchetKeyPair () { 305 | let keyPair = box.keyPair() 306 | // Encode in hex for easier handling 307 | keyPair.publicKey = Buffer.from(keyPair.publicKey).toString('hex') 308 | return keyPair 309 | } 310 | 311 | // Initialises an end-to-end encryption session 312 | async initSession (id) { 313 | // Generates a new ephemeral ratchet Curve25519 key pair for chat 314 | let { publicKey, secretKey } = this._generateRatchetKeyPair() 315 | // Initialise session object 316 | this._chatKeys[id].session = { 317 | currentRatchet: { 318 | sendingKeys: { 319 | publicKey, 320 | secretKey 321 | }, 322 | previousCounter: 0 323 | }, 324 | sending: {}, 325 | receiving: {} 326 | } 327 | // Sign public key 328 | const timestamp = new Date().toISOString() 329 | const signature = await this.sign(publicKey + timestamp) 330 | console.log('Initialised new session', this._chatKeys[id].session) 331 | return { publicKey, timestamp, signature } 332 | } 333 | 334 | // Starts the session 335 | async startSession (id, keyMessage) { 336 | const { publicKey, timestamp, signature } = keyMessage 337 | // Validate sender public key 338 | const sigValid = await this.verify(id, publicKey + timestamp, signature) 339 | // Ignore if new encryption session if signature not valid 340 | if (!sigValid) return console.log('PubKey sig invalid', publicKey) 341 | 342 | const ratchet = this._chatKeys[id].session.currentRatchet 343 | const { secretKey } = ratchet.sendingKeys 344 | ratchet.receivingKey = publicKey 345 | // Derive shared master secret and root key 346 | const [rootKey] = this._calcRatchetKeys( 347 | 'CiphoraSecret', 348 | secretKey, 349 | publicKey 350 | ) 351 | ratchet.rootKey = rootKey 352 | console.log( 353 | 'Initialised Session', 354 | rootKey.toString('hex'), 355 | this._chatKeys[id].session 356 | ) 357 | } 358 | 359 | // Calculates the ratchet keys (root and chain key) 360 | _calcRatchetKeys (oldRootKey, sendingSecretKey, receivingKey) { 361 | // Convert receivingKey to a Uint8Array if it isn't already 362 | if (typeof receivingKey === 'string') 363 | receivingKey = hexToUint8(receivingKey) 364 | // Derive shared ephemeral secret 365 | const sharedSecret = box.before(receivingKey, sendingSecretKey) 366 | // Derive the new ratchet keys 367 | const ratchetKeys = this._HKDF(sharedSecret, oldRootKey, 'CiphoraRatchet') 368 | console.log('Derived ratchet keys', ratchetKeys.toString('hex')) 369 | // Chunk ratchetKeys output into its parts: root key and chain key 370 | return chunk(ratchetKeys, RATCHET_KEYS_LEN / 2) 371 | } 372 | 373 | // Calculates the next receiving or sending ratchet 374 | _calcRatchet (session, sending, receivingKey) { 375 | let ratchet = session.currentRatchet 376 | let ratchetChains, publicKey, previousChain 377 | 378 | if (sending) { 379 | ratchetChains = session.sending 380 | previousChain = ratchetChains[ratchet.sendingKeys.publicKey] 381 | // Replace ephemeral ratchet sending keys with new ones 382 | ratchet.sendingKeys = this._generateRatchetKeyPair() 383 | publicKey = ratchet.sendingKeys.publicKey 384 | console.log('New sending keys generated', publicKey) 385 | } else { 386 | // TODO: Check counters to pre-compute skipped keys 387 | ratchetChains = session.receiving 388 | previousChain = ratchetChains[ratchet.receivingKey] 389 | publicKey = ratchet.receivingKey = receivingKey 390 | } 391 | 392 | if (previousChain) { 393 | // Update the previousCounter with the previous chain counter 394 | ratchet.previousCounter = previousChain.chain.counter 395 | } 396 | // Derive new ratchet keys 397 | const [rootKey, chainKey] = this._calcRatchetKeys( 398 | ratchet.rootKey, 399 | ratchet.sendingKeys.secretKey, 400 | ratchet.receivingKey 401 | ) 402 | // Update root key 403 | ratchet.rootKey = rootKey 404 | // Initialise new chain 405 | ratchetChains[publicKey] = { 406 | messageKeys: {}, 407 | chain: { 408 | counter: -1, 409 | key: chainKey 410 | } 411 | } 412 | return ratchetChains[publicKey] 413 | } 414 | 415 | // Calculates the next message key for the ratchet and updates it 416 | // TODO: Try to get messagekey with message counter otherwise calculate all 417 | // message keys up to it and return it (instead of pre-comp on ratchet) 418 | _calcMessageKey (ratchet) { 419 | let chain = ratchet.chain 420 | // Calculate next message key 421 | const messageKey = this._HMAC(chain.key, Buffer.alloc(1, MESSAGE_KEY_SEED)) 422 | // Calculate next ratchet chain key 423 | chain.key = this._HMAC(chain.key, Buffer.alloc(1, CHAIN_KEY_SEED)) 424 | // Increment the chain counter 425 | chain.counter++ 426 | // Save the message key 427 | ratchet.messageKeys[chain.counter] = messageKey 428 | console.log('Calculated next messageKey', ratchet) 429 | // Derive encryption key, mac key and iv 430 | return chunk( 431 | this._HKDF(messageKey, 'CiphoraCrypt', null, MESSAGE_KEY_LEN), 432 | MESSAGE_CHUNK_LEN 433 | ) 434 | } 435 | 436 | // Encrypts a message 437 | async encrypt (id, message, isFile) { 438 | let session = this._chatKeys[id].session 439 | let ratchet = session.currentRatchet 440 | let sendingChain = session.sending[ratchet.sendingKeys.publicKey] 441 | // Ratchet after every RACHET_MESSAGE_COUNT of messages 442 | let shouldRatchet = 443 | sendingChain && sendingChain.chain.counter >= RACHET_MESSAGE_COUNT 444 | if (!sendingChain || shouldRatchet) { 445 | sendingChain = this._calcRatchet(session, true) 446 | console.log('Calculated new sending ratchet', session) 447 | } 448 | const { previousCounter } = ratchet 449 | const { publicKey } = ratchet.sendingKeys 450 | const [encryptKey, macKey, iv] = this._calcMessageKey(sendingChain) 451 | console.log( 452 | 'Calculated encryption creds', 453 | encryptKey.toString('hex'), 454 | iv.toString('hex') 455 | ) 456 | const { counter } = sendingChain.chain 457 | // Encrypt message contents 458 | const messageCipher = crypto.createCipheriv(CIPHER, encryptKey, iv) 459 | const content = 460 | messageCipher.update(message.content, 'utf8', 'hex') + 461 | messageCipher.final('hex') 462 | 463 | // Construct full message 464 | let encryptedMessage = { 465 | ...message, 466 | publicKey, 467 | previousCounter, 468 | counter, 469 | content 470 | } 471 | // Sign message with PGP 472 | encryptedMessage.signature = await this.sign( 473 | JSON.stringify(encryptedMessage) 474 | ) 475 | 476 | if (isFile) { 477 | // Return cipher 478 | const contentCipher = crypto.createCipheriv(CIPHER, encryptKey, iv) 479 | return { encryptedMessage, contentCipher } 480 | } 481 | 482 | return { encryptedMessage } 483 | } 484 | 485 | // Decrypts a message 486 | async decrypt (id, signedMessage, isFile) { 487 | const { signature, ...fullMessage } = signedMessage 488 | const sigValid = await this.verify( 489 | id, 490 | JSON.stringify(fullMessage), 491 | signature 492 | ) 493 | // Ignore message if signature invalid 494 | if (!sigValid) { 495 | console.log('Message signature invalid!') 496 | return false 497 | } 498 | const { publicKey, counter, previousCounter, ...message } = fullMessage 499 | let session = this._chatKeys[id].session 500 | let receivingChain = session.receiving[publicKey] 501 | if (!receivingChain) { 502 | // Receiving ratchet for key does not exist so create one 503 | receivingChain = this._calcRatchet(session, false, publicKey) 504 | console.log('Calculated new receiving ratchet', receivingChain) 505 | } 506 | // Derive decryption credentials 507 | const [decryptKey, macKey, iv] = this._calcMessageKey(receivingChain) 508 | console.log( 509 | 'Calculated decryption creds', 510 | decryptKey.toString('hex'), 511 | iv.toString('hex') 512 | ) 513 | // Decrypt the message contents 514 | const messageDecipher = crypto.createDecipheriv(CIPHER, decryptKey, iv) 515 | const content = 516 | messageDecipher.update(message.content, 'hex', 'utf8') + 517 | messageDecipher.final('utf8') 518 | console.log('--> Decrypted content', content) 519 | 520 | const decryptedMessage = { ...message, content } 521 | 522 | if (isFile) { 523 | // Return Decipher 524 | const contentDecipher = crypto.createDecipheriv(CIPHER, decryptKey, iv) 525 | return { decryptedMessage, contentDecipher } 526 | } 527 | 528 | return { decryptedMessage } 529 | } 530 | } 531 | -------------------------------------------------------------------------------- /static/scss/ionicons.scss: -------------------------------------------------------------------------------- 1 | @charset "UTF-8"; 2 | /*! 3 | Ionicons, v4.6.3 4 | Created by Ben Sperry for the Ionic Framework, http://ionicons.com/ 5 | https://twitter.com/benjsperry https://twitter.com/ionicframework 6 | MIT License: https://github.com/driftyco/ionicons 7 | 8 | Android-style icons originally built by Google’s 9 | Material Design Icons: https://github.com/google/material-design-icons 10 | used under CC BY http://creativecommons.org/licenses/by/4.0/ 11 | Modified icons to fit ionicon’s grid from original. 12 | https://cdnjs.cloudflare.com/ajax/libs/ionicons/4.6.3/css/ionicons.css 13 | */ 14 | @font-face { 15 | font-family: "Ionicons"; 16 | src: url("../fonts/ionicons.woff2") format("woff2"); 17 | font-weight: normal; 18 | font-style: normal; 19 | } 20 | .ion, .ionicons, 21 | .ion-ios-add:before, 22 | .ion-ios-add-circle:before, 23 | .ion-ios-add-circle-outline:before, 24 | .ion-ios-airplane:before, 25 | .ion-ios-alarm:before, 26 | .ion-ios-albums:before, 27 | .ion-ios-alert:before, 28 | .ion-ios-american-football:before, 29 | .ion-ios-analytics:before, 30 | .ion-ios-aperture:before, 31 | .ion-ios-apps:before, 32 | .ion-ios-appstore:before, 33 | .ion-ios-archive:before, 34 | .ion-ios-arrow-back:before, 35 | .ion-ios-arrow-down:before, 36 | .ion-ios-arrow-dropdown:before, 37 | .ion-ios-arrow-dropdown-circle:before, 38 | .ion-ios-arrow-dropleft:before, 39 | .ion-ios-arrow-dropleft-circle:before, 40 | .ion-ios-arrow-dropright:before, 41 | .ion-ios-arrow-dropright-circle:before, 42 | .ion-ios-arrow-dropup:before, 43 | .ion-ios-arrow-dropup-circle:before, 44 | .ion-ios-arrow-forward:before, 45 | .ion-ios-arrow-round-back:before, 46 | .ion-ios-arrow-round-down:before, 47 | .ion-ios-arrow-round-forward:before, 48 | .ion-ios-arrow-round-up:before, 49 | .ion-ios-arrow-up:before, 50 | .ion-ios-at:before, 51 | .ion-ios-attach:before, 52 | .ion-ios-backspace:before, 53 | .ion-ios-barcode:before, 54 | .ion-ios-baseball:before, 55 | .ion-ios-basket:before, 56 | .ion-ios-basketball:before, 57 | .ion-ios-battery-charging:before, 58 | .ion-ios-battery-dead:before, 59 | .ion-ios-battery-full:before, 60 | .ion-ios-beaker:before, 61 | .ion-ios-bed:before, 62 | .ion-ios-beer:before, 63 | .ion-ios-bicycle:before, 64 | .ion-ios-bluetooth:before, 65 | .ion-ios-boat:before, 66 | .ion-ios-body:before, 67 | .ion-ios-bonfire:before, 68 | .ion-ios-book:before, 69 | .ion-ios-bookmark:before, 70 | .ion-ios-bookmarks:before, 71 | .ion-ios-bowtie:before, 72 | .ion-ios-briefcase:before, 73 | .ion-ios-browsers:before, 74 | .ion-ios-brush:before, 75 | .ion-ios-bug:before, 76 | .ion-ios-build:before, 77 | .ion-ios-bulb:before, 78 | .ion-ios-bus:before, 79 | .ion-ios-business:before, 80 | .ion-ios-cafe:before, 81 | .ion-ios-calculator:before, 82 | .ion-ios-calendar:before, 83 | .ion-ios-call:before, 84 | .ion-ios-camera:before, 85 | .ion-ios-car:before, 86 | .ion-ios-card:before, 87 | .ion-ios-cart:before, 88 | .ion-ios-cash:before, 89 | .ion-ios-cellular:before, 90 | .ion-ios-chatboxes:before, 91 | .ion-ios-chatbubbles:before, 92 | .ion-ios-checkbox:before, 93 | .ion-ios-checkbox-outline:before, 94 | .ion-ios-checkmark:before, 95 | .ion-ios-checkmark-circle:before, 96 | .ion-ios-checkmark-circle-outline:before, 97 | .ion-ios-clipboard:before, 98 | .ion-ios-clock:before, 99 | .ion-ios-close:before, 100 | .ion-ios-close-circle:before, 101 | .ion-ios-close-circle-outline:before, 102 | .ion-ios-cloud:before, 103 | .ion-ios-cloud-circle:before, 104 | .ion-ios-cloud-done:before, 105 | .ion-ios-cloud-download:before, 106 | .ion-ios-cloud-outline:before, 107 | .ion-ios-cloud-upload:before, 108 | .ion-ios-cloudy:before, 109 | .ion-ios-cloudy-night:before, 110 | .ion-ios-code:before, 111 | .ion-ios-code-download:before, 112 | .ion-ios-code-working:before, 113 | .ion-ios-cog:before, 114 | .ion-ios-color-fill:before, 115 | .ion-ios-color-filter:before, 116 | .ion-ios-color-palette:before, 117 | .ion-ios-color-wand:before, 118 | .ion-ios-compass:before, 119 | .ion-ios-construct:before, 120 | .ion-ios-contact:before, 121 | .ion-ios-contacts:before, 122 | .ion-ios-contract:before, 123 | .ion-ios-contrast:before, 124 | .ion-ios-copy:before, 125 | .ion-ios-create:before, 126 | .ion-ios-crop:before, 127 | .ion-ios-cube:before, 128 | .ion-ios-cut:before, 129 | .ion-ios-desktop:before, 130 | .ion-ios-disc:before, 131 | .ion-ios-document:before, 132 | .ion-ios-done-all:before, 133 | .ion-ios-download:before, 134 | .ion-ios-easel:before, 135 | .ion-ios-egg:before, 136 | .ion-ios-exit:before, 137 | .ion-ios-expand:before, 138 | .ion-ios-eye:before, 139 | .ion-ios-eye-off:before, 140 | .ion-ios-fastforward:before, 141 | .ion-ios-female:before, 142 | .ion-ios-filing:before, 143 | .ion-ios-film:before, 144 | .ion-ios-finger-print:before, 145 | .ion-ios-fitness:before, 146 | .ion-ios-flag:before, 147 | .ion-ios-flame:before, 148 | .ion-ios-flash:before, 149 | .ion-ios-flash-off:before, 150 | .ion-ios-flashlight:before, 151 | .ion-ios-flask:before, 152 | .ion-ios-flower:before, 153 | .ion-ios-folder:before, 154 | .ion-ios-folder-open:before, 155 | .ion-ios-football:before, 156 | .ion-ios-funnel:before, 157 | .ion-ios-gift:before, 158 | .ion-ios-git-branch:before, 159 | .ion-ios-git-commit:before, 160 | .ion-ios-git-compare:before, 161 | .ion-ios-git-merge:before, 162 | .ion-ios-git-network:before, 163 | .ion-ios-git-pull-request:before, 164 | .ion-ios-glasses:before, 165 | .ion-ios-globe:before, 166 | .ion-ios-grid:before, 167 | .ion-ios-hammer:before, 168 | .ion-ios-hand:before, 169 | .ion-ios-happy:before, 170 | .ion-ios-headset:before, 171 | .ion-ios-heart:before, 172 | .ion-ios-heart-dislike:before, 173 | .ion-ios-heart-empty:before, 174 | .ion-ios-heart-half:before, 175 | .ion-ios-help:before, 176 | .ion-ios-help-buoy:before, 177 | .ion-ios-help-circle:before, 178 | .ion-ios-help-circle-outline:before, 179 | .ion-ios-home:before, 180 | .ion-ios-hourglass:before, 181 | .ion-ios-ice-cream:before, 182 | .ion-ios-image:before, 183 | .ion-ios-images:before, 184 | .ion-ios-infinite:before, 185 | .ion-ios-information:before, 186 | .ion-ios-information-circle:before, 187 | .ion-ios-information-circle-outline:before, 188 | .ion-ios-jet:before, 189 | .ion-ios-journal:before, 190 | .ion-ios-key:before, 191 | .ion-ios-keypad:before, 192 | .ion-ios-laptop:before, 193 | .ion-ios-leaf:before, 194 | .ion-ios-link:before, 195 | .ion-ios-list:before, 196 | .ion-ios-list-box:before, 197 | .ion-ios-locate:before, 198 | .ion-ios-lock:before, 199 | .ion-ios-log-in:before, 200 | .ion-ios-log-out:before, 201 | .ion-ios-magnet:before, 202 | .ion-ios-mail:before, 203 | .ion-ios-mail-open:before, 204 | .ion-ios-mail-unread:before, 205 | .ion-ios-male:before, 206 | .ion-ios-man:before, 207 | .ion-ios-map:before, 208 | .ion-ios-medal:before, 209 | .ion-ios-medical:before, 210 | .ion-ios-medkit:before, 211 | .ion-ios-megaphone:before, 212 | .ion-ios-menu:before, 213 | .ion-ios-mic:before, 214 | .ion-ios-mic-off:before, 215 | .ion-ios-microphone:before, 216 | .ion-ios-moon:before, 217 | .ion-ios-more:before, 218 | .ion-ios-move:before, 219 | .ion-ios-musical-note:before, 220 | .ion-ios-musical-notes:before, 221 | .ion-ios-navigate:before, 222 | .ion-ios-notifications:before, 223 | .ion-ios-notifications-off:before, 224 | .ion-ios-notifications-outline:before, 225 | .ion-ios-nuclear:before, 226 | .ion-ios-nutrition:before, 227 | .ion-ios-open:before, 228 | .ion-ios-options:before, 229 | .ion-ios-outlet:before, 230 | .ion-ios-paper:before, 231 | .ion-ios-paper-plane:before, 232 | .ion-ios-partly-sunny:before, 233 | .ion-ios-pause:before, 234 | .ion-ios-paw:before, 235 | .ion-ios-people:before, 236 | .ion-ios-person:before, 237 | .ion-ios-person-add:before, 238 | .ion-ios-phone-landscape:before, 239 | .ion-ios-phone-portrait:before, 240 | .ion-ios-photos:before, 241 | .ion-ios-pie:before, 242 | .ion-ios-pin:before, 243 | .ion-ios-pint:before, 244 | .ion-ios-pizza:before, 245 | .ion-ios-planet:before, 246 | .ion-ios-play:before, 247 | .ion-ios-play-circle:before, 248 | .ion-ios-podium:before, 249 | .ion-ios-power:before, 250 | .ion-ios-pricetag:before, 251 | .ion-ios-pricetags:before, 252 | .ion-ios-print:before, 253 | .ion-ios-pulse:before, 254 | .ion-ios-qr-scanner:before, 255 | .ion-ios-quote:before, 256 | .ion-ios-radio:before, 257 | .ion-ios-radio-button-off:before, 258 | .ion-ios-radio-button-on:before, 259 | .ion-ios-rainy:before, 260 | .ion-ios-recording:before, 261 | .ion-ios-redo:before, 262 | .ion-ios-refresh:before, 263 | .ion-ios-refresh-circle:before, 264 | .ion-ios-remove:before, 265 | .ion-ios-remove-circle:before, 266 | .ion-ios-remove-circle-outline:before, 267 | .ion-ios-reorder:before, 268 | .ion-ios-repeat:before, 269 | .ion-ios-resize:before, 270 | .ion-ios-restaurant:before, 271 | .ion-ios-return-left:before, 272 | .ion-ios-return-right:before, 273 | .ion-ios-reverse-camera:before, 274 | .ion-ios-rewind:before, 275 | .ion-ios-ribbon:before, 276 | .ion-ios-rocket:before, 277 | .ion-ios-rose:before, 278 | .ion-ios-sad:before, 279 | .ion-ios-save:before, 280 | .ion-ios-school:before, 281 | .ion-ios-search:before, 282 | .ion-ios-send:before, 283 | .ion-ios-settings:before, 284 | .ion-ios-share:before, 285 | .ion-ios-share-alt:before, 286 | .ion-ios-shirt:before, 287 | .ion-ios-shuffle:before, 288 | .ion-ios-skip-backward:before, 289 | .ion-ios-skip-forward:before, 290 | .ion-ios-snow:before, 291 | .ion-ios-speedometer:before, 292 | .ion-ios-square:before, 293 | .ion-ios-square-outline:before, 294 | .ion-ios-star:before, 295 | .ion-ios-star-half:before, 296 | .ion-ios-star-outline:before, 297 | .ion-ios-stats:before, 298 | .ion-ios-stopwatch:before, 299 | .ion-ios-subway:before, 300 | .ion-ios-sunny:before, 301 | .ion-ios-swap:before, 302 | .ion-ios-switch:before, 303 | .ion-ios-sync:before, 304 | .ion-ios-tablet-landscape:before, 305 | .ion-ios-tablet-portrait:before, 306 | .ion-ios-tennisball:before, 307 | .ion-ios-text:before, 308 | .ion-ios-thermometer:before, 309 | .ion-ios-thumbs-down:before, 310 | .ion-ios-thumbs-up:before, 311 | .ion-ios-thunderstorm:before, 312 | .ion-ios-time:before, 313 | .ion-ios-timer:before, 314 | .ion-ios-today:before, 315 | .ion-ios-train:before, 316 | .ion-ios-transgender:before, 317 | .ion-ios-trash:before, 318 | .ion-ios-trending-down:before, 319 | .ion-ios-trending-up:before, 320 | .ion-ios-trophy:before, 321 | .ion-ios-tv:before, 322 | .ion-ios-umbrella:before, 323 | .ion-ios-undo:before, 324 | .ion-ios-unlock:before, 325 | .ion-ios-videocam:before, 326 | .ion-ios-volume-high:before, 327 | .ion-ios-volume-low:before, 328 | .ion-ios-volume-mute:before, 329 | .ion-ios-volume-off:before, 330 | .ion-ios-walk:before, 331 | .ion-ios-wallet:before, 332 | .ion-ios-warning:before, 333 | .ion-ios-watch:before, 334 | .ion-ios-water:before, 335 | .ion-ios-wifi:before, 336 | .ion-ios-wine:before, 337 | .ion-ios-woman:before, 338 | .ion-logo-android:before, 339 | .ion-logo-angular:before, 340 | .ion-logo-apple:before, 341 | .ion-logo-bitbucket:before, 342 | .ion-logo-bitcoin:before, 343 | .ion-logo-buffer:before, 344 | .ion-logo-chrome:before, 345 | .ion-logo-closed-captioning:before, 346 | .ion-logo-codepen:before, 347 | .ion-logo-css3:before, 348 | .ion-logo-designernews:before, 349 | .ion-logo-dribbble:before, 350 | .ion-logo-dropbox:before, 351 | .ion-logo-euro:before, 352 | .ion-logo-facebook:before, 353 | .ion-logo-flickr:before, 354 | .ion-logo-foursquare:before, 355 | .ion-logo-freebsd-devil:before, 356 | .ion-logo-game-controller-a:before, 357 | .ion-logo-game-controller-b:before, 358 | .ion-logo-github:before, 359 | .ion-logo-google:before, 360 | .ion-logo-googleplus:before, 361 | .ion-logo-hackernews:before, 362 | .ion-logo-html5:before, 363 | .ion-logo-instagram:before, 364 | .ion-logo-ionic:before, 365 | .ion-logo-ionitron:before, 366 | .ion-logo-javascript:before, 367 | .ion-logo-linkedin:before, 368 | .ion-logo-markdown:before, 369 | .ion-logo-model-s:before, 370 | .ion-logo-no-smoking:before, 371 | .ion-logo-nodejs:before, 372 | .ion-logo-npm:before, 373 | .ion-logo-octocat:before, 374 | .ion-logo-pinterest:before, 375 | .ion-logo-playstation:before, 376 | .ion-logo-polymer:before, 377 | .ion-logo-python:before, 378 | .ion-logo-reddit:before, 379 | .ion-logo-rss:before, 380 | .ion-logo-sass:before, 381 | .ion-logo-skype:before, 382 | .ion-logo-slack:before, 383 | .ion-logo-snapchat:before, 384 | .ion-logo-steam:before, 385 | .ion-logo-tumblr:before, 386 | .ion-logo-tux:before, 387 | .ion-logo-twitch:before, 388 | .ion-logo-twitter:before, 389 | .ion-logo-usd:before, 390 | .ion-logo-vimeo:before, 391 | .ion-logo-vk:before, 392 | .ion-logo-whatsapp:before, 393 | .ion-logo-windows:before, 394 | .ion-logo-wordpress:before, 395 | .ion-logo-xbox:before, 396 | .ion-logo-xing:before, 397 | .ion-logo-yahoo:before, 398 | .ion-logo-yen:before, 399 | .ion-logo-youtube:before, 400 | .ion-md-add:before, 401 | .ion-md-add-circle:before, 402 | .ion-md-add-circle-outline:before, 403 | .ion-md-airplane:before, 404 | .ion-md-alarm:before, 405 | .ion-md-albums:before, 406 | .ion-md-alert:before, 407 | .ion-md-american-football:before, 408 | .ion-md-analytics:before, 409 | .ion-md-aperture:before, 410 | .ion-md-apps:before, 411 | .ion-md-appstore:before, 412 | .ion-md-archive:before, 413 | .ion-md-arrow-back:before, 414 | .ion-md-arrow-down:before, 415 | .ion-md-arrow-dropdown:before, 416 | .ion-md-arrow-dropdown-circle:before, 417 | .ion-md-arrow-dropleft:before, 418 | .ion-md-arrow-dropleft-circle:before, 419 | .ion-md-arrow-dropright:before, 420 | .ion-md-arrow-dropright-circle:before, 421 | .ion-md-arrow-dropup:before, 422 | .ion-md-arrow-dropup-circle:before, 423 | .ion-md-arrow-forward:before, 424 | .ion-md-arrow-round-back:before, 425 | .ion-md-arrow-round-down:before, 426 | .ion-md-arrow-round-forward:before, 427 | .ion-md-arrow-round-up:before, 428 | .ion-md-arrow-up:before, 429 | .ion-md-at:before, 430 | .ion-md-attach:before, 431 | .ion-md-backspace:before, 432 | .ion-md-barcode:before, 433 | .ion-md-baseball:before, 434 | .ion-md-basket:before, 435 | .ion-md-basketball:before, 436 | .ion-md-battery-charging:before, 437 | .ion-md-battery-dead:before, 438 | .ion-md-battery-full:before, 439 | .ion-md-beaker:before, 440 | .ion-md-bed:before, 441 | .ion-md-beer:before, 442 | .ion-md-bicycle:before, 443 | .ion-md-bluetooth:before, 444 | .ion-md-boat:before, 445 | .ion-md-body:before, 446 | .ion-md-bonfire:before, 447 | .ion-md-book:before, 448 | .ion-md-bookmark:before, 449 | .ion-md-bookmarks:before, 450 | .ion-md-bowtie:before, 451 | .ion-md-briefcase:before, 452 | .ion-md-browsers:before, 453 | .ion-md-brush:before, 454 | .ion-md-bug:before, 455 | .ion-md-build:before, 456 | .ion-md-bulb:before, 457 | .ion-md-bus:before, 458 | .ion-md-business:before, 459 | .ion-md-cafe:before, 460 | .ion-md-calculator:before, 461 | .ion-md-calendar:before, 462 | .ion-md-call:before, 463 | .ion-md-camera:before, 464 | .ion-md-car:before, 465 | .ion-md-card:before, 466 | .ion-md-cart:before, 467 | .ion-md-cash:before, 468 | .ion-md-cellular:before, 469 | .ion-md-chatboxes:before, 470 | .ion-md-chatbubbles:before, 471 | .ion-md-checkbox:before, 472 | .ion-md-checkbox-outline:before, 473 | .ion-md-checkmark:before, 474 | .ion-md-checkmark-circle:before, 475 | .ion-md-checkmark-circle-outline:before, 476 | .ion-md-clipboard:before, 477 | .ion-md-clock:before, 478 | .ion-md-close:before, 479 | .ion-md-close-circle:before, 480 | .ion-md-close-circle-outline:before, 481 | .ion-md-cloud:before, 482 | .ion-md-cloud-circle:before, 483 | .ion-md-cloud-done:before, 484 | .ion-md-cloud-download:before, 485 | .ion-md-cloud-outline:before, 486 | .ion-md-cloud-upload:before, 487 | .ion-md-cloudy:before, 488 | .ion-md-cloudy-night:before, 489 | .ion-md-code:before, 490 | .ion-md-code-download:before, 491 | .ion-md-code-working:before, 492 | .ion-md-cog:before, 493 | .ion-md-color-fill:before, 494 | .ion-md-color-filter:before, 495 | .ion-md-color-palette:before, 496 | .ion-md-color-wand:before, 497 | .ion-md-compass:before, 498 | .ion-md-construct:before, 499 | .ion-md-contact:before, 500 | .ion-md-contacts:before, 501 | .ion-md-contract:before, 502 | .ion-md-contrast:before, 503 | .ion-md-copy:before, 504 | .ion-md-create:before, 505 | .ion-md-crop:before, 506 | .ion-md-cube:before, 507 | .ion-md-cut:before, 508 | .ion-md-desktop:before, 509 | .ion-md-disc:before, 510 | .ion-md-document:before, 511 | .ion-md-done-all:before, 512 | .ion-md-download:before, 513 | .ion-md-easel:before, 514 | .ion-md-egg:before, 515 | .ion-md-exit:before, 516 | .ion-md-expand:before, 517 | .ion-md-eye:before, 518 | .ion-md-eye-off:before, 519 | .ion-md-fastforward:before, 520 | .ion-md-female:before, 521 | .ion-md-filing:before, 522 | .ion-md-film:before, 523 | .ion-md-finger-print:before, 524 | .ion-md-fitness:before, 525 | .ion-md-flag:before, 526 | .ion-md-flame:before, 527 | .ion-md-flash:before, 528 | .ion-md-flash-off:before, 529 | .ion-md-flashlight:before, 530 | .ion-md-flask:before, 531 | .ion-md-flower:before, 532 | .ion-md-folder:before, 533 | .ion-md-folder-open:before, 534 | .ion-md-football:before, 535 | .ion-md-funnel:before, 536 | .ion-md-gift:before, 537 | .ion-md-git-branch:before, 538 | .ion-md-git-commit:before, 539 | .ion-md-git-compare:before, 540 | .ion-md-git-merge:before, 541 | .ion-md-git-network:before, 542 | .ion-md-git-pull-request:before, 543 | .ion-md-glasses:before, 544 | .ion-md-globe:before, 545 | .ion-md-grid:before, 546 | .ion-md-hammer:before, 547 | .ion-md-hand:before, 548 | .ion-md-happy:before, 549 | .ion-md-headset:before, 550 | .ion-md-heart:before, 551 | .ion-md-heart-dislike:before, 552 | .ion-md-heart-empty:before, 553 | .ion-md-heart-half:before, 554 | .ion-md-help:before, 555 | .ion-md-help-buoy:before, 556 | .ion-md-help-circle:before, 557 | .ion-md-help-circle-outline:before, 558 | .ion-md-home:before, 559 | .ion-md-hourglass:before, 560 | .ion-md-ice-cream:before, 561 | .ion-md-image:before, 562 | .ion-md-images:before, 563 | .ion-md-infinite:before, 564 | .ion-md-information:before, 565 | .ion-md-information-circle:before, 566 | .ion-md-information-circle-outline:before, 567 | .ion-md-jet:before, 568 | .ion-md-journal:before, 569 | .ion-md-key:before, 570 | .ion-md-keypad:before, 571 | .ion-md-laptop:before, 572 | .ion-md-leaf:before, 573 | .ion-md-link:before, 574 | .ion-md-list:before, 575 | .ion-md-list-box:before, 576 | .ion-md-locate:before, 577 | .ion-md-lock:before, 578 | .ion-md-log-in:before, 579 | .ion-md-log-out:before, 580 | .ion-md-magnet:before, 581 | .ion-md-mail:before, 582 | .ion-md-mail-open:before, 583 | .ion-md-mail-unread:before, 584 | .ion-md-male:before, 585 | .ion-md-man:before, 586 | .ion-md-map:before, 587 | .ion-md-medal:before, 588 | .ion-md-medical:before, 589 | .ion-md-medkit:before, 590 | .ion-md-megaphone:before, 591 | .ion-md-menu:before, 592 | .ion-md-mic:before, 593 | .ion-md-mic-off:before, 594 | .ion-md-microphone:before, 595 | .ion-md-moon:before, 596 | .ion-md-more:before, 597 | .ion-md-move:before, 598 | .ion-md-musical-note:before, 599 | .ion-md-musical-notes:before, 600 | .ion-md-navigate:before, 601 | .ion-md-notifications:before, 602 | .ion-md-notifications-off:before, 603 | .ion-md-notifications-outline:before, 604 | .ion-md-nuclear:before, 605 | .ion-md-nutrition:before, 606 | .ion-md-open:before, 607 | .ion-md-options:before, 608 | .ion-md-outlet:before, 609 | .ion-md-paper:before, 610 | .ion-md-paper-plane:before, 611 | .ion-md-partly-sunny:before, 612 | .ion-md-pause:before, 613 | .ion-md-paw:before, 614 | .ion-md-people:before, 615 | .ion-md-person:before, 616 | .ion-md-person-add:before, 617 | .ion-md-phone-landscape:before, 618 | .ion-md-phone-portrait:before, 619 | .ion-md-photos:before, 620 | .ion-md-pie:before, 621 | .ion-md-pin:before, 622 | .ion-md-pint:before, 623 | .ion-md-pizza:before, 624 | .ion-md-planet:before, 625 | .ion-md-play:before, 626 | .ion-md-play-circle:before, 627 | .ion-md-podium:before, 628 | .ion-md-power:before, 629 | .ion-md-pricetag:before, 630 | .ion-md-pricetags:before, 631 | .ion-md-print:before, 632 | .ion-md-pulse:before, 633 | .ion-md-qr-scanner:before, 634 | .ion-md-quote:before, 635 | .ion-md-radio:before, 636 | .ion-md-radio-button-off:before, 637 | .ion-md-radio-button-on:before, 638 | .ion-md-rainy:before, 639 | .ion-md-recording:before, 640 | .ion-md-redo:before, 641 | .ion-md-refresh:before, 642 | .ion-md-refresh-circle:before, 643 | .ion-md-remove:before, 644 | .ion-md-remove-circle:before, 645 | .ion-md-remove-circle-outline:before, 646 | .ion-md-reorder:before, 647 | .ion-md-repeat:before, 648 | .ion-md-resize:before, 649 | .ion-md-restaurant:before, 650 | .ion-md-return-left:before, 651 | .ion-md-return-right:before, 652 | .ion-md-reverse-camera:before, 653 | .ion-md-rewind:before, 654 | .ion-md-ribbon:before, 655 | .ion-md-rocket:before, 656 | .ion-md-rose:before, 657 | .ion-md-sad:before, 658 | .ion-md-save:before, 659 | .ion-md-school:before, 660 | .ion-md-search:before, 661 | .ion-md-send:before, 662 | .ion-md-settings:before, 663 | .ion-md-share:before, 664 | .ion-md-share-alt:before, 665 | .ion-md-shirt:before, 666 | .ion-md-shuffle:before, 667 | .ion-md-skip-backward:before, 668 | .ion-md-skip-forward:before, 669 | .ion-md-snow:before, 670 | .ion-md-speedometer:before, 671 | .ion-md-square:before, 672 | .ion-md-square-outline:before, 673 | .ion-md-star:before, 674 | .ion-md-star-half:before, 675 | .ion-md-star-outline:before, 676 | .ion-md-stats:before, 677 | .ion-md-stopwatch:before, 678 | .ion-md-subway:before, 679 | .ion-md-sunny:before, 680 | .ion-md-swap:before, 681 | .ion-md-switch:before, 682 | .ion-md-sync:before, 683 | .ion-md-tablet-landscape:before, 684 | .ion-md-tablet-portrait:before, 685 | .ion-md-tennisball:before, 686 | .ion-md-text:before, 687 | .ion-md-thermometer:before, 688 | .ion-md-thumbs-down:before, 689 | .ion-md-thumbs-up:before, 690 | .ion-md-thunderstorm:before, 691 | .ion-md-time:before, 692 | .ion-md-timer:before, 693 | .ion-md-today:before, 694 | .ion-md-train:before, 695 | .ion-md-transgender:before, 696 | .ion-md-trash:before, 697 | .ion-md-trending-down:before, 698 | .ion-md-trending-up:before, 699 | .ion-md-trophy:before, 700 | .ion-md-tv:before, 701 | .ion-md-umbrella:before, 702 | .ion-md-undo:before, 703 | .ion-md-unlock:before, 704 | .ion-md-videocam:before, 705 | .ion-md-volume-high:before, 706 | .ion-md-volume-low:before, 707 | .ion-md-volume-mute:before, 708 | .ion-md-volume-off:before, 709 | .ion-md-walk:before, 710 | .ion-md-wallet:before, 711 | .ion-md-warning:before, 712 | .ion-md-watch:before, 713 | .ion-md-water:before, 714 | .ion-md-wifi:before, 715 | .ion-md-wine:before, 716 | .ion-md-woman:before { 717 | display: inline-block; 718 | font-family: "Ionicons"; 719 | speak: none; 720 | font-style: normal; 721 | font-weight: normal; 722 | font-variant: normal; 723 | text-transform: none; 724 | text-rendering: auto; 725 | line-height: 1; 726 | -webkit-font-smoothing: antialiased; 727 | -moz-osx-font-smoothing: grayscale; 728 | } 729 | 730 | .ion-ios-add:before { 731 | content: ""; 732 | } 733 | 734 | .ion-ios-add-circle:before { 735 | content: ""; 736 | } 737 | 738 | .ion-ios-add-circle-outline:before { 739 | content: ""; 740 | } 741 | 742 | .ion-ios-airplane:before { 743 | content: ""; 744 | } 745 | 746 | .ion-ios-alarm:before { 747 | content: ""; 748 | } 749 | 750 | .ion-ios-albums:before { 751 | content: ""; 752 | } 753 | 754 | .ion-ios-alert:before { 755 | content: ""; 756 | } 757 | 758 | .ion-ios-american-football:before { 759 | content: ""; 760 | } 761 | 762 | .ion-ios-analytics:before { 763 | content: ""; 764 | } 765 | 766 | .ion-ios-aperture:before { 767 | content: ""; 768 | } 769 | 770 | .ion-ios-apps:before { 771 | content: ""; 772 | } 773 | 774 | .ion-ios-appstore:before { 775 | content: ""; 776 | } 777 | 778 | .ion-ios-archive:before { 779 | content: ""; 780 | } 781 | 782 | .ion-ios-arrow-back:before { 783 | content: ""; 784 | } 785 | 786 | .ion-ios-arrow-down:before { 787 | content: ""; 788 | } 789 | 790 | .ion-ios-arrow-dropdown:before { 791 | content: ""; 792 | } 793 | 794 | .ion-ios-arrow-dropdown-circle:before { 795 | content: ""; 796 | } 797 | 798 | .ion-ios-arrow-dropleft:before { 799 | content: ""; 800 | } 801 | 802 | .ion-ios-arrow-dropleft-circle:before { 803 | content: ""; 804 | } 805 | 806 | .ion-ios-arrow-dropright:before { 807 | content: ""; 808 | } 809 | 810 | .ion-ios-arrow-dropright-circle:before { 811 | content: ""; 812 | } 813 | 814 | .ion-ios-arrow-dropup:before { 815 | content: ""; 816 | } 817 | 818 | .ion-ios-arrow-dropup-circle:before { 819 | content: ""; 820 | } 821 | 822 | .ion-ios-arrow-forward:before { 823 | content: ""; 824 | } 825 | 826 | .ion-ios-arrow-round-back:before { 827 | content: ""; 828 | } 829 | 830 | .ion-ios-arrow-round-down:before { 831 | content: ""; 832 | } 833 | 834 | .ion-ios-arrow-round-forward:before { 835 | content: ""; 836 | } 837 | 838 | .ion-ios-arrow-round-up:before { 839 | content: ""; 840 | } 841 | 842 | .ion-ios-arrow-up:before { 843 | content: ""; 844 | } 845 | 846 | .ion-ios-at:before { 847 | content: ""; 848 | } 849 | 850 | .ion-ios-attach:before { 851 | content: ""; 852 | } 853 | 854 | .ion-ios-backspace:before { 855 | content: ""; 856 | } 857 | 858 | .ion-ios-barcode:before { 859 | content: ""; 860 | } 861 | 862 | .ion-ios-baseball:before { 863 | content: ""; 864 | } 865 | 866 | .ion-ios-basket:before { 867 | content: ""; 868 | } 869 | 870 | .ion-ios-basketball:before { 871 | content: ""; 872 | } 873 | 874 | .ion-ios-battery-charging:before { 875 | content: ""; 876 | } 877 | 878 | .ion-ios-battery-dead:before { 879 | content: ""; 880 | } 881 | 882 | .ion-ios-battery-full:before { 883 | content: ""; 884 | } 885 | 886 | .ion-ios-beaker:before { 887 | content: ""; 888 | } 889 | 890 | .ion-ios-bed:before { 891 | content: ""; 892 | } 893 | 894 | .ion-ios-beer:before { 895 | content: ""; 896 | } 897 | 898 | .ion-ios-bicycle:before { 899 | content: ""; 900 | } 901 | 902 | .ion-ios-bluetooth:before { 903 | content: ""; 904 | } 905 | 906 | .ion-ios-boat:before { 907 | content: ""; 908 | } 909 | 910 | .ion-ios-body:before { 911 | content: ""; 912 | } 913 | 914 | .ion-ios-bonfire:before { 915 | content: ""; 916 | } 917 | 918 | .ion-ios-book:before { 919 | content: ""; 920 | } 921 | 922 | .ion-ios-bookmark:before { 923 | content: ""; 924 | } 925 | 926 | .ion-ios-bookmarks:before { 927 | content: ""; 928 | } 929 | 930 | .ion-ios-bowtie:before { 931 | content: ""; 932 | } 933 | 934 | .ion-ios-briefcase:before { 935 | content: ""; 936 | } 937 | 938 | .ion-ios-browsers:before { 939 | content: ""; 940 | } 941 | 942 | .ion-ios-brush:before { 943 | content: ""; 944 | } 945 | 946 | .ion-ios-bug:before { 947 | content: ""; 948 | } 949 | 950 | .ion-ios-build:before { 951 | content: ""; 952 | } 953 | 954 | .ion-ios-bulb:before { 955 | content: ""; 956 | } 957 | 958 | .ion-ios-bus:before { 959 | content: ""; 960 | } 961 | 962 | .ion-ios-business:before { 963 | content: ""; 964 | } 965 | 966 | .ion-ios-cafe:before { 967 | content: ""; 968 | } 969 | 970 | .ion-ios-calculator:before { 971 | content: ""; 972 | } 973 | 974 | .ion-ios-calendar:before { 975 | content: ""; 976 | } 977 | 978 | .ion-ios-call:before { 979 | content: ""; 980 | } 981 | 982 | .ion-ios-camera:before { 983 | content: ""; 984 | } 985 | 986 | .ion-ios-car:before { 987 | content: ""; 988 | } 989 | 990 | .ion-ios-card:before { 991 | content: ""; 992 | } 993 | 994 | .ion-ios-cart:before { 995 | content: ""; 996 | } 997 | 998 | .ion-ios-cash:before { 999 | content: ""; 1000 | } 1001 | 1002 | .ion-ios-cellular:before { 1003 | content: ""; 1004 | } 1005 | 1006 | .ion-ios-chatboxes:before { 1007 | content: ""; 1008 | } 1009 | 1010 | .ion-ios-chatbubbles:before { 1011 | content: ""; 1012 | } 1013 | 1014 | .ion-ios-checkbox:before { 1015 | content: ""; 1016 | } 1017 | 1018 | .ion-ios-checkbox-outline:before { 1019 | content: ""; 1020 | } 1021 | 1022 | .ion-ios-checkmark:before { 1023 | content: ""; 1024 | } 1025 | 1026 | .ion-ios-checkmark-circle:before { 1027 | content: ""; 1028 | } 1029 | 1030 | .ion-ios-checkmark-circle-outline:before { 1031 | content: ""; 1032 | } 1033 | 1034 | .ion-ios-clipboard:before { 1035 | content: ""; 1036 | } 1037 | 1038 | .ion-ios-clock:before { 1039 | content: ""; 1040 | } 1041 | 1042 | .ion-ios-close:before { 1043 | content: ""; 1044 | } 1045 | 1046 | .ion-ios-close-circle:before { 1047 | content: ""; 1048 | } 1049 | 1050 | .ion-ios-close-circle-outline:before { 1051 | content: ""; 1052 | } 1053 | 1054 | .ion-ios-cloud:before { 1055 | content: ""; 1056 | } 1057 | 1058 | .ion-ios-cloud-circle:before { 1059 | content: ""; 1060 | } 1061 | 1062 | .ion-ios-cloud-done:before { 1063 | content: ""; 1064 | } 1065 | 1066 | .ion-ios-cloud-download:before { 1067 | content: ""; 1068 | } 1069 | 1070 | .ion-ios-cloud-outline:before { 1071 | content: ""; 1072 | } 1073 | 1074 | .ion-ios-cloud-upload:before { 1075 | content: ""; 1076 | } 1077 | 1078 | .ion-ios-cloudy:before { 1079 | content: ""; 1080 | } 1081 | 1082 | .ion-ios-cloudy-night:before { 1083 | content: ""; 1084 | } 1085 | 1086 | .ion-ios-code:before { 1087 | content: ""; 1088 | } 1089 | 1090 | .ion-ios-code-download:before { 1091 | content: ""; 1092 | } 1093 | 1094 | .ion-ios-code-working:before { 1095 | content: ""; 1096 | } 1097 | 1098 | .ion-ios-cog:before { 1099 | content: ""; 1100 | } 1101 | 1102 | .ion-ios-color-fill:before { 1103 | content: ""; 1104 | } 1105 | 1106 | .ion-ios-color-filter:before { 1107 | content: ""; 1108 | } 1109 | 1110 | .ion-ios-color-palette:before { 1111 | content: ""; 1112 | } 1113 | 1114 | .ion-ios-color-wand:before { 1115 | content: ""; 1116 | } 1117 | 1118 | .ion-ios-compass:before { 1119 | content: ""; 1120 | } 1121 | 1122 | .ion-ios-construct:before { 1123 | content: ""; 1124 | } 1125 | 1126 | .ion-ios-contact:before { 1127 | content: ""; 1128 | } 1129 | 1130 | .ion-ios-contacts:before { 1131 | content: ""; 1132 | } 1133 | 1134 | .ion-ios-contract:before { 1135 | content: ""; 1136 | } 1137 | 1138 | .ion-ios-contrast:before { 1139 | content: ""; 1140 | } 1141 | 1142 | .ion-ios-copy:before { 1143 | content: ""; 1144 | } 1145 | 1146 | .ion-ios-create:before { 1147 | content: ""; 1148 | } 1149 | 1150 | .ion-ios-crop:before { 1151 | content: ""; 1152 | } 1153 | 1154 | .ion-ios-cube:before { 1155 | content: ""; 1156 | } 1157 | 1158 | .ion-ios-cut:before { 1159 | content: ""; 1160 | } 1161 | 1162 | .ion-ios-desktop:before { 1163 | content: ""; 1164 | } 1165 | 1166 | .ion-ios-disc:before { 1167 | content: ""; 1168 | } 1169 | 1170 | .ion-ios-document:before { 1171 | content: ""; 1172 | } 1173 | 1174 | .ion-ios-done-all:before { 1175 | content: ""; 1176 | } 1177 | 1178 | .ion-ios-download:before { 1179 | content: ""; 1180 | } 1181 | 1182 | .ion-ios-easel:before { 1183 | content: ""; 1184 | } 1185 | 1186 | .ion-ios-egg:before { 1187 | content: ""; 1188 | } 1189 | 1190 | .ion-ios-exit:before { 1191 | content: ""; 1192 | } 1193 | 1194 | .ion-ios-expand:before { 1195 | content: ""; 1196 | } 1197 | 1198 | .ion-ios-eye:before { 1199 | content: ""; 1200 | } 1201 | 1202 | .ion-ios-eye-off:before { 1203 | content: ""; 1204 | } 1205 | 1206 | .ion-ios-fastforward:before { 1207 | content: ""; 1208 | } 1209 | 1210 | .ion-ios-female:before { 1211 | content: ""; 1212 | } 1213 | 1214 | .ion-ios-filing:before { 1215 | content: ""; 1216 | } 1217 | 1218 | .ion-ios-film:before { 1219 | content: ""; 1220 | } 1221 | 1222 | .ion-ios-finger-print:before { 1223 | content: ""; 1224 | } 1225 | 1226 | .ion-ios-fitness:before { 1227 | content: ""; 1228 | } 1229 | 1230 | .ion-ios-flag:before { 1231 | content: ""; 1232 | } 1233 | 1234 | .ion-ios-flame:before { 1235 | content: ""; 1236 | } 1237 | 1238 | .ion-ios-flash:before { 1239 | content: ""; 1240 | } 1241 | 1242 | .ion-ios-flash-off:before { 1243 | content: ""; 1244 | } 1245 | 1246 | .ion-ios-flashlight:before { 1247 | content: ""; 1248 | } 1249 | 1250 | .ion-ios-flask:before { 1251 | content: ""; 1252 | } 1253 | 1254 | .ion-ios-flower:before { 1255 | content: ""; 1256 | } 1257 | 1258 | .ion-ios-folder:before { 1259 | content: ""; 1260 | } 1261 | 1262 | .ion-ios-folder-open:before { 1263 | content: ""; 1264 | } 1265 | 1266 | .ion-ios-football:before { 1267 | content: ""; 1268 | } 1269 | 1270 | .ion-ios-funnel:before { 1271 | content: ""; 1272 | } 1273 | 1274 | .ion-ios-gift:before { 1275 | content: ""; 1276 | } 1277 | 1278 | .ion-ios-git-branch:before { 1279 | content: ""; 1280 | } 1281 | 1282 | .ion-ios-git-commit:before { 1283 | content: ""; 1284 | } 1285 | 1286 | .ion-ios-git-compare:before { 1287 | content: ""; 1288 | } 1289 | 1290 | .ion-ios-git-merge:before { 1291 | content: ""; 1292 | } 1293 | 1294 | .ion-ios-git-network:before { 1295 | content: ""; 1296 | } 1297 | 1298 | .ion-ios-git-pull-request:before { 1299 | content: ""; 1300 | } 1301 | 1302 | .ion-ios-glasses:before { 1303 | content: ""; 1304 | } 1305 | 1306 | .ion-ios-globe:before { 1307 | content: ""; 1308 | } 1309 | 1310 | .ion-ios-grid:before { 1311 | content: ""; 1312 | } 1313 | 1314 | .ion-ios-hammer:before { 1315 | content: ""; 1316 | } 1317 | 1318 | .ion-ios-hand:before { 1319 | content: ""; 1320 | } 1321 | 1322 | .ion-ios-happy:before { 1323 | content: ""; 1324 | } 1325 | 1326 | .ion-ios-headset:before { 1327 | content: ""; 1328 | } 1329 | 1330 | .ion-ios-heart:before { 1331 | content: ""; 1332 | } 1333 | 1334 | .ion-ios-heart-dislike:before { 1335 | content: ""; 1336 | } 1337 | 1338 | .ion-ios-heart-empty:before { 1339 | content: ""; 1340 | } 1341 | 1342 | .ion-ios-heart-half:before { 1343 | content: ""; 1344 | } 1345 | 1346 | .ion-ios-help:before { 1347 | content: ""; 1348 | } 1349 | 1350 | .ion-ios-help-buoy:before { 1351 | content: ""; 1352 | } 1353 | 1354 | .ion-ios-help-circle:before { 1355 | content: ""; 1356 | } 1357 | 1358 | .ion-ios-help-circle-outline:before { 1359 | content: ""; 1360 | } 1361 | 1362 | .ion-ios-home:before { 1363 | content: ""; 1364 | } 1365 | 1366 | .ion-ios-hourglass:before { 1367 | content: ""; 1368 | } 1369 | 1370 | .ion-ios-ice-cream:before { 1371 | content: ""; 1372 | } 1373 | 1374 | .ion-ios-image:before { 1375 | content: ""; 1376 | } 1377 | 1378 | .ion-ios-images:before { 1379 | content: ""; 1380 | } 1381 | 1382 | .ion-ios-infinite:before { 1383 | content: ""; 1384 | } 1385 | 1386 | .ion-ios-information:before { 1387 | content: ""; 1388 | } 1389 | 1390 | .ion-ios-information-circle:before { 1391 | content: ""; 1392 | } 1393 | 1394 | .ion-ios-information-circle-outline:before { 1395 | content: ""; 1396 | } 1397 | 1398 | .ion-ios-jet:before { 1399 | content: ""; 1400 | } 1401 | 1402 | .ion-ios-journal:before { 1403 | content: ""; 1404 | } 1405 | 1406 | .ion-ios-key:before { 1407 | content: ""; 1408 | } 1409 | 1410 | .ion-ios-keypad:before { 1411 | content: ""; 1412 | } 1413 | 1414 | .ion-ios-laptop:before { 1415 | content: ""; 1416 | } 1417 | 1418 | .ion-ios-leaf:before { 1419 | content: ""; 1420 | } 1421 | 1422 | .ion-ios-link:before { 1423 | content: ""; 1424 | } 1425 | 1426 | .ion-ios-list:before { 1427 | content: ""; 1428 | } 1429 | 1430 | .ion-ios-list-box:before { 1431 | content: ""; 1432 | } 1433 | 1434 | .ion-ios-locate:before { 1435 | content: ""; 1436 | } 1437 | 1438 | .ion-ios-lock:before { 1439 | content: ""; 1440 | } 1441 | 1442 | .ion-ios-log-in:before { 1443 | content: ""; 1444 | } 1445 | 1446 | .ion-ios-log-out:before { 1447 | content: ""; 1448 | } 1449 | 1450 | .ion-ios-magnet:before { 1451 | content: ""; 1452 | } 1453 | 1454 | .ion-ios-mail:before { 1455 | content: ""; 1456 | } 1457 | 1458 | .ion-ios-mail-open:before { 1459 | content: ""; 1460 | } 1461 | 1462 | .ion-ios-mail-unread:before { 1463 | content: ""; 1464 | } 1465 | 1466 | .ion-ios-male:before { 1467 | content: ""; 1468 | } 1469 | 1470 | .ion-ios-man:before { 1471 | content: ""; 1472 | } 1473 | 1474 | .ion-ios-map:before { 1475 | content: ""; 1476 | } 1477 | 1478 | .ion-ios-medal:before { 1479 | content: ""; 1480 | } 1481 | 1482 | .ion-ios-medical:before { 1483 | content: ""; 1484 | } 1485 | 1486 | .ion-ios-medkit:before { 1487 | content: ""; 1488 | } 1489 | 1490 | .ion-ios-megaphone:before { 1491 | content: ""; 1492 | } 1493 | 1494 | .ion-ios-menu:before { 1495 | content: ""; 1496 | } 1497 | 1498 | .ion-ios-mic:before { 1499 | content: ""; 1500 | } 1501 | 1502 | .ion-ios-mic-off:before { 1503 | content: ""; 1504 | } 1505 | 1506 | .ion-ios-microphone:before { 1507 | content: ""; 1508 | } 1509 | 1510 | .ion-ios-moon:before { 1511 | content: ""; 1512 | } 1513 | 1514 | .ion-ios-more:before { 1515 | content: ""; 1516 | } 1517 | 1518 | .ion-ios-move:before { 1519 | content: ""; 1520 | } 1521 | 1522 | .ion-ios-musical-note:before { 1523 | content: ""; 1524 | } 1525 | 1526 | .ion-ios-musical-notes:before { 1527 | content: ""; 1528 | } 1529 | 1530 | .ion-ios-navigate:before { 1531 | content: ""; 1532 | } 1533 | 1534 | .ion-ios-notifications:before { 1535 | content: ""; 1536 | } 1537 | 1538 | .ion-ios-notifications-off:before { 1539 | content: ""; 1540 | } 1541 | 1542 | .ion-ios-notifications-outline:before { 1543 | content: ""; 1544 | } 1545 | 1546 | .ion-ios-nuclear:before { 1547 | content: ""; 1548 | } 1549 | 1550 | .ion-ios-nutrition:before { 1551 | content: ""; 1552 | } 1553 | 1554 | .ion-ios-open:before { 1555 | content: ""; 1556 | } 1557 | 1558 | .ion-ios-options:before { 1559 | content: ""; 1560 | } 1561 | 1562 | .ion-ios-outlet:before { 1563 | content: ""; 1564 | } 1565 | 1566 | .ion-ios-paper:before { 1567 | content: ""; 1568 | } 1569 | 1570 | .ion-ios-paper-plane:before { 1571 | content: ""; 1572 | } 1573 | 1574 | .ion-ios-partly-sunny:before { 1575 | content: ""; 1576 | } 1577 | 1578 | .ion-ios-pause:before { 1579 | content: ""; 1580 | } 1581 | 1582 | .ion-ios-paw:before { 1583 | content: ""; 1584 | } 1585 | 1586 | .ion-ios-people:before { 1587 | content: ""; 1588 | } 1589 | 1590 | .ion-ios-person:before { 1591 | content: ""; 1592 | } 1593 | 1594 | .ion-ios-person-add:before { 1595 | content: ""; 1596 | } 1597 | 1598 | .ion-ios-phone-landscape:before { 1599 | content: ""; 1600 | } 1601 | 1602 | .ion-ios-phone-portrait:before { 1603 | content: ""; 1604 | } 1605 | 1606 | .ion-ios-photos:before { 1607 | content: ""; 1608 | } 1609 | 1610 | .ion-ios-pie:before { 1611 | content: ""; 1612 | } 1613 | 1614 | .ion-ios-pin:before { 1615 | content: ""; 1616 | } 1617 | 1618 | .ion-ios-pint:before { 1619 | content: ""; 1620 | } 1621 | 1622 | .ion-ios-pizza:before { 1623 | content: ""; 1624 | } 1625 | 1626 | .ion-ios-planet:before { 1627 | content: ""; 1628 | } 1629 | 1630 | .ion-ios-play:before { 1631 | content: ""; 1632 | } 1633 | 1634 | .ion-ios-play-circle:before { 1635 | content: ""; 1636 | } 1637 | 1638 | .ion-ios-podium:before { 1639 | content: ""; 1640 | } 1641 | 1642 | .ion-ios-power:before { 1643 | content: ""; 1644 | } 1645 | 1646 | .ion-ios-pricetag:before { 1647 | content: ""; 1648 | } 1649 | 1650 | .ion-ios-pricetags:before { 1651 | content: ""; 1652 | } 1653 | 1654 | .ion-ios-print:before { 1655 | content: ""; 1656 | } 1657 | 1658 | .ion-ios-pulse:before { 1659 | content: ""; 1660 | } 1661 | 1662 | .ion-ios-qr-scanner:before { 1663 | content: ""; 1664 | } 1665 | 1666 | .ion-ios-quote:before { 1667 | content: ""; 1668 | } 1669 | 1670 | .ion-ios-radio:before { 1671 | content: ""; 1672 | } 1673 | 1674 | .ion-ios-radio-button-off:before { 1675 | content: ""; 1676 | } 1677 | 1678 | .ion-ios-radio-button-on:before { 1679 | content: ""; 1680 | } 1681 | 1682 | .ion-ios-rainy:before { 1683 | content: ""; 1684 | } 1685 | 1686 | .ion-ios-recording:before { 1687 | content: ""; 1688 | } 1689 | 1690 | .ion-ios-redo:before { 1691 | content: ""; 1692 | } 1693 | 1694 | .ion-ios-refresh:before { 1695 | content: ""; 1696 | } 1697 | 1698 | .ion-ios-refresh-circle:before { 1699 | content: ""; 1700 | } 1701 | 1702 | .ion-ios-remove:before { 1703 | content: ""; 1704 | } 1705 | 1706 | .ion-ios-remove-circle:before { 1707 | content: ""; 1708 | } 1709 | 1710 | .ion-ios-remove-circle-outline:before { 1711 | content: ""; 1712 | } 1713 | 1714 | .ion-ios-reorder:before { 1715 | content: ""; 1716 | } 1717 | 1718 | .ion-ios-repeat:before { 1719 | content: ""; 1720 | } 1721 | 1722 | .ion-ios-resize:before { 1723 | content: ""; 1724 | } 1725 | 1726 | .ion-ios-restaurant:before { 1727 | content: ""; 1728 | } 1729 | 1730 | .ion-ios-return-left:before { 1731 | content: ""; 1732 | } 1733 | 1734 | .ion-ios-return-right:before { 1735 | content: ""; 1736 | } 1737 | 1738 | .ion-ios-reverse-camera:before { 1739 | content: ""; 1740 | } 1741 | 1742 | .ion-ios-rewind:before { 1743 | content: ""; 1744 | } 1745 | 1746 | .ion-ios-ribbon:before { 1747 | content: ""; 1748 | } 1749 | 1750 | .ion-ios-rocket:before { 1751 | content: ""; 1752 | } 1753 | 1754 | .ion-ios-rose:before { 1755 | content: ""; 1756 | } 1757 | 1758 | .ion-ios-sad:before { 1759 | content: ""; 1760 | } 1761 | 1762 | .ion-ios-save:before { 1763 | content: ""; 1764 | } 1765 | 1766 | .ion-ios-school:before { 1767 | content: ""; 1768 | } 1769 | 1770 | .ion-ios-search:before { 1771 | content: ""; 1772 | } 1773 | 1774 | .ion-ios-send:before { 1775 | content: ""; 1776 | } 1777 | 1778 | .ion-ios-settings:before { 1779 | content: ""; 1780 | } 1781 | 1782 | .ion-ios-share:before { 1783 | content: ""; 1784 | } 1785 | 1786 | .ion-ios-share-alt:before { 1787 | content: ""; 1788 | } 1789 | 1790 | .ion-ios-shirt:before { 1791 | content: ""; 1792 | } 1793 | 1794 | .ion-ios-shuffle:before { 1795 | content: ""; 1796 | } 1797 | 1798 | .ion-ios-skip-backward:before { 1799 | content: ""; 1800 | } 1801 | 1802 | .ion-ios-skip-forward:before { 1803 | content: ""; 1804 | } 1805 | 1806 | .ion-ios-snow:before { 1807 | content: ""; 1808 | } 1809 | 1810 | .ion-ios-speedometer:before { 1811 | content: ""; 1812 | } 1813 | 1814 | .ion-ios-square:before { 1815 | content: ""; 1816 | } 1817 | 1818 | .ion-ios-square-outline:before { 1819 | content: ""; 1820 | } 1821 | 1822 | .ion-ios-star:before { 1823 | content: ""; 1824 | } 1825 | 1826 | .ion-ios-star-half:before { 1827 | content: ""; 1828 | } 1829 | 1830 | .ion-ios-star-outline:before { 1831 | content: ""; 1832 | } 1833 | 1834 | .ion-ios-stats:before { 1835 | content: ""; 1836 | } 1837 | 1838 | .ion-ios-stopwatch:before { 1839 | content: ""; 1840 | } 1841 | 1842 | .ion-ios-subway:before { 1843 | content: ""; 1844 | } 1845 | 1846 | .ion-ios-sunny:before { 1847 | content: ""; 1848 | } 1849 | 1850 | .ion-ios-swap:before { 1851 | content: ""; 1852 | } 1853 | 1854 | .ion-ios-switch:before { 1855 | content: ""; 1856 | } 1857 | 1858 | .ion-ios-sync:before { 1859 | content: ""; 1860 | } 1861 | 1862 | .ion-ios-tablet-landscape:before { 1863 | content: ""; 1864 | } 1865 | 1866 | .ion-ios-tablet-portrait:before { 1867 | content: ""; 1868 | } 1869 | 1870 | .ion-ios-tennisball:before { 1871 | content: ""; 1872 | } 1873 | 1874 | .ion-ios-text:before { 1875 | content: ""; 1876 | } 1877 | 1878 | .ion-ios-thermometer:before { 1879 | content: ""; 1880 | } 1881 | 1882 | .ion-ios-thumbs-down:before { 1883 | content: ""; 1884 | } 1885 | 1886 | .ion-ios-thumbs-up:before { 1887 | content: ""; 1888 | } 1889 | 1890 | .ion-ios-thunderstorm:before { 1891 | content: ""; 1892 | } 1893 | 1894 | .ion-ios-time:before { 1895 | content: ""; 1896 | } 1897 | 1898 | .ion-ios-timer:before { 1899 | content: ""; 1900 | } 1901 | 1902 | .ion-ios-today:before { 1903 | content: ""; 1904 | } 1905 | 1906 | .ion-ios-train:before { 1907 | content: ""; 1908 | } 1909 | 1910 | .ion-ios-transgender:before { 1911 | content: ""; 1912 | } 1913 | 1914 | .ion-ios-trash:before { 1915 | content: ""; 1916 | } 1917 | 1918 | .ion-ios-trending-down:before { 1919 | content: ""; 1920 | } 1921 | 1922 | .ion-ios-trending-up:before { 1923 | content: ""; 1924 | } 1925 | 1926 | .ion-ios-trophy:before { 1927 | content: ""; 1928 | } 1929 | 1930 | .ion-ios-tv:before { 1931 | content: ""; 1932 | } 1933 | 1934 | .ion-ios-umbrella:before { 1935 | content: ""; 1936 | } 1937 | 1938 | .ion-ios-undo:before { 1939 | content: ""; 1940 | } 1941 | 1942 | .ion-ios-unlock:before { 1943 | content: ""; 1944 | } 1945 | 1946 | .ion-ios-videocam:before { 1947 | content: ""; 1948 | } 1949 | 1950 | .ion-ios-volume-high:before { 1951 | content: ""; 1952 | } 1953 | 1954 | .ion-ios-volume-low:before { 1955 | content: ""; 1956 | } 1957 | 1958 | .ion-ios-volume-mute:before { 1959 | content: ""; 1960 | } 1961 | 1962 | .ion-ios-volume-off:before { 1963 | content: ""; 1964 | } 1965 | 1966 | .ion-ios-walk:before { 1967 | content: ""; 1968 | } 1969 | 1970 | .ion-ios-wallet:before { 1971 | content: ""; 1972 | } 1973 | 1974 | .ion-ios-warning:before { 1975 | content: ""; 1976 | } 1977 | 1978 | .ion-ios-watch:before { 1979 | content: ""; 1980 | } 1981 | 1982 | .ion-ios-water:before { 1983 | content: ""; 1984 | } 1985 | 1986 | .ion-ios-wifi:before { 1987 | content: ""; 1988 | } 1989 | 1990 | .ion-ios-wine:before { 1991 | content: ""; 1992 | } 1993 | 1994 | .ion-ios-woman:before { 1995 | content: ""; 1996 | } 1997 | 1998 | .ion-logo-android:before { 1999 | content: ""; 2000 | } 2001 | 2002 | .ion-logo-angular:before { 2003 | content: ""; 2004 | } 2005 | 2006 | .ion-logo-apple:before { 2007 | content: ""; 2008 | } 2009 | 2010 | .ion-logo-bitbucket:before { 2011 | content: ""; 2012 | } 2013 | 2014 | .ion-logo-bitcoin:before { 2015 | content: ""; 2016 | } 2017 | 2018 | .ion-logo-buffer:before { 2019 | content: ""; 2020 | } 2021 | 2022 | .ion-logo-chrome:before { 2023 | content: ""; 2024 | } 2025 | 2026 | .ion-logo-closed-captioning:before { 2027 | content: ""; 2028 | } 2029 | 2030 | .ion-logo-codepen:before { 2031 | content: ""; 2032 | } 2033 | 2034 | .ion-logo-css3:before { 2035 | content: ""; 2036 | } 2037 | 2038 | .ion-logo-designernews:before { 2039 | content: ""; 2040 | } 2041 | 2042 | .ion-logo-dribbble:before { 2043 | content: ""; 2044 | } 2045 | 2046 | .ion-logo-dropbox:before { 2047 | content: ""; 2048 | } 2049 | 2050 | .ion-logo-euro:before { 2051 | content: ""; 2052 | } 2053 | 2054 | .ion-logo-facebook:before { 2055 | content: ""; 2056 | } 2057 | 2058 | .ion-logo-flickr:before { 2059 | content: ""; 2060 | } 2061 | 2062 | .ion-logo-foursquare:before { 2063 | content: ""; 2064 | } 2065 | 2066 | .ion-logo-freebsd-devil:before { 2067 | content: ""; 2068 | } 2069 | 2070 | .ion-logo-game-controller-a:before { 2071 | content: ""; 2072 | } 2073 | 2074 | .ion-logo-game-controller-b:before { 2075 | content: ""; 2076 | } 2077 | 2078 | .ion-logo-github:before { 2079 | content: ""; 2080 | } 2081 | 2082 | .ion-logo-google:before { 2083 | content: ""; 2084 | } 2085 | 2086 | .ion-logo-googleplus:before { 2087 | content: ""; 2088 | } 2089 | 2090 | .ion-logo-hackernews:before { 2091 | content: ""; 2092 | } 2093 | 2094 | .ion-logo-html5:before { 2095 | content: ""; 2096 | } 2097 | 2098 | .ion-logo-instagram:before { 2099 | content: ""; 2100 | } 2101 | 2102 | .ion-logo-ionic:before { 2103 | content: ""; 2104 | } 2105 | 2106 | .ion-logo-ionitron:before { 2107 | content: ""; 2108 | } 2109 | 2110 | .ion-logo-javascript:before { 2111 | content: ""; 2112 | } 2113 | 2114 | .ion-logo-linkedin:before { 2115 | content: ""; 2116 | } 2117 | 2118 | .ion-logo-markdown:before { 2119 | content: ""; 2120 | } 2121 | 2122 | .ion-logo-model-s:before { 2123 | content: ""; 2124 | } 2125 | 2126 | .ion-logo-no-smoking:before { 2127 | content: ""; 2128 | } 2129 | 2130 | .ion-logo-nodejs:before { 2131 | content: ""; 2132 | } 2133 | 2134 | .ion-logo-npm:before { 2135 | content: ""; 2136 | } 2137 | 2138 | .ion-logo-octocat:before { 2139 | content: ""; 2140 | } 2141 | 2142 | .ion-logo-pinterest:before { 2143 | content: ""; 2144 | } 2145 | 2146 | .ion-logo-playstation:before { 2147 | content: ""; 2148 | } 2149 | 2150 | .ion-logo-polymer:before { 2151 | content: ""; 2152 | } 2153 | 2154 | .ion-logo-python:before { 2155 | content: ""; 2156 | } 2157 | 2158 | .ion-logo-reddit:before { 2159 | content: ""; 2160 | } 2161 | 2162 | .ion-logo-rss:before { 2163 | content: ""; 2164 | } 2165 | 2166 | .ion-logo-sass:before { 2167 | content: ""; 2168 | } 2169 | 2170 | .ion-logo-skype:before { 2171 | content: ""; 2172 | } 2173 | 2174 | .ion-logo-slack:before { 2175 | content: ""; 2176 | } 2177 | 2178 | .ion-logo-snapchat:before { 2179 | content: ""; 2180 | } 2181 | 2182 | .ion-logo-steam:before { 2183 | content: ""; 2184 | } 2185 | 2186 | .ion-logo-tumblr:before { 2187 | content: ""; 2188 | } 2189 | 2190 | .ion-logo-tux:before { 2191 | content: ""; 2192 | } 2193 | 2194 | .ion-logo-twitch:before { 2195 | content: ""; 2196 | } 2197 | 2198 | .ion-logo-twitter:before { 2199 | content: ""; 2200 | } 2201 | 2202 | .ion-logo-usd:before { 2203 | content: ""; 2204 | } 2205 | 2206 | .ion-logo-vimeo:before { 2207 | content: ""; 2208 | } 2209 | 2210 | .ion-logo-vk:before { 2211 | content: ""; 2212 | } 2213 | 2214 | .ion-logo-whatsapp:before { 2215 | content: ""; 2216 | } 2217 | 2218 | .ion-logo-windows:before { 2219 | content: ""; 2220 | } 2221 | 2222 | .ion-logo-wordpress:before { 2223 | content: ""; 2224 | } 2225 | 2226 | .ion-logo-xbox:before { 2227 | content: ""; 2228 | } 2229 | 2230 | .ion-logo-xing:before { 2231 | content: ""; 2232 | } 2233 | 2234 | .ion-logo-yahoo:before { 2235 | content: ""; 2236 | } 2237 | 2238 | .ion-logo-yen:before { 2239 | content: ""; 2240 | } 2241 | 2242 | .ion-logo-youtube:before { 2243 | content: ""; 2244 | } 2245 | 2246 | .ion-md-add:before { 2247 | content: ""; 2248 | } 2249 | 2250 | .ion-md-add-circle:before { 2251 | content: ""; 2252 | } 2253 | 2254 | .ion-md-add-circle-outline:before { 2255 | content: ""; 2256 | } 2257 | 2258 | .ion-md-airplane:before { 2259 | content: ""; 2260 | } 2261 | 2262 | .ion-md-alarm:before { 2263 | content: ""; 2264 | } 2265 | 2266 | .ion-md-albums:before { 2267 | content: ""; 2268 | } 2269 | 2270 | .ion-md-alert:before { 2271 | content: ""; 2272 | } 2273 | 2274 | .ion-md-american-football:before { 2275 | content: ""; 2276 | } 2277 | 2278 | .ion-md-analytics:before { 2279 | content: ""; 2280 | } 2281 | 2282 | .ion-md-aperture:before { 2283 | content: ""; 2284 | } 2285 | 2286 | .ion-md-apps:before { 2287 | content: ""; 2288 | } 2289 | 2290 | .ion-md-appstore:before { 2291 | content: ""; 2292 | } 2293 | 2294 | .ion-md-archive:before { 2295 | content: ""; 2296 | } 2297 | 2298 | .ion-md-arrow-back:before { 2299 | content: ""; 2300 | } 2301 | 2302 | .ion-md-arrow-down:before { 2303 | content: ""; 2304 | } 2305 | 2306 | .ion-md-arrow-dropdown:before { 2307 | content: ""; 2308 | } 2309 | 2310 | .ion-md-arrow-dropdown-circle:before { 2311 | content: ""; 2312 | } 2313 | 2314 | .ion-md-arrow-dropleft:before { 2315 | content: ""; 2316 | } 2317 | 2318 | .ion-md-arrow-dropleft-circle:before { 2319 | content: ""; 2320 | } 2321 | 2322 | .ion-md-arrow-dropright:before { 2323 | content: ""; 2324 | } 2325 | 2326 | .ion-md-arrow-dropright-circle:before { 2327 | content: ""; 2328 | } 2329 | 2330 | .ion-md-arrow-dropup:before { 2331 | content: ""; 2332 | } 2333 | 2334 | .ion-md-arrow-dropup-circle:before { 2335 | content: ""; 2336 | } 2337 | 2338 | .ion-md-arrow-forward:before { 2339 | content: ""; 2340 | } 2341 | 2342 | .ion-md-arrow-round-back:before { 2343 | content: ""; 2344 | } 2345 | 2346 | .ion-md-arrow-round-down:before { 2347 | content: ""; 2348 | } 2349 | 2350 | .ion-md-arrow-round-forward:before { 2351 | content: ""; 2352 | } 2353 | 2354 | .ion-md-arrow-round-up:before { 2355 | content: ""; 2356 | } 2357 | 2358 | .ion-md-arrow-up:before { 2359 | content: ""; 2360 | } 2361 | 2362 | .ion-md-at:before { 2363 | content: ""; 2364 | } 2365 | 2366 | .ion-md-attach:before { 2367 | content: ""; 2368 | } 2369 | 2370 | .ion-md-backspace:before { 2371 | content: ""; 2372 | } 2373 | 2374 | .ion-md-barcode:before { 2375 | content: ""; 2376 | } 2377 | 2378 | .ion-md-baseball:before { 2379 | content: ""; 2380 | } 2381 | 2382 | .ion-md-basket:before { 2383 | content: ""; 2384 | } 2385 | 2386 | .ion-md-basketball:before { 2387 | content: ""; 2388 | } 2389 | 2390 | .ion-md-battery-charging:before { 2391 | content: ""; 2392 | } 2393 | 2394 | .ion-md-battery-dead:before { 2395 | content: ""; 2396 | } 2397 | 2398 | .ion-md-battery-full:before { 2399 | content: ""; 2400 | } 2401 | 2402 | .ion-md-beaker:before { 2403 | content: ""; 2404 | } 2405 | 2406 | .ion-md-bed:before { 2407 | content: ""; 2408 | } 2409 | 2410 | .ion-md-beer:before { 2411 | content: ""; 2412 | } 2413 | 2414 | .ion-md-bicycle:before { 2415 | content: ""; 2416 | } 2417 | 2418 | .ion-md-bluetooth:before { 2419 | content: ""; 2420 | } 2421 | 2422 | .ion-md-boat:before { 2423 | content: ""; 2424 | } 2425 | 2426 | .ion-md-body:before { 2427 | content: ""; 2428 | } 2429 | 2430 | .ion-md-bonfire:before { 2431 | content: ""; 2432 | } 2433 | 2434 | .ion-md-book:before { 2435 | content: ""; 2436 | } 2437 | 2438 | .ion-md-bookmark:before { 2439 | content: ""; 2440 | } 2441 | 2442 | .ion-md-bookmarks:before { 2443 | content: ""; 2444 | } 2445 | 2446 | .ion-md-bowtie:before { 2447 | content: ""; 2448 | } 2449 | 2450 | .ion-md-briefcase:before { 2451 | content: ""; 2452 | } 2453 | 2454 | .ion-md-browsers:before { 2455 | content: ""; 2456 | } 2457 | 2458 | .ion-md-brush:before { 2459 | content: ""; 2460 | } 2461 | 2462 | .ion-md-bug:before { 2463 | content: ""; 2464 | } 2465 | 2466 | .ion-md-build:before { 2467 | content: ""; 2468 | } 2469 | 2470 | .ion-md-bulb:before { 2471 | content: ""; 2472 | } 2473 | 2474 | .ion-md-bus:before { 2475 | content: ""; 2476 | } 2477 | 2478 | .ion-md-business:before { 2479 | content: ""; 2480 | } 2481 | 2482 | .ion-md-cafe:before { 2483 | content: ""; 2484 | } 2485 | 2486 | .ion-md-calculator:before { 2487 | content: ""; 2488 | } 2489 | 2490 | .ion-md-calendar:before { 2491 | content: ""; 2492 | } 2493 | 2494 | .ion-md-call:before { 2495 | content: ""; 2496 | } 2497 | 2498 | .ion-md-camera:before { 2499 | content: ""; 2500 | } 2501 | 2502 | .ion-md-car:before { 2503 | content: ""; 2504 | } 2505 | 2506 | .ion-md-card:before { 2507 | content: ""; 2508 | } 2509 | 2510 | .ion-md-cart:before { 2511 | content: ""; 2512 | } 2513 | 2514 | .ion-md-cash:before { 2515 | content: ""; 2516 | } 2517 | 2518 | .ion-md-cellular:before { 2519 | content: ""; 2520 | } 2521 | 2522 | .ion-md-chatboxes:before { 2523 | content: ""; 2524 | } 2525 | 2526 | .ion-md-chatbubbles:before { 2527 | content: ""; 2528 | } 2529 | 2530 | .ion-md-checkbox:before { 2531 | content: ""; 2532 | } 2533 | 2534 | .ion-md-checkbox-outline:before { 2535 | content: ""; 2536 | } 2537 | 2538 | .ion-md-checkmark:before { 2539 | content: ""; 2540 | } 2541 | 2542 | .ion-md-checkmark-circle:before { 2543 | content: ""; 2544 | } 2545 | 2546 | .ion-md-checkmark-circle-outline:before { 2547 | content: ""; 2548 | } 2549 | 2550 | .ion-md-clipboard:before { 2551 | content: ""; 2552 | } 2553 | 2554 | .ion-md-clock:before { 2555 | content: ""; 2556 | } 2557 | 2558 | .ion-md-close:before { 2559 | content: ""; 2560 | } 2561 | 2562 | .ion-md-close-circle:before { 2563 | content: ""; 2564 | } 2565 | 2566 | .ion-md-close-circle-outline:before { 2567 | content: ""; 2568 | } 2569 | 2570 | .ion-md-cloud:before { 2571 | content: ""; 2572 | } 2573 | 2574 | .ion-md-cloud-circle:before { 2575 | content: ""; 2576 | } 2577 | 2578 | .ion-md-cloud-done:before { 2579 | content: ""; 2580 | } 2581 | 2582 | .ion-md-cloud-download:before { 2583 | content: ""; 2584 | } 2585 | 2586 | .ion-md-cloud-outline:before { 2587 | content: ""; 2588 | } 2589 | 2590 | .ion-md-cloud-upload:before { 2591 | content: ""; 2592 | } 2593 | 2594 | .ion-md-cloudy:before { 2595 | content: ""; 2596 | } 2597 | 2598 | .ion-md-cloudy-night:before { 2599 | content: ""; 2600 | } 2601 | 2602 | .ion-md-code:before { 2603 | content: ""; 2604 | } 2605 | 2606 | .ion-md-code-download:before { 2607 | content: ""; 2608 | } 2609 | 2610 | .ion-md-code-working:before { 2611 | content: ""; 2612 | } 2613 | 2614 | .ion-md-cog:before { 2615 | content: ""; 2616 | } 2617 | 2618 | .ion-md-color-fill:before { 2619 | content: ""; 2620 | } 2621 | 2622 | .ion-md-color-filter:before { 2623 | content: ""; 2624 | } 2625 | 2626 | .ion-md-color-palette:before { 2627 | content: ""; 2628 | } 2629 | 2630 | .ion-md-color-wand:before { 2631 | content: ""; 2632 | } 2633 | 2634 | .ion-md-compass:before { 2635 | content: ""; 2636 | } 2637 | 2638 | .ion-md-construct:before { 2639 | content: ""; 2640 | } 2641 | 2642 | .ion-md-contact:before { 2643 | content: ""; 2644 | } 2645 | 2646 | .ion-md-contacts:before { 2647 | content: ""; 2648 | } 2649 | 2650 | .ion-md-contract:before { 2651 | content: ""; 2652 | } 2653 | 2654 | .ion-md-contrast:before { 2655 | content: ""; 2656 | } 2657 | 2658 | .ion-md-copy:before { 2659 | content: ""; 2660 | } 2661 | 2662 | .ion-md-create:before { 2663 | content: ""; 2664 | } 2665 | 2666 | .ion-md-crop:before { 2667 | content: ""; 2668 | } 2669 | 2670 | .ion-md-cube:before { 2671 | content: ""; 2672 | } 2673 | 2674 | .ion-md-cut:before { 2675 | content: ""; 2676 | } 2677 | 2678 | .ion-md-desktop:before { 2679 | content: ""; 2680 | } 2681 | 2682 | .ion-md-disc:before { 2683 | content: ""; 2684 | } 2685 | 2686 | .ion-md-document:before { 2687 | content: ""; 2688 | } 2689 | 2690 | .ion-md-done-all:before { 2691 | content: ""; 2692 | } 2693 | 2694 | .ion-md-download:before { 2695 | content: ""; 2696 | } 2697 | 2698 | .ion-md-easel:before { 2699 | content: ""; 2700 | } 2701 | 2702 | .ion-md-egg:before { 2703 | content: ""; 2704 | } 2705 | 2706 | .ion-md-exit:before { 2707 | content: ""; 2708 | } 2709 | 2710 | .ion-md-expand:before { 2711 | content: ""; 2712 | } 2713 | 2714 | .ion-md-eye:before { 2715 | content: ""; 2716 | } 2717 | 2718 | .ion-md-eye-off:before { 2719 | content: ""; 2720 | } 2721 | 2722 | .ion-md-fastforward:before { 2723 | content: ""; 2724 | } 2725 | 2726 | .ion-md-female:before { 2727 | content: ""; 2728 | } 2729 | 2730 | .ion-md-filing:before { 2731 | content: ""; 2732 | } 2733 | 2734 | .ion-md-film:before { 2735 | content: ""; 2736 | } 2737 | 2738 | .ion-md-finger-print:before { 2739 | content: ""; 2740 | } 2741 | 2742 | .ion-md-fitness:before { 2743 | content: ""; 2744 | } 2745 | 2746 | .ion-md-flag:before { 2747 | content: ""; 2748 | } 2749 | 2750 | .ion-md-flame:before { 2751 | content: ""; 2752 | } 2753 | 2754 | .ion-md-flash:before { 2755 | content: ""; 2756 | } 2757 | 2758 | .ion-md-flash-off:before { 2759 | content: ""; 2760 | } 2761 | 2762 | .ion-md-flashlight:before { 2763 | content: ""; 2764 | } 2765 | 2766 | .ion-md-flask:before { 2767 | content: ""; 2768 | } 2769 | 2770 | .ion-md-flower:before { 2771 | content: ""; 2772 | } 2773 | 2774 | .ion-md-folder:before { 2775 | content: ""; 2776 | } 2777 | 2778 | .ion-md-folder-open:before { 2779 | content: ""; 2780 | } 2781 | 2782 | .ion-md-football:before { 2783 | content: ""; 2784 | } 2785 | 2786 | .ion-md-funnel:before { 2787 | content: ""; 2788 | } 2789 | 2790 | .ion-md-gift:before { 2791 | content: ""; 2792 | } 2793 | 2794 | .ion-md-git-branch:before { 2795 | content: ""; 2796 | } 2797 | 2798 | .ion-md-git-commit:before { 2799 | content: ""; 2800 | } 2801 | 2802 | .ion-md-git-compare:before { 2803 | content: ""; 2804 | } 2805 | 2806 | .ion-md-git-merge:before { 2807 | content: ""; 2808 | } 2809 | 2810 | .ion-md-git-network:before { 2811 | content: ""; 2812 | } 2813 | 2814 | .ion-md-git-pull-request:before { 2815 | content: ""; 2816 | } 2817 | 2818 | .ion-md-glasses:before { 2819 | content: ""; 2820 | } 2821 | 2822 | .ion-md-globe:before { 2823 | content: ""; 2824 | } 2825 | 2826 | .ion-md-grid:before { 2827 | content: ""; 2828 | } 2829 | 2830 | .ion-md-hammer:before { 2831 | content: ""; 2832 | } 2833 | 2834 | .ion-md-hand:before { 2835 | content: ""; 2836 | } 2837 | 2838 | .ion-md-happy:before { 2839 | content: ""; 2840 | } 2841 | 2842 | .ion-md-headset:before { 2843 | content: ""; 2844 | } 2845 | 2846 | .ion-md-heart:before { 2847 | content: ""; 2848 | } 2849 | 2850 | .ion-md-heart-dislike:before { 2851 | content: ""; 2852 | } 2853 | 2854 | .ion-md-heart-empty:before { 2855 | content: ""; 2856 | } 2857 | 2858 | .ion-md-heart-half:before { 2859 | content: ""; 2860 | } 2861 | 2862 | .ion-md-help:before { 2863 | content: ""; 2864 | } 2865 | 2866 | .ion-md-help-buoy:before { 2867 | content: ""; 2868 | } 2869 | 2870 | .ion-md-help-circle:before { 2871 | content: ""; 2872 | } 2873 | 2874 | .ion-md-help-circle-outline:before { 2875 | content: ""; 2876 | } 2877 | 2878 | .ion-md-home:before { 2879 | content: ""; 2880 | } 2881 | 2882 | .ion-md-hourglass:before { 2883 | content: ""; 2884 | } 2885 | 2886 | .ion-md-ice-cream:before { 2887 | content: ""; 2888 | } 2889 | 2890 | .ion-md-image:before { 2891 | content: ""; 2892 | } 2893 | 2894 | .ion-md-images:before { 2895 | content: ""; 2896 | } 2897 | 2898 | .ion-md-infinite:before { 2899 | content: ""; 2900 | } 2901 | 2902 | .ion-md-information:before { 2903 | content: ""; 2904 | } 2905 | 2906 | .ion-md-information-circle:before { 2907 | content: ""; 2908 | } 2909 | 2910 | .ion-md-information-circle-outline:before { 2911 | content: ""; 2912 | } 2913 | 2914 | .ion-md-jet:before { 2915 | content: ""; 2916 | } 2917 | 2918 | .ion-md-journal:before { 2919 | content: ""; 2920 | } 2921 | 2922 | .ion-md-key:before { 2923 | content: ""; 2924 | } 2925 | 2926 | .ion-md-keypad:before { 2927 | content: ""; 2928 | } 2929 | 2930 | .ion-md-laptop:before { 2931 | content: ""; 2932 | } 2933 | 2934 | .ion-md-leaf:before { 2935 | content: ""; 2936 | } 2937 | 2938 | .ion-md-link:before { 2939 | content: ""; 2940 | } 2941 | 2942 | .ion-md-list:before { 2943 | content: ""; 2944 | } 2945 | 2946 | .ion-md-list-box:before { 2947 | content: ""; 2948 | } 2949 | 2950 | .ion-md-locate:before { 2951 | content: ""; 2952 | } 2953 | 2954 | .ion-md-lock:before { 2955 | content: ""; 2956 | } 2957 | 2958 | .ion-md-log-in:before { 2959 | content: ""; 2960 | } 2961 | 2962 | .ion-md-log-out:before { 2963 | content: ""; 2964 | } 2965 | 2966 | .ion-md-magnet:before { 2967 | content: ""; 2968 | } 2969 | 2970 | .ion-md-mail:before { 2971 | content: ""; 2972 | } 2973 | 2974 | .ion-md-mail-open:before { 2975 | content: ""; 2976 | } 2977 | 2978 | .ion-md-mail-unread:before { 2979 | content: ""; 2980 | } 2981 | 2982 | .ion-md-male:before { 2983 | content: ""; 2984 | } 2985 | 2986 | .ion-md-man:before { 2987 | content: ""; 2988 | } 2989 | 2990 | .ion-md-map:before { 2991 | content: ""; 2992 | } 2993 | 2994 | .ion-md-medal:before { 2995 | content: ""; 2996 | } 2997 | 2998 | .ion-md-medical:before { 2999 | content: ""; 3000 | } 3001 | 3002 | .ion-md-medkit:before { 3003 | content: ""; 3004 | } 3005 | 3006 | .ion-md-megaphone:before { 3007 | content: ""; 3008 | } 3009 | 3010 | .ion-md-menu:before { 3011 | content: ""; 3012 | } 3013 | 3014 | .ion-md-mic:before { 3015 | content: ""; 3016 | } 3017 | 3018 | .ion-md-mic-off:before { 3019 | content: ""; 3020 | } 3021 | 3022 | .ion-md-microphone:before { 3023 | content: ""; 3024 | } 3025 | 3026 | .ion-md-moon:before { 3027 | content: ""; 3028 | } 3029 | 3030 | .ion-md-more:before { 3031 | content: ""; 3032 | } 3033 | 3034 | .ion-md-move:before { 3035 | content: ""; 3036 | } 3037 | 3038 | .ion-md-musical-note:before { 3039 | content: ""; 3040 | } 3041 | 3042 | .ion-md-musical-notes:before { 3043 | content: ""; 3044 | } 3045 | 3046 | .ion-md-navigate:before { 3047 | content: ""; 3048 | } 3049 | 3050 | .ion-md-notifications:before { 3051 | content: ""; 3052 | } 3053 | 3054 | .ion-md-notifications-off:before { 3055 | content: ""; 3056 | } 3057 | 3058 | .ion-md-notifications-outline:before { 3059 | content: ""; 3060 | } 3061 | 3062 | .ion-md-nuclear:before { 3063 | content: ""; 3064 | } 3065 | 3066 | .ion-md-nutrition:before { 3067 | content: ""; 3068 | } 3069 | 3070 | .ion-md-open:before { 3071 | content: ""; 3072 | } 3073 | 3074 | .ion-md-options:before { 3075 | content: ""; 3076 | } 3077 | 3078 | .ion-md-outlet:before { 3079 | content: ""; 3080 | } 3081 | 3082 | .ion-md-paper:before { 3083 | content: ""; 3084 | } 3085 | 3086 | .ion-md-paper-plane:before { 3087 | content: ""; 3088 | } 3089 | 3090 | .ion-md-partly-sunny:before { 3091 | content: ""; 3092 | } 3093 | 3094 | .ion-md-pause:before { 3095 | content: ""; 3096 | } 3097 | 3098 | .ion-md-paw:before { 3099 | content: ""; 3100 | } 3101 | 3102 | .ion-md-people:before { 3103 | content: ""; 3104 | } 3105 | 3106 | .ion-md-person:before { 3107 | content: ""; 3108 | } 3109 | 3110 | .ion-md-person-add:before { 3111 | content: ""; 3112 | } 3113 | 3114 | .ion-md-phone-landscape:before { 3115 | content: ""; 3116 | } 3117 | 3118 | .ion-md-phone-portrait:before { 3119 | content: ""; 3120 | } 3121 | 3122 | .ion-md-photos:before { 3123 | content: ""; 3124 | } 3125 | 3126 | .ion-md-pie:before { 3127 | content: ""; 3128 | } 3129 | 3130 | .ion-md-pin:before { 3131 | content: ""; 3132 | } 3133 | 3134 | .ion-md-pint:before { 3135 | content: ""; 3136 | } 3137 | 3138 | .ion-md-pizza:before { 3139 | content: ""; 3140 | } 3141 | 3142 | .ion-md-planet:before { 3143 | content: ""; 3144 | } 3145 | 3146 | .ion-md-play:before { 3147 | content: ""; 3148 | } 3149 | 3150 | .ion-md-play-circle:before { 3151 | content: ""; 3152 | } 3153 | 3154 | .ion-md-podium:before { 3155 | content: ""; 3156 | } 3157 | 3158 | .ion-md-power:before { 3159 | content: ""; 3160 | } 3161 | 3162 | .ion-md-pricetag:before { 3163 | content: ""; 3164 | } 3165 | 3166 | .ion-md-pricetags:before { 3167 | content: ""; 3168 | } 3169 | 3170 | .ion-md-print:before { 3171 | content: ""; 3172 | } 3173 | 3174 | .ion-md-pulse:before { 3175 | content: ""; 3176 | } 3177 | 3178 | .ion-md-qr-scanner:before { 3179 | content: ""; 3180 | } 3181 | 3182 | .ion-md-quote:before { 3183 | content: ""; 3184 | } 3185 | 3186 | .ion-md-radio:before { 3187 | content: ""; 3188 | } 3189 | 3190 | .ion-md-radio-button-off:before { 3191 | content: ""; 3192 | } 3193 | 3194 | .ion-md-radio-button-on:before { 3195 | content: ""; 3196 | } 3197 | 3198 | .ion-md-rainy:before { 3199 | content: ""; 3200 | } 3201 | 3202 | .ion-md-recording:before { 3203 | content: ""; 3204 | } 3205 | 3206 | .ion-md-redo:before { 3207 | content: ""; 3208 | } 3209 | 3210 | .ion-md-refresh:before { 3211 | content: ""; 3212 | } 3213 | 3214 | .ion-md-refresh-circle:before { 3215 | content: ""; 3216 | } 3217 | 3218 | .ion-md-remove:before { 3219 | content: ""; 3220 | } 3221 | 3222 | .ion-md-remove-circle:before { 3223 | content: ""; 3224 | } 3225 | 3226 | .ion-md-remove-circle-outline:before { 3227 | content: ""; 3228 | } 3229 | 3230 | .ion-md-reorder:before { 3231 | content: ""; 3232 | } 3233 | 3234 | .ion-md-repeat:before { 3235 | content: ""; 3236 | } 3237 | 3238 | .ion-md-resize:before { 3239 | content: ""; 3240 | } 3241 | 3242 | .ion-md-restaurant:before { 3243 | content: ""; 3244 | } 3245 | 3246 | .ion-md-return-left:before { 3247 | content: ""; 3248 | } 3249 | 3250 | .ion-md-return-right:before { 3251 | content: ""; 3252 | } 3253 | 3254 | .ion-md-reverse-camera:before { 3255 | content: ""; 3256 | } 3257 | 3258 | .ion-md-rewind:before { 3259 | content: ""; 3260 | } 3261 | 3262 | .ion-md-ribbon:before { 3263 | content: ""; 3264 | } 3265 | 3266 | .ion-md-rocket:before { 3267 | content: ""; 3268 | } 3269 | 3270 | .ion-md-rose:before { 3271 | content: ""; 3272 | } 3273 | 3274 | .ion-md-sad:before { 3275 | content: ""; 3276 | } 3277 | 3278 | .ion-md-save:before { 3279 | content: ""; 3280 | } 3281 | 3282 | .ion-md-school:before { 3283 | content: ""; 3284 | } 3285 | 3286 | .ion-md-search:before { 3287 | content: ""; 3288 | } 3289 | 3290 | .ion-md-send:before { 3291 | content: ""; 3292 | } 3293 | 3294 | .ion-md-settings:before { 3295 | content: ""; 3296 | } 3297 | 3298 | .ion-md-share:before { 3299 | content: ""; 3300 | } 3301 | 3302 | .ion-md-share-alt:before { 3303 | content: ""; 3304 | } 3305 | 3306 | .ion-md-shirt:before { 3307 | content: ""; 3308 | } 3309 | 3310 | .ion-md-shuffle:before { 3311 | content: ""; 3312 | } 3313 | 3314 | .ion-md-skip-backward:before { 3315 | content: ""; 3316 | } 3317 | 3318 | .ion-md-skip-forward:before { 3319 | content: ""; 3320 | } 3321 | 3322 | .ion-md-snow:before { 3323 | content: ""; 3324 | } 3325 | 3326 | .ion-md-speedometer:before { 3327 | content: ""; 3328 | } 3329 | 3330 | .ion-md-square:before { 3331 | content: ""; 3332 | } 3333 | 3334 | .ion-md-square-outline:before { 3335 | content: ""; 3336 | } 3337 | 3338 | .ion-md-star:before { 3339 | content: ""; 3340 | } 3341 | 3342 | .ion-md-star-half:before { 3343 | content: ""; 3344 | } 3345 | 3346 | .ion-md-star-outline:before { 3347 | content: ""; 3348 | } 3349 | 3350 | .ion-md-stats:before { 3351 | content: ""; 3352 | } 3353 | 3354 | .ion-md-stopwatch:before { 3355 | content: ""; 3356 | } 3357 | 3358 | .ion-md-subway:before { 3359 | content: ""; 3360 | } 3361 | 3362 | .ion-md-sunny:before { 3363 | content: ""; 3364 | } 3365 | 3366 | .ion-md-swap:before { 3367 | content: ""; 3368 | } 3369 | 3370 | .ion-md-switch:before { 3371 | content: ""; 3372 | } 3373 | 3374 | .ion-md-sync:before { 3375 | content: ""; 3376 | } 3377 | 3378 | .ion-md-tablet-landscape:before { 3379 | content: ""; 3380 | } 3381 | 3382 | .ion-md-tablet-portrait:before { 3383 | content: ""; 3384 | } 3385 | 3386 | .ion-md-tennisball:before { 3387 | content: ""; 3388 | } 3389 | 3390 | .ion-md-text:before { 3391 | content: ""; 3392 | } 3393 | 3394 | .ion-md-thermometer:before { 3395 | content: ""; 3396 | } 3397 | 3398 | .ion-md-thumbs-down:before { 3399 | content: ""; 3400 | } 3401 | 3402 | .ion-md-thumbs-up:before { 3403 | content: ""; 3404 | } 3405 | 3406 | .ion-md-thunderstorm:before { 3407 | content: ""; 3408 | } 3409 | 3410 | .ion-md-time:before { 3411 | content: ""; 3412 | } 3413 | 3414 | .ion-md-timer:before { 3415 | content: ""; 3416 | } 3417 | 3418 | .ion-md-today:before { 3419 | content: ""; 3420 | } 3421 | 3422 | .ion-md-train:before { 3423 | content: ""; 3424 | } 3425 | 3426 | .ion-md-transgender:before { 3427 | content: ""; 3428 | } 3429 | 3430 | .ion-md-trash:before { 3431 | content: ""; 3432 | } 3433 | 3434 | .ion-md-trending-down:before { 3435 | content: ""; 3436 | } 3437 | 3438 | .ion-md-trending-up:before { 3439 | content: ""; 3440 | } 3441 | 3442 | .ion-md-trophy:before { 3443 | content: ""; 3444 | } 3445 | 3446 | .ion-md-tv:before { 3447 | content: ""; 3448 | } 3449 | 3450 | .ion-md-umbrella:before { 3451 | content: ""; 3452 | } 3453 | 3454 | .ion-md-undo:before { 3455 | content: ""; 3456 | } 3457 | 3458 | .ion-md-unlock:before { 3459 | content: ""; 3460 | } 3461 | 3462 | .ion-md-videocam:before { 3463 | content: ""; 3464 | } 3465 | 3466 | .ion-md-volume-high:before { 3467 | content: ""; 3468 | } 3469 | 3470 | .ion-md-volume-low:before { 3471 | content: ""; 3472 | } 3473 | 3474 | .ion-md-volume-mute:before { 3475 | content: ""; 3476 | } 3477 | 3478 | .ion-md-volume-off:before { 3479 | content: ""; 3480 | } 3481 | 3482 | .ion-md-walk:before { 3483 | content: ""; 3484 | } 3485 | 3486 | .ion-md-wallet:before { 3487 | content: ""; 3488 | } 3489 | 3490 | .ion-md-warning:before { 3491 | content: ""; 3492 | } 3493 | 3494 | .ion-md-watch:before { 3495 | content: ""; 3496 | } 3497 | 3498 | .ion-md-water:before { 3499 | content: ""; 3500 | } 3501 | 3502 | .ion-md-wifi:before { 3503 | content: ""; 3504 | } 3505 | 3506 | .ion-md-wine:before { 3507 | content: ""; 3508 | } 3509 | 3510 | .ion-md-woman:before { 3511 | content: ""; 3512 | } --------------------------------------------------------------------------------