├── .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 | Import PGP key
18 |
19 | Create PGP key
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 |
20 | {!!props.onClose && (
21 |
22 | )}
23 |
{props.header}
24 |
25 | {props.body}
26 | {!!props.message && !!props.message.text && (
27 |
28 | {props.message.text}
29 |
30 | )}
31 |
32 |
33 | {!!props.action &&
{props.action}
}
34 |
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 | props.onImportClick({ keys, passphrase })}>
46 | Import
47 |
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 | setAlgo(event.target.value)}>
40 | {PGP_KEY_ALGOS.map(algo => (
41 |
42 | {algo.toUpperCase()}
43 |
44 | ))}
45 |
46 |
47 | }
48 | action={
49 | props.onCreateClick({ name, passphrase, algo, email })}
51 | >
52 | Create
53 |
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 | {/*
*/}
45 |
46 | {props.id === COMPOSE_CHAT_ID ? '' : initialsise(name)}
47 | {props.isOnline &&
}
48 |
49 |
50 |
51 |
{name}
52 |
{content}
53 |
54 |
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 |
4 |
5 | Ciphora
6 |
7 |
8 |
9 |
10 | A decentralized end-to-end encrypted messaging app.
11 |
12 |
13 |
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 | }
--------------------------------------------------------------------------------