├── .babelrc ├── .editorconfig ├── .eslintrc ├── .gitattributes ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── app ├── api │ ├── .gitkeep │ └── DhtStore.js ├── app.css ├── app.html ├── app.icns ├── app.ico ├── components │ ├── Address.js │ ├── DhtDownload.js │ ├── DhtPublish.js │ ├── DhtSkipList.js │ ├── FastDhtPublish.js │ ├── Following.js │ ├── Main.js │ ├── Settings.js │ ├── SkipList.js │ ├── Timeline.js │ ├── Topbar.js │ └── Tweet.js ├── css │ └── ionicons.min.css ├── fonts │ └── ionicons.woff ├── hot-dev-app.html ├── index.js ├── routes.js ├── stores │ └── index.js └── utils │ └── .gitkeep ├── erb-logo.png ├── main.js ├── package.js ├── package.json ├── server.js ├── test ├── .eslintrc ├── actions │ └── counter.spec.js ├── components │ └── Counter.spec.js ├── containers │ └── CounterPage.spec.js ├── e2e.js ├── example.js ├── reducers │ └── counter.spec.js └── setup.js ├── webpack.config.base.js ├── webpack.config.development.js ├── webpack.config.production.js └── winstaller.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-0", "react"], 3 | "plugins": ["add-module-exports"], 4 | "env": { 5 | "development": { 6 | "presets": ["react-hmre"] 7 | } 8 | } 9 | } -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.{json,js,jsx,html,css}] 11 | indent_style = space 12 | indent_size = 2 13 | 14 | [.eslintrc] 15 | indent_style = space 16 | indent_size = 2 17 | 18 | [*.md] 19 | trim_trailing_whitespace = false 20 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": "airbnb", 4 | "env": { 5 | "browser": true, 6 | "mocha": true, 7 | "node": true 8 | }, 9 | "rules": { 10 | "react/jsx-uses-react": 2, 11 | "react/jsx-uses-vars": 2, 12 | "react/react-in-jsx-scope": 2, 13 | 14 | "no-var": 0, 15 | "vars-on-top": 0, 16 | "comma-dangle": 0, 17 | "no-use-before-define": 0 18 | }, 19 | "plugins": [ 20 | "react" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 27 | node_modules 28 | 29 | # OSX 30 | .DS_Store 31 | 32 | # App packaged 33 | dist 34 | release 35 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - "4" 5 | - "5" 6 | 7 | cache: 8 | directories: 9 | - node_modules 10 | 11 | addons: 12 | apt: 13 | sources: 14 | - ubuntu-toolchain-r-test 15 | packages: 16 | - g++-4.8 17 | 18 | install: 19 | - export CXX="g++-4.8" 20 | - npm install 21 | - "/sbin/start-stop-daemon --start --quiet --pidfile /tmp/custom_xvfb_99.pid --make-pidfile --background --exec /usr/bin/Xvfb -- :99 -ac -screen 0 1280x1024x16" 22 | 23 | before_script: 24 | - export DISPLAY=:99.0 25 | - sh -e /etc/init.d/xvfb start & 26 | - sleep 3 27 | 28 | script: 29 | - npm run lint 30 | - npm run test 31 | - npm run build 32 | - npm run test-e2e 33 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.7.1 (2015.12.27) 2 | 3 | #### Bug fixed 4 | 5 | - **Fixed npm script on windows 10:** #103. 6 | - **history and react-router version bump**: #109, #110. 7 | 8 | #### Improvements 9 | 10 | - **electron 0.36** 11 | 12 | 13 | 14 | # 0.7.0 (2015.12.16) 15 | 16 | #### Bug fixed 17 | 18 | - **Fixed process.env.NODE_ENV variable in webpack:** #74. 19 | - **add missing object-assign**: #76. 20 | - **packaging in npm@3:** #77. 21 | - **compatibility in windows:** #100. 22 | - **disable chrome debugger in production env:** #102. 23 | 24 | #### Improvements 25 | 26 | - **redux** 27 | - **css-modules** 28 | - **upgrade to react-router 1.x** 29 | - **unit tests** 30 | - **e2e tests** 31 | - **travis-ci** 32 | - **upgrade to electron 0.35.x** 33 | - **use es2015** 34 | - **check dev engine for node and npm** 35 | 36 | 37 | # 0.6.5 (2015.11.7) 38 | 39 | #### Improvements 40 | 41 | - **Bump style-loader to 0.13** 42 | - **Bump css-loader to 0.22** 43 | 44 | 45 | # 0.6.4 (2015.10.27) 46 | 47 | #### Improvements 48 | 49 | - **Bump electron-debug to 0.3** 50 | 51 | 52 | # 0.6.3 (2015.10.26) 53 | 54 | #### Improvements 55 | 56 | - **Initialize ExtractTextPlugin once:** #64. 57 | 58 | 59 | # 0.6.2 (2015.10.18) 60 | 61 | #### Bug fixed 62 | 63 | - **Babel plugins production env not be set properly:** #57. 64 | 65 | 66 | # 0.6.1 (2015.10.17) 67 | 68 | #### Improvements 69 | 70 | - **Bump electron to v0.34.0** 71 | 72 | 73 | # 0.6.0 (2015.10.16) 74 | 75 | #### Breaking Changes 76 | 77 | - **From react-hot-loader to react-transform** 78 | 79 | 80 | # 0.5.2 (2015.10.15) 81 | 82 | #### Improvements 83 | 84 | - **Run tests with babel-register:** #29. 85 | 86 | 87 | # 0.5.1 (2015.10.12) 88 | 89 | #### Bug fixed 90 | 91 | - **Fix #51:** use `path.join(__dirname` instead of `./`. 92 | 93 | 94 | # 0.5.0 (2015.10.11) 95 | 96 | #### Improvements 97 | 98 | - **Simplify webpack config** see [#50](https://github.com/chentsulin/electron-react-boilerplate/pull/50). 99 | 100 | #### Breaking Changes 101 | 102 | - **webpack configs** 103 | - **port changed:** changed default port from 2992 to 3000. 104 | - **npm scripts:** remove `start-dev` and `dev-server`. rename `hot-dev-server` to `hot-server`. 105 | 106 | 107 | # 0.4.3 (2015.9.22) 108 | 109 | #### Bug fixed 110 | 111 | - **Fix #45 zeromq crash:** bump version of `electron-prebuilt`. 112 | 113 | 114 | # 0.4.2 (2015.9.15) 115 | 116 | #### Bug fixed 117 | 118 | - **run start-hot breaks chrome refresh(CTRL+R) (#42)**: bump `electron-debug` to `0.2.1` 119 | 120 | 121 | # 0.4.1 (2015.9.11) 122 | 123 | #### Improvements 124 | 125 | - **use electron-prebuilt version for packaging (#33)** 126 | 127 | 128 | # 0.4.0 (2015.9.5) 129 | 130 | #### Improvements 131 | 132 | - **update dependencies** 133 | 134 | 135 | # 0.3.0 (2015.8.31) 136 | 137 | #### Improvements 138 | 139 | - **eslint-config-airbnb** 140 | 141 | 142 | # 0.2.10 (2015.8.27) 143 | 144 | #### Features 145 | 146 | - **custom placeholder icon** 147 | 148 | #### Improvements 149 | 150 | - **electron-renderer as target:** via [webpack-target-electron-renderer](https://github.com/chentsulin/webpack-target-electron-renderer) 151 | 152 | 153 | # 0.2.9 (2015.8.18) 154 | 155 | #### Bug fixed 156 | 157 | - **Fix hot-reload** 158 | 159 | 160 | # 0.2.8 (2015.8.13) 161 | 162 | #### Improvements 163 | 164 | - **bump electron-debug** 165 | - **babelrc** 166 | - **organize webpack scripts** 167 | 168 | 169 | # 0.2.7 (2015.7.9) 170 | 171 | #### Bug fixed 172 | 173 | - **defaultProps:** fix typos. 174 | 175 | 176 | # 0.2.6 (2015.7.3) 177 | 178 | #### Features 179 | 180 | - **menu** 181 | 182 | #### Bug fixed 183 | 184 | - **package.js:** include webpack build. 185 | 186 | 187 | # 0.2.5 (2015.7.1) 188 | 189 | #### Features 190 | 191 | - **NPM Script:** support multi-platform 192 | - **package:** `--all` option 193 | 194 | 195 | # 0.2.4 (2015.6.9) 196 | 197 | #### Bug fixed 198 | 199 | - **Eslint:** typo, [#17](https://github.com/chentsulin/electron-react-boilerplate/issues/17) and improve `.eslintrc` 200 | 201 | 202 | # 0.2.3 (2015.6.3) 203 | 204 | #### Features 205 | 206 | - **Package Version:** use latest release electron version as default 207 | - **Ignore Large peerDependencies** 208 | 209 | #### Bug fixed 210 | 211 | - **Npm Script:** typo, [#6](https://github.com/chentsulin/electron-react-boilerplate/pull/6) 212 | - **Missing css:** [#7](https://github.com/chentsulin/electron-react-boilerplate/pull/7) 213 | 214 | 215 | # 0.2.2 (2015.6.2) 216 | 217 | #### Features 218 | 219 | - **electron-debug** 220 | 221 | #### Bug fixed 222 | 223 | - **Webpack:** add `.json` and `.node` to extensions for imitating node require. 224 | - **Webpack:** set `node_modules` to externals for native module support. 225 | 226 | 227 | # 0.2.1 (2015.5.30) 228 | 229 | #### Bug fixed 230 | 231 | - **Webpack:** #1, change build target to `atom`. 232 | 233 | 234 | # 0.2.0 (2015.5.30) 235 | 236 | #### Features 237 | 238 | - **Ignore:** `test`, `tools`, `release` folder and devDependencies in `package.json`. 239 | - **Support asar** 240 | - **Support icon** 241 | 242 | 243 | # 0.1.0 (2015.5.27) 244 | 245 | #### Features 246 | 247 | - **Webpack:** babel, react-hot, ... 248 | - **Flux:** actions, api, components, containers, stores.. 249 | - **Package:** darwin (osx), linux and win32 (windows) platform. 250 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PeerTweet 2 | 3 | > Decentralized feeds using BitTorrent's DHT. Idea from Arvid and The_8472 "DHT RSS feeds" http://libtorrent.org/dht_rss.html 4 | 5 | ## Screenshot 6 | 7 | ![PeerTweet](http://i.imgur.com/5vYZScZ.png) 8 | 9 | ## Quick links 10 | 11 | * Download the latest binaries from the [releases page](https://github.com/lmatteis/peer-tweet/releases). 12 | * Find and share PeerTweet addresses from the [addresses](https://github.com/lmatteis/peer-tweet/issues/9) issues page. 13 | 14 | 15 | ## What is PeerTweet? 16 | 17 | BitTorrent's DHT is probably one of the most resilient and censorship-resistant networks on the internet. PeerTweet uses this network to allow users to broadcast *tweets* to anyone who is listening. When you start PeerTweet, it generates a hash `@33cwte8iwWn7uhtj9MKCs4q5Ax7B` which is similar to your Twitter username (ex. `@lmatteis`). The difference is that you have entire control over what can be posted because only you own the private key associated with such address. Furthermore, thanks to the DHT, what you post cannot be stopped by any government or institution. 18 | 19 | Once you find other PeerTweet addresses you trust (and are not spam), you can follow them. This configures your client to store this user's tweets and broadcasts them to the DHT every once in a while to keep their feed alive. This cooperation of following accounts, allows for feeds to stay alive in the DHT network. The PeerTweet protocol also publishes your actions such as `I just followed @919c..` or `I just liked @9139..` and `I just retweeted @5789..`. This allows the possibility for new users to find other addresses they can trust; if I trust the user `@6749..` and they're following `@9801..`, then perhaps I can mark `@9801..` as not spam. This idea of publicly tweeting about your actions also allows for powerful future crawling analysis of this social graph. 20 | 21 | 22 | 23 | 24 | 25 | ## How does it work? 26 | 27 | PeerTweet follows most of the implementation guidelines provided by the DHT RSS feed proposal http://libtorrent.org/dht_rss.html. We implemented it on top of the current [BEP44](http://bittorrent.org/beps/bep_0044.html) proposal which provides `get()` and `put()` functionality over the DHT network. This means that, rather than only using the DHT to announce which torrents one is currently downloading, we can use it to also put and get small amounts of data (roughly 1000 bytes). 28 | 29 | PeerTweet differentiates between two types of items: 30 | 31 | 1. **Your feed head**. Which is the only mutable item of your feed, and is what your followers use to download your items and find updates. Your head's hash is what your followers use to know about updates - it's your identity and can be used to let others know about your feed (similar to your `@lmattes` handle). The feed head is roughly structured as follows: 32 | 33 | ``` 34 | { 35 | "d": , 36 | "next": , 37 | "n": , 38 | "a": , 39 | "i": 40 | } 41 | ``` 42 | 43 | 2. **Your feed items**. These are immutable items which contain your actual tweets and are structured: 44 | 45 | ``` 46 | { 47 | "d": , 48 | "next": , 49 | "t": 50 | } 51 | ``` 52 | 53 | ### Skip lists 54 | 55 | The reason items have multiple pointers to other items in the list is to allow for parallel lookups. Our [skip list](https://en.wikipedia.org/wiki/Skip_list) implementation differs from regular implementations and is targeted for network lookups, where each item contains 4 pointers so that when we receive an item, we can issue 4 `get()` requests in parallel to other items in the list. This is crucial for accessing user's feeds in a timely manner because DHT lookups have unpredictable response times. 56 | 57 | 58 | ### Following 59 | 60 | When you follow someone, you're essentially informing your client to download their feed and republish it every so often. The DHT network is not a persistent one, and items quickly drop out of the network after roughly 30 minutes. In order to keep things alive, having many followers is crucial for the uptime of your feed. Otherwise you can still have a server somewhere running 24/7 which keeps your feed alive by republishing items every 30 minutes. 61 | 62 | 63 | # Install 64 | 65 | Install dependencies. 66 | 67 | ```bash 68 | $ npm install 69 | ``` 70 | 71 | ## Installing native modules 72 | 73 | The app comes with some native bindings. I used this code to make it run on my computer: 74 | 75 | Source: https://github.com/atom/electron/blob/master/docs/tutorial/using-native-node-modules.md 76 | 77 | ```bash 78 | npm install --save-dev electron-rebuild 79 | 80 | # Every time you run "npm install", run this 81 | ./node_modules/.bin/electron-rebuild 82 | 83 | # On Windows if you have trouble, try: 84 | .\node_modules\.bin\electron-rebuild.cmd 85 | ``` 86 | 87 | To get `ed25519-supercop` to work on Windows I also had to install `node-gyp` and all the Python2.7 and Visual Studio stuff which node-gyp requires: https://github.com/nodejs/node-gyp 88 | 89 | Then run these commands to build it on Windows: 90 | 91 | ``` 92 | npm install -g node-gyp 93 | cd ./node_modules/ed25519-supercop/ 94 | HOME=~/.electron-gyp node-gyp rebuild --target=0.36.9 --arch=x64 --dist-url=https://atom.io/download/atom-shell 95 | ``` 96 | 97 | 98 | ## Run 99 | 100 | Run this two commands __simultaneously__ in different console tabs. 101 | 102 | ```bash 103 | $ npm run hot-server 104 | $ npm run start-hot 105 | ``` 106 | 107 | *Note: requires a node version >= 4 and an npm version >= 2.* 108 | 109 | #### Toggle Chrome DevTools 110 | 111 | - OS X: Cmd Alt I or F12 112 | - Linux: Ctrl Shift I or F12 113 | - Windows: Ctrl Shift I or F12 114 | 115 | *See [electron-debug](https://github.com/sindresorhus/electron-debug) for more information.* 116 | 117 | #### Toggle Redux DevTools 118 | 119 | - All platforms: Ctrl+H 120 | 121 | *See [redux-devtools-dock-monitor](https://github.com/gaearon/redux-devtools-dock-monitor) for more information.* 122 | 123 | 124 | ## Externals 125 | 126 | If you use any 3rd party libraries which can't be built with webpack, you must list them in your `webpack.config.base.js`: 127 | 128 | ```javascript 129 | externals: [ 130 | // put your node 3rd party libraries which can't be built with webpack here (mysql, mongodb, and so on..) 131 | ] 132 | ``` 133 | 134 | You can find those lines in the file. 135 | 136 | 137 | ## CSS Modules support 138 | 139 | Import css file as [css-modules](https://github.com/css-modules/css-modules) using `.module.css`. 140 | 141 | 142 | ## Package 143 | 144 | ```bash 145 | $ npm run package 146 | ``` 147 | 148 | To package apps for all platforms: 149 | 150 | ```bash 151 | $ npm run package-all 152 | ``` 153 | 154 | #### Options 155 | 156 | - --name, -n: Application name (default: ElectronReact) 157 | - --version, -v: Electron version (default: latest version) 158 | - --asar, -a: [asar](https://github.com/atom/asar) support (default: false) 159 | - --icon, -i: Application icon 160 | - --all: pack for all platforms 161 | 162 | Use `electron-packager` to pack your app with `--all` options for darwin (osx), linux and win32 (windows) platform. After build, you will find them in `release` folder. Otherwise, you will only find one for your os. 163 | 164 | `test`, `tools`, `release` folder and devDependencies in `package.json` will be ignored by default. 165 | 166 | #### Default Ignore modules 167 | 168 | We add some module's `peerDependencies` to ignore option as default for application size reduction. 169 | 170 | - `babel-core` is required by `babel-loader` and its size is ~19 MB 171 | - `node-libs-browser` is required by `webpack` and its size is ~3MB. 172 | 173 | > **Note:** If you want to use any above modules in runtime, for example: `require('babel/register')`, you should move them form `devDependencies` to `dependencies`. 174 | 175 | #### Building windows apps from non-windows platforms 176 | 177 | Please checkout [Building windows apps from non-windows platforms](https://github.com/maxogden/electron-packager#building-windows-apps-from-non-windows-platforms). 178 | 179 | 180 | ## Native-like UI 181 | 182 | If you want to have native-like User Interface (OS X El Capitan and Windows 10), [react-desktop](https://github.com/gabrielbull/react-desktop) may perfect suit for you. 183 | 184 | 185 | ## Maintainers 186 | 187 | This is a fork of the https://github.com/chentsulin/electron-react-boilerplate project. 188 | 189 | ## License 190 | MIT 191 | -------------------------------------------------------------------------------- /app/api/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmatteis/peer-tweet/469ec0a56926d8ad074b15f7042394a85e824fca/app/api/.gitkeep -------------------------------------------------------------------------------- /app/api/DhtStore.js: -------------------------------------------------------------------------------- 1 | var DHT = require('bittorrent-dht') 2 | var ed = require('ed25519-supercop') 3 | var bencode = require('bencode') 4 | var JSONB = require('json-buffer') 5 | var crypto = require('crypto') 6 | const remote = require('electron').remote; 7 | 8 | var BASE58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' 9 | var bs58 = require('base-x')(BASE58) 10 | 11 | console.log('initting DHT') 12 | export var dht = new DHT({ verify: ed.verify }) 13 | 14 | var port = remote.process.env.PORT || 3000 15 | dht.listen(port, function () { 16 | console.log('now listening on '+port+' with default bootstrap nodes') 17 | }) 18 | dht.on('ready', function () { 19 | console.log('WE ARE READY!') 20 | }) 21 | 22 | 23 | if (!localStorage.publicKey || !localStorage.secretKey) { 24 | var keypair = ed.createKeyPair(ed.createSeed()) 25 | localStorage.publicKey = keypair.publicKey.toString('hex') 26 | localStorage.secretKey = keypair.secretKey.toString('hex') 27 | } 28 | 29 | // restore DHT nodes that have been persisted to disk 30 | window.onbeforeunload = (e) => { 31 | localStorage['dht-nodes'] = JSON.stringify(dht.toArray()) 32 | } 33 | if (localStorage['dht-nodes']) { 34 | var dhtNodes = JSON.parse(localStorage['dht-nodes']) 35 | dhtNodes.forEach(function (node) { 36 | dht.addNode(node) 37 | }) 38 | } 39 | 40 | console.log('pub key', localStorage.publicKey) 41 | 42 | var buffPubKey = Buffer(localStorage.publicKey, 'hex') 43 | var buffSecKey = Buffer(localStorage.secretKey, 'hex') 44 | export var opts = { 45 | k: buffPubKey, 46 | seq: -1, 47 | sign: function (buf) { 48 | return ed.sign(buf, buffPubKey, buffSecKey) 49 | } 50 | } 51 | 52 | export function sha1 (buf) { 53 | return crypto.createHash('sha1').update(buf).digest() 54 | } 55 | 56 | export var DhtStore = { 57 | myHash: function() { 58 | var k = Buffer(localStorage.publicKey, 'hex') 59 | return crypto.createHash('sha1').update(k).digest('hex') 60 | }, 61 | hashToBase58: function(hash) { 62 | return bs58.encode(new Buffer(hash, 'hex')) 63 | }, 64 | base58toHash: function(address) { 65 | var out = bs58.decode(address) 66 | //console.log(out.toString()) 67 | // => 0,60,23,110,101,155,234,15,41,163,233,191,120,128,193,18,177,179,27,77,200,38,38,129,135 68 | 69 | // if using Node.js or browserify 70 | return new Buffer(out).toString('hex') 71 | 72 | }, 73 | get: function(hash, callback) { 74 | // check if this hash is in localStorage 75 | if (localStorage[hash]) { 76 | return callback(false, JSONB.parse(localStorage[hash])) 77 | } 78 | 79 | // contact network 80 | dht.get(hash, function(err, res) { 81 | if (err) return console.error(err) 82 | if (res) { 83 | localStorage[hash] = JSONB.stringify(res); 84 | callback(err, res) 85 | } 86 | }) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /app/app.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | width: 100%; 3 | height: 100%; 4 | overflow: hidden; 5 | } 6 | 7 | body > div { 8 | height: 100%; 9 | overflow: hidden; 10 | -webkit-overflow-scrolling: touch; 11 | } 12 | html { 13 | height: 100%; 14 | } 15 | body { 16 | margin:0; 17 | padding:0; 18 | font: 200 14px "Helvetica Neue","Helvetica","Arial",sans-serif; 19 | letter-spacing: 0.4px; 20 | height:100%; 21 | -webkit-font-smoothing: antialiased; 22 | } 23 | #root { 24 | height: 100%; 25 | } 26 | 27 | .flexbox-container { 28 | display:flex; 29 | height: 100%; 30 | } 31 | .flexbox-sidebar { 32 | background-color: #F3E1DB; 33 | width: 82px; 34 | border-right: 1px solid #08090A; 35 | background: #2E3038; 36 | position: relative; 37 | display: flex; 38 | flex-direction: column; 39 | } 40 | .sidebar-item { 41 | z-index: 0; 42 | padding-top:5px; 43 | padding-bottom:5px; 44 | width: 82px; 45 | /*border-radius: 4px;*/ 46 | list-style: none; 47 | text-align: center; 48 | font-weight: normal; 49 | font-size: 32px; 50 | cursor: pointer; 51 | color: #60666C; 52 | color: #696B70; 53 | } 54 | .sidebar-item.top { 55 | padding-top:10px; 56 | padding-bottom:10px; 57 | /*border-bottom: 1px solid #08090A;*/ 58 | font-size: 40px; 59 | } 60 | .sidebar-item.top img { 61 | width: 50px; 62 | height: 50px; 63 | border-radius: 50%; 64 | position: absolute; 65 | left: 50%; 66 | margin-left: -25px; 67 | } 68 | .sidebar-item.space { 69 | cursor: default; 70 | flex-grow:10; 71 | } 72 | .sidebar-item.down { 73 | font-size: 15px; 74 | } 75 | .sidebar-item.down span { 76 | font-size: 10px; 77 | } 78 | .sidebar-item:hover { 79 | color: #D2D3D5; 80 | } 81 | .sidebar-item:last-child { 82 | margin-bottom: 10px; 83 | } 84 | .selected { 85 | color: #3093FF; 86 | color: #D2D3D5; 87 | } 88 | 89 | .flexbox-content { 90 | width: 100%; 91 | overflow-y:auto; 92 | } 93 | .top-bar { 94 | font-weight: 500; 95 | text-align: center; 96 | padding: 20px; 97 | color: #60666C; 98 | position: relative; 99 | } 100 | .top-bar .compose , 101 | .top-bar .search { 102 | position: absolute; 103 | top:10px; 104 | right:10px; 105 | font-size:25px; 106 | cursor: pointer; 107 | } 108 | .top-bar .search:hover, 109 | .top-bar .compose:hover { 110 | color: #3093FF; 111 | } 112 | .top-bar .search { 113 | right: 40px; 114 | } 115 | .tweet { 116 | margin-bottom:10px; 117 | margin-left: 70px; 118 | padding-bottom:10px; 119 | border-bottom: 1px solid #EBEBEB; 120 | position: relative; 121 | min-height: 50px; 122 | padding-right: 10px; 123 | } 124 | .tweet a { 125 | color: #4F7EB6; 126 | text-decoration: none; 127 | } 128 | .tweet:last-child { 129 | border-bottom: 0; 130 | } 131 | .tweet .address { 132 | text-decoration: none; 133 | color: #9D9D9D; 134 | width: 100px; 135 | display: inline-block; 136 | vertical-align: bottom; 137 | overflow: hidden; 138 | text-overflow: ellipsis; 139 | } 140 | .tweet .minutes-ago { 141 | position: absolute; 142 | right:10px; 143 | color: #9D9D9D; 144 | top:0; 145 | } 146 | .tweet .media { 147 | width: 100%; 148 | margin-top: 10px; 149 | border-radius: 2%; 150 | } 151 | 152 | .avatar { 153 | position:absolute; 154 | left:-60px; 155 | top:0; 156 | } 157 | .avatar .default-avatar { 158 | font-size: 50px; 159 | width: 50px; 160 | height: 50px; 161 | text-align: center; 162 | color:#9D9D9D; 163 | } 164 | .avatar img { 165 | width: 50px; 166 | height: 50px; 167 | border-radius: 50%; 168 | } 169 | -------------------------------------------------------------------------------- /app/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PeerTweet 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /app/app.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmatteis/peer-tweet/469ec0a56926d8ad074b15f7042394a85e824fca/app/app.icns -------------------------------------------------------------------------------- /app/app.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmatteis/peer-tweet/469ec0a56926d8ad074b15f7042394a85e824fca/app/app.ico -------------------------------------------------------------------------------- /app/components/Address.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | var bencode = require('bencode') 3 | var JSONB = require('json-buffer') 4 | import { DhtStore, dht, opts} from '../api/DhtStore' 5 | import SkipList from './SkipList' 6 | import Topbar from './Topbar' 7 | 8 | 9 | export default class Address extends Component { 10 | constructor(props) { 11 | super(props) 12 | this.state = { 13 | date: Date.now() 14 | } 15 | } 16 | 17 | render() { 18 | return ( 19 |
20 | this.setState({ date: Date.now() }) } /> 21 |
22 | 23 |
24 |
25 | ); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/components/DhtDownload.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | var bencode = require('bencode') 3 | var JSONB = require('json-buffer') 4 | import { DhtStore, dht, opts} from '../api/DhtStore' 5 | import Tweet from './Tweet' 6 | import SkipList from './SkipList' 7 | 8 | export default class DhtDownload extends Component { 9 | constructor(props) { 10 | super(props) 11 | this.state = { 12 | stack: 0, 13 | timeOfLastRun: Date.now() 14 | } 15 | } 16 | 17 | componentDidMount() { 18 | var run = () => { 19 | if (this.state.stack > 0) { 20 | console.log('still downloading') 21 | // XXX we don't stop the timer if stack is stalled 22 | //return; 23 | } 24 | this.setState({ timeOfLastRun: Date.now() }) 25 | this.download() 26 | } 27 | var setTimeRemaining = () => { 28 | var now = Date.now() 29 | var t = this.props.every - (Date.now() - this.state.timeOfLastRun) 30 | if (t < 0) t = 0 31 | var seconds = Math.floor( (t/1000) % 60 ); 32 | var minutes = Math.floor( (t/1000/60) % 60 ); 33 | 34 | this.setState({ timeRemaining: this.state.timeOfLastRun ? 35 | minutes 36 | //('0' + minutes).slice(-2) + ':' + ('0' + seconds).slice(-2) 37 | : null}) 38 | } 39 | 40 | this.intervalID = setInterval(run, this.props.every || 1800000) // 30 minutes = 1800000 ms 41 | this.updateTimeIntervalID = setInterval(setTimeRemaining, 60000) // every minute 42 | setTimeRemaining() 43 | } 44 | 45 | componentWillUnmount() { 46 | this.intervalID && clearInterval(this.intervalID); 47 | this.intervalID = false; 48 | 49 | this.updateTimeIntervalID && clearInterval(this.updateTimeIntervalID); 50 | this.updateTimeIntervalID = false; 51 | } 52 | 53 | downloadRecursion(hash, isHead) { 54 | var curr = localStorage[hash] 55 | if (curr && !isHead) { // we already have it, go to next 56 | curr = JSONB.parse(curr) 57 | console.log('already have', hash, 'in localstorage') 58 | 59 | if (!curr.v.next) { 60 | this.setState((state) => ({ stack: state.stack - 1 })) 61 | if (this.state.stack == 0) { 62 | console.log('download finished') 63 | } 64 | return; 65 | } 66 | var next = curr.v.next.slice(0, 20) // only first 20 bytes 67 | return this.downloadRecursion(next.toString('hex')) 68 | } 69 | 70 | console.log('dht.get()ing', hash) 71 | dht.get(hash, (err, res) => { 72 | if (!res) { 73 | this.setState((state) => ({ stack: state.stack - 1 })) 74 | return; 75 | } 76 | console.log('got and storing', hash) 77 | localStorage[hash] = JSONB.stringify(res) 78 | if (res.v.next) { 79 | var next = res.v.next.slice(0, 20) // only first 20 bytes 80 | this.downloadRecursion(next.toString('hex')) 81 | } else { 82 | this.setState((state) => ({ stack: state.stack - 1 })) 83 | } 84 | 85 | }) 86 | 87 | /* 88 | dht.put(curr, (err, res) => { 89 | if (err) return console.error(err); 90 | console.log('published', res) 91 | 92 | if (!curr || !curr.v.next) { 93 | console.log('publishing finished') 94 | return; 95 | } 96 | 97 | var next = curr.v.next.slice(0, 20) 98 | curr = JSONB.parse(localStorage[next.toString('hex')]) 99 | console.log('now publishing', curr) 100 | this.downloadRecursion(curr) 101 | 102 | }) 103 | */ 104 | } 105 | 106 | download(e) { 107 | 108 | // start from getting head 109 | var myHash = DhtStore.myHash() 110 | 111 | var heads = [] 112 | // find all followers 113 | if (localStorage.following) { 114 | var following = JSON.parse(localStorage.following) 115 | heads = following 116 | } 117 | // add my head to heads 118 | heads.push(myHash) 119 | 120 | 121 | for(var i=0; i ({ stack: state.stack + 1 })) 125 | this.downloadRecursion(head, true) 126 | } 127 | 128 | // if (curr.v.f && isMyFeed) { // we have a follow hash! branch out! 129 | // console.log('have a follower. branching out') 130 | // this.setState((state) => ({ stack: state.stack + 1 })) 131 | // this.downloadRecursion(curr.v.f.toString('hex'), false, true) 132 | // } 133 | 134 | } 135 | 136 | render() { 137 | // downloads all the feeds i'm following 138 | // including my own feed - it doesn't dht.get() them if already in localStorage 139 | return ( 140 |
0 ? 'Currently downloading... ('+this.state.stack+')' :"Start downlading all the feeds you're following to see if there are any changes. Will download next in " + this.state.timeRemaining +" min(s)"}> 141 |
142 | ); 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /app/components/DhtPublish.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | var bencode = require('bencode') 3 | var JSONB = require('json-buffer') 4 | import { DhtStore, dht, opts} from '../api/DhtStore' 5 | import Tweet from './Tweet' 6 | import SkipList from './SkipList' 7 | 8 | export default class DhtPublish extends Component { 9 | constructor(props) { 10 | super(props) 11 | this.props = props 12 | this.state = { 13 | stack: 0, 14 | tweets: [] 15 | } 16 | 17 | } 18 | 19 | componentDidMount() { 20 | var run = () => { 21 | if (this.state.stack > 0) { 22 | console.log('still publishing') 23 | return; 24 | } 25 | this.publish() 26 | } 27 | 28 | this.intervalID = setInterval(run, this.props.every || 1800000) // 30 minutes = 1800000 ms 29 | 30 | dht.on('ready', () => { 31 | //run() 32 | }) 33 | 34 | } 35 | 36 | componentWillUnmount() { 37 | this.intervalID && clearInterval(this.intervalID); 38 | this.intervalID = false; 39 | } 40 | 41 | getTweet(hash) { 42 | dht.get(hash, (err, res) => { 43 | console.log('got tweet', res) 44 | // add tweet to state 45 | if (res) 46 | this.setState((state) => { tweets: state.tweets.push(res.v.t.toString('utf-8')) }) 47 | 48 | if (res && res.v.next) 49 | this.getTweet(res.v.next) 50 | 51 | }) 52 | } 53 | publishRecursion(curr, isMyFeed) { 54 | 55 | dht.put(curr, (err, res) => { 56 | if (!res || err) { 57 | this.setState((state) => ({ stack: state.stack - 1 })) 58 | return console.error(err); 59 | } 60 | console.log('published', res) 61 | 62 | if (!curr || !curr.v.next) { 63 | this.setState((state) => ({ stack: state.stack - 1 })) 64 | if (this.state.stack == 0) { 65 | console.log('publishing finished') 66 | } 67 | return; 68 | } 69 | 70 | var next = curr.v.next.slice(0, 20) 71 | curr = JSONB.parse(localStorage[next.toString('hex')]) 72 | 73 | if (curr.v.f && isMyFeed) { // we have a follow hash! branch out! 74 | var followerCurr = JSONB.parse(localStorage[curr.v.f.toString('hex')]) 75 | console.log('have a follower. branching out publishing', curr.v.f.toString('hex')) 76 | this.setState((state) => ({ stack: state.stack + 1 })) 77 | this.publishRecursion(followerCurr, false) 78 | //this.downloadRecursion(curr.v.f.toString('hex'), false, true) 79 | } 80 | 81 | console.log('now publishing', next.toString('hex')) 82 | this.publishRecursion(curr, isMyFeed) 83 | 84 | }) 85 | } 86 | 87 | publish(e) { 88 | // start from getting head 89 | var myHash = DhtStore.myHash() 90 | var myFeed = localStorage[myHash] 91 | 92 | if (!myFeed) { 93 | console.log('head not in localStorage') 94 | return; 95 | } 96 | 97 | var head = JSONB.parse(myFeed) 98 | // need to merge this with our default key sign stuff 99 | // should .get() my head to see which is the current .seq 100 | head.sign = opts.sign; 101 | 102 | 103 | // i guess we can start publishing head 104 | console.log('starting to publish head', head) 105 | var curr = head 106 | this.setState((state) => ({ stack: state.stack + 1 })) 107 | this.publishRecursion(curr, true) 108 | 109 | } 110 | 111 | render() { 112 | // this publishes to the DHT, starting from my hash in localStorage 113 | return ( 114 |
0} title="Publish to the DHT, starting from my feed"> 115 | {this.state.stack} 116 |
117 | ); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /app/components/DhtSkipList.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | var bencode = require('bencode') 3 | var JSONB = require('json-buffer') 4 | import { DhtStore, dht, opts} from '../api/DhtStore' 5 | import Tweet from './Tweet' 6 | import SkipList from './SkipList' 7 | import { currentPageStore, tweetsStore } from '../stores' 8 | 9 | export default class DhtSkipList extends Component { 10 | constructor(props) { 11 | super(props) 12 | this.state = { 13 | base58: '', 14 | stack: 0 15 | } 16 | this.gotHashes = {} 17 | this.gettingHashes = {} 18 | } 19 | onBase58Change(e) { 20 | this.setState({ base58: e.target.value }) 21 | } 22 | 23 | start(hash, headHex) { 24 | hash = hash.toString('hex') 25 | if (this.gotHashes[hash]) 26 | return console.log('we already got this hash', hash) 27 | if (this.gettingHashes[hash]) 28 | return console.log('already getting this hash', hash) 29 | 30 | if (Object.keys(this.gettingHashes).length >= 10) 31 | return console.log('we got 10 items!') 32 | 33 | console.log('getting', hash) 34 | this.gettingHashes[hash] = true 35 | this.setState((state) => ({ stack: state.stack + 1 })) 36 | dht.get(hash, (err, res) => { 37 | this.setState((state) => ({ stack: state.stack - 1 })) 38 | if (Object.keys(this.gettingHashes).length == 1 && (err || !res)) { 39 | this.setState({nothingFound: true}) 40 | return 41 | } 42 | // concurrently get all hashes in all .next fields :) and cache the hash in this.state 43 | // hashes are in 20 bytes chunks in .next 44 | if (res) { 45 | this.gotHashes[hash] = res 46 | var action = { 47 | type: 'ADD_TWEET', 48 | tweet: { 49 | key: hash, 50 | hashHex: headHex, 51 | nickname: this.gotHashes[headHex].v.n, 52 | avatar: this.gotHashes[headHex].v.a, 53 | value: res.v 54 | } 55 | } 56 | if (hash != headHex) // it's head, don't add it to the view 57 | tweetsStore.dispatch(action) 58 | /* 59 | if (res.v.t) 60 | this.setState((state) => { tweets: state.tweets.push(res.v.t.toString('utf-8')) }) 61 | else if (res.v.n) // feed name 62 | this.setState((state) => { tweets: state.tweets.push(res.v.n.toString('utf-8')) }) 63 | else if (res.v.f) 64 | this.setState((state) => { tweets: state.tweets.push(res.v.f.toString('hex')) }) 65 | */ 66 | var buff = res.v.next 67 | if (!buff) return console.error('hash doesnt have a next field', hash); 68 | var chunkSize = 20 69 | for (var i = 0; i < buff.length; i = i + chunkSize) { 70 | var slicedHash = buff.slice(i, i + chunkSize) 71 | this.start(slicedHash, headHex) 72 | } 73 | } 74 | }) 75 | } 76 | 77 | skip(e) { 78 | tweetsStore.dispatch({ type: 'RESET'}) 79 | this.gotHashes = {} 80 | this.gettingHashes = {} 81 | var headHex = DhtStore.base58toHash(this.state.base58) 82 | this.setState({nothingFound:false}) 83 | this.start(headHex, headHex) 84 | // start from getting head 85 | //var myHash = DhtStore.myHash() 86 | 87 | 88 | // i guess we can start publishing head 89 | //console.log('starting to download head') 90 | //this.downloadRecursion(myHash, true, false) 91 | 92 | } 93 | 94 | render() { 95 | return ( 96 |
97 | 98 | 99 | {this.state.nothingFound ? 100 |
nothing found
101 | : null} 102 | 103 | { false && 104 |
105 | {this.state.tweets.map(function(tweet) { 106 | return
{tweet}
107 | })} 108 |
109 | } 110 |
111 | ); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /app/components/FastDhtPublish.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | var bencode = require('bencode') 3 | var JSONB = require('json-buffer') 4 | import { DhtStore, dht, opts} from '../api/DhtStore' 5 | import Tweet from './Tweet' 6 | import SkipList from './SkipList' 7 | 8 | export default class FastDhtPublish extends Component { 9 | constructor(props) { 10 | super(props) 11 | this.props = props 12 | this.state = { 13 | stack: 0, 14 | timeOfLastRun: Date.now(), 15 | tweets: [] 16 | } 17 | 18 | this.limit = 11 // this is the top number of items per feed that we want to republish 19 | // 11 because it's top 10 + head 20 | 21 | this.chunkSize = 10 // we can't .put() more than 11 at the same time, so this helps 22 | this.stack = 0 23 | 24 | } 25 | 26 | componentDidMount() { 27 | var run = () => { 28 | if (this.state.stack > 0) { 29 | console.log('still publishing') 30 | // XXX we don't stop the timer if stack is stalled 31 | //return; 32 | } 33 | this.setState({ timeOfLastRun: Date.now() }) 34 | this.publish() 35 | } 36 | var setTimeRemaining = () => { 37 | var now = Date.now() 38 | var t = this.props.every - (Date.now() - this.state.timeOfLastRun) 39 | if (t < 0) t = 0 40 | var seconds = Math.floor( (t/1000) % 60 ); 41 | var minutes = Math.floor( (t/1000/60) % 60 ); 42 | 43 | this.setState({ timeRemaining: this.state.timeOfLastRun ? 44 | minutes 45 | //('0' + minutes).slice(-2) + ':' + ('0' + seconds).slice(-2) 46 | : null}) 47 | } 48 | 49 | this.intervalID = setInterval(run, this.props.every || 1800000) // 30 minutes = 1800000 ms 50 | this.updateTimeIntervalID = setInterval(setTimeRemaining, 60000) // every minute 51 | setTimeRemaining() 52 | 53 | // dht.on('ready', () => { 54 | // //run() 55 | // }) 56 | 57 | } 58 | 59 | componentWillUnmount() { 60 | this.intervalID && clearInterval(this.intervalID); 61 | this.intervalID = false; 62 | 63 | this.updateTimeIntervalID && clearInterval(this.updateTimeIntervalID); 64 | this.updateTimeIntervalID = false; 65 | } 66 | 67 | publishInChunks(toPublish, from, to) { 68 | 69 | for (var i=from; i { 80 | if (err) console.error(err) 81 | this.stack -= 1 82 | 83 | this.setState((state) => ({ stack: state.stack - 1 })) 84 | 85 | if (this.stack == 0) { // finished this chunk, go to next 86 | console.log('starting to publish other chunk', from + this.chunkSize, to + this.chunkSize) 87 | this.publishInChunks(toPublish, from + this.chunkSize, to + this.chunkSize) 88 | } 89 | }) 90 | } 91 | 92 | } 93 | 94 | 95 | publish(e) { 96 | 97 | // start from getting head 98 | var myHash = DhtStore.myHash() 99 | var myHead = localStorage[myHash] 100 | 101 | if (!myHead) { 102 | console.log('head not in localStorage') 103 | return; 104 | } 105 | myHead = JSONB.parse(myHead) 106 | 107 | // get all heads hashes in hex i'm suppose to publish 108 | var headsHashesHex = [] 109 | // find all followers 110 | if (localStorage.following) { 111 | var following = JSON.parse(localStorage.following) 112 | headsHashesHex = following 113 | } 114 | // add my head to heads 115 | headsHashesHex.push(myHash) 116 | 117 | console.log('headsHashesHex', headsHashesHex) 118 | 119 | // now print first 10 tweets in all heads 120 | var toPublish = [] 121 | for (var i=0; i= this.limit) break; 140 | } 141 | } 142 | this.setState({ stack: toPublish.length }) 143 | console.log('toPublish', toPublish) 144 | this.publishInChunks(toPublish, 0, this.chunkSize) 145 | 146 | } 147 | 148 | render() { 149 | // this publishes to the DHT, starting from my hash in localStorage 150 | return ( 151 |
0 ? 'Currently publishing... ('+this.state.stack+')': 'Publish to the DHT, starting from my feed. Will publish next in '+this.state.timeRemaining+' min(s)'}> 152 |
153 | ); 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /app/components/Following.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | var bencode = require('bencode') 3 | var JSONB = require('json-buffer') 4 | import { DhtStore, dht, opts} from '../api/DhtStore' 5 | import SkipList from './SkipList' 6 | import Topbar from './Topbar' 7 | 8 | export default class Timeline extends Component { 9 | constructor(props) { 10 | super(props) 11 | this.state = { 12 | date: Date.now() 13 | } 14 | } 15 | 16 | render() { 17 | return ( 18 |
19 | this.setState({ date: Date.now() }) }/> 20 |
21 | 22 |
23 |
24 | ); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/components/Main.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | var bencode = require('bencode') 3 | var JSONB = require('json-buffer') 4 | import { DhtStore, dht, opts} from '../api/DhtStore' 5 | import Tweet from './Tweet' 6 | import SkipList from './SkipList' 7 | import FastDhtPublish from './FastDhtPublish' 8 | import DhtDownload from './DhtDownload' 9 | import DhtSkipList from './DhtSkipList' 10 | import Timeline from './Timeline' 11 | import Address from './Address' 12 | import Settings from './Settings' 13 | import Following from './Following' 14 | 15 | const remote = require('electron').remote; 16 | import { currentPageStore } from '../stores' 17 | 18 | 19 | export default class Main extends Component { 20 | constructor(props) { 21 | super(props) 22 | this.myHash = DhtStore.myHash() 23 | var myLocalstorageHead = localStorage[this.myHash] 24 | 25 | this.state = { 26 | page: 'timeline', 27 | myHead: { v: {}} 28 | } 29 | if (myLocalstorageHead) 30 | this.state.myHead = JSONB.parse(myLocalstorageHead) 31 | 32 | this.unsubscribe = currentPageStore.subscribe(() => { 33 | this.setState(currentPageStore.getState()) 34 | }) 35 | } 36 | componentWillUnmount() { 37 | this.unsubscribe() 38 | } 39 | 40 | render() { 41 | var content; 42 | if (this.state.page == 'timeline') 43 | content = 44 | else if (this.state.page == 'address') 45 | content =
46 | else if (this.state.page == 'following') 47 | content = 48 | else if (this.state.page == 'settings') 49 | content = 50 | 51 | var myHash = DhtStore.myHash() 52 | return ( 53 |
54 |
55 |
this.setState({ 58 | page: 'address', 59 | hashHex: myHash 60 | })}> 61 | 62 | {this.state.myHead.v.a ? : null} 63 |
64 |
this.setState({ 68 | page: 'timeline', 69 | })}> 70 |
71 |
this.setState({ 75 | page: 'following' 76 | })}>
77 |
this.setState({ page: 'settings'})} 80 | >
81 |
82 | 83 | 84 | 85 |
86 |
87 | {content} 88 |
89 |
90 | ); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /app/components/Settings.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | var bencode = require('bencode') 3 | var JSONB = require('json-buffer') 4 | import { DhtStore, dht, opts} from '../api/DhtStore' 5 | import Tweet from './Tweet' 6 | import SkipList from './SkipList' 7 | import DhtSkipList from './DhtSkipList' 8 | import FastDhtPublish from './FastDhtPublish' 9 | 10 | export default class Settings extends Component { 11 | constructor(props) { 12 | super(props) 13 | this.myHash = DhtStore.myHash() 14 | var myLocalstorageHead = localStorage[this.myHash] 15 | this.state = { 16 | dhtArray : [], 17 | getResponse: null, 18 | putResponse: null, 19 | getRequest: null, 20 | putRequest: null, 21 | myHead: { v: {}} 22 | } 23 | 24 | if (myLocalstorageHead) 25 | this.state.myHead = JSONB.parse(myLocalstorageHead) 26 | 27 | this.dht = dht 28 | } 29 | updateDhtArray(e) { 30 | this.setState({ 31 | dhtArray: this.dht.toArray().length, 32 | }); 33 | }; 34 | 35 | get(e) { 36 | var that = this; 37 | this.dht.get(this.state.getRequest, function(err, res) { 38 | if(err) return console.error(err) 39 | if(res) { 40 | console.log(res) 41 | console.log(JSONB.stringify(res)) 42 | console.log(JSONB.parse(JSONB.stringify(res))) 43 | that.setState({ getResponse : JSONB.stringify(res) }) 44 | } else { 45 | console.log(res) 46 | } 47 | }) 48 | } 49 | 50 | put(e) { 51 | var data = this.state.putRequest; 52 | try { 53 | var obj = JSON.parse(data) 54 | } catch(e) { 55 | return console.error('error parsing JSON') 56 | } 57 | // must be a concatenated list of 3 Buffers 58 | // each being a hash of the next items in the skip list 59 | var a = new Buffer('c3bcf073c950cc07389dbdedb0d404a69726ccbc', 'hex') 60 | var b = new Buffer('c3bcf073c950cc07389dbdedb0d404a69726ccbc', 'hex') 61 | var tot = a.length + b.length 62 | obj.next = Buffer.concat([a, b], tot) 63 | opts.v = obj 64 | opts.seq = opts.seq + 1 65 | 66 | this.dht.put(opts, (errors, hash) => { 67 | console.error('errors=', errors) 68 | console.log('hash=', hash.toString('hex')) 69 | this.setState({ putResponse: hash.toString('hex') }) 70 | 71 | }) 72 | }; 73 | 74 | onGetChange(e) { 75 | this.setState({ getRequest: e.target.value }) 76 | }; 77 | onPutChange(e) { 78 | this.setState({ putRequest: e.target.value }) 79 | }; 80 | 81 | onBlur(attrName, e) { 82 | // read 83 | var myHead = this.state.myHead 84 | if (attrName == 'n') 85 | myHead.v.n = e.target.value 86 | else if (attrName == 'a') 87 | myHead.v.a = e.target.value 88 | else if (attrName == 'i') 89 | myHead.v.i = e.target.value 90 | 91 | // set 92 | localStorage[this.myHash] = JSONB.stringify(myHead) 93 | } 94 | 95 | render() { 96 | return ( 97 |
98 |
99 |

100 | My nickname is . 101 |

102 |

103 | My avatar is . 104 |

105 |

106 | My info is . 107 |

108 | 109 |
110 | 111 | 112 | My hash: {DhtStore.myHash()} 113 |
114 | My hash base58 converted: {DhtStore.hashToBase58(DhtStore.myHash())} 115 |
116 | My hash decoded from base58: {DhtStore.base58toHash(DhtStore.hashToBase58(DhtStore.myHash()))} 117 |
118 | 119 | 120 | 121 | 122 |
123 | 124 | 125 | 126 | 127 |
128 | 129 | 130 |
131 |
132 | 133 | 134 |
135 | 136 | 137 |
138 |
139 | ); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /app/components/SkipList.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | var bencode = require('bencode') 3 | var JSONB = require('json-buffer') 4 | import { DhtStore, dht, opts} from '../api/DhtStore' 5 | import Tweet from './Tweet' 6 | import SkipList from './SkipList' 7 | import { tweetsStore, currentPageStore } from '../stores' 8 | const shell = require('electron').shell; 9 | 10 | export default class Main extends Component { 11 | constructor(props) { 12 | super(props) 13 | this.state = { 14 | tweets: [], 15 | max: 20 16 | } 17 | this.pageLength = 20 18 | 19 | this.unsubscribe = tweetsStore.subscribe(() => { 20 | var s = tweetsStore.getState() 21 | if (s == 'RESET') 22 | return this.setState({ tweets: [] }) 23 | 24 | this.setState((state) => { tweets: state.tweets.push(s) }) 25 | }) 26 | } 27 | componentDidMount() { 28 | var localHashHex = localStorage[this.props.hashHex] 29 | if (localHashHex) { 30 | this.head = JSONB.parse(localHashHex) 31 | this.headHex = this.props.hashHex 32 | } else { 33 | this.headHex = DhtStore.myHash() 34 | if (localStorage[this.headHex]) 35 | this.head = JSONB.parse(localStorage[this.headHex]) 36 | } 37 | this.reiterate(this.headHex, this.props.following) 38 | } 39 | componentWillUnmount() { 40 | this.unsubscribe() 41 | } 42 | 43 | getTweet(hash) { 44 | dht.get(hash, (err, res) => { 45 | console.log('got tweet', res) 46 | // add tweet to state 47 | if (res) 48 | this.setState((state) => { tweets: state.tweets.push(res.v.t.toString('utf-8')) }) 49 | 50 | if (res && res.v.next) 51 | this.getTweet(res.v.next) 52 | 53 | }) 54 | } 55 | 56 | reiterate(hashHex, following) { 57 | this.setState({ tweets: [] }) 58 | var feed = localStorage[hashHex] 59 | 60 | if (!feed) return; 61 | 62 | var tweets = [] 63 | 64 | if (following) { 65 | var foll = localStorage.following 66 | if (foll) { 67 | foll = JSON.parse(foll) 68 | for(var i=0; i bTweetMinutes) return -1; 199 | if(aTweetMinutes < bTweetMinutes) return 1; 200 | return 0; 201 | }) 202 | 203 | var tweetsHtml = [] 204 | var len = this.state.max 205 | if (tweets.length < this.state.max) 206 | len = tweets.length 207 | 208 | for (var i=0; iFollowing @{DhtStore.hashToBase58(tweet.value.f.toString('hex'))} 218 | } 219 | 220 | if (this.props.following) { 221 | text = Unfollow 222 | } 223 | 224 | var urlPattern = /(https?:\/\/(?:www\.|(?!www))[^\s\.]+\.[^\s]{2,}|www\.[^\s]+\.[^\s]{2,})/g 225 | 226 | var url = text.toString().match(urlPattern) 227 | 228 | if (url) { 229 | var parts = text.split(url[0]); 230 | text = [] 231 | text[0] = parts[0]; 232 | (function (text, url) { 233 | text[1] = shell.openExternal(url[0]) }>{url[0]} 234 | })(text, url) 235 | text[2] = parts[1]; 236 | } 237 | 238 | 239 | tweetsHtml.push( 240 |
241 | {tweet.nickname ? {tweet.nickname.toString()} : null} @{DhtStore.hashToBase58(tweet.hashHex)} 242 |
{this.showTime(tweetMinutes)}
243 |
{text}
244 | {/* (url) ? : null*/} 245 |
246 | { tweet.avatar ? :
} 247 |
248 |
249 | ); 250 | } 251 | 252 | return ( 253 |
254 | {tweetsHtml} 255 | {tweets.length < this.state.max ? null 256 | : this.setState((state) => ({ max: state.max + this.pageLength })) 258 | }>Load more tweets } 259 |
260 | ); 261 | } 262 | } 263 | -------------------------------------------------------------------------------- /app/components/Timeline.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | var bencode = require('bencode') 3 | var JSONB = require('json-buffer') 4 | import { DhtStore, dht, opts} from '../api/DhtStore' 5 | import SkipList from './SkipList' 6 | import Topbar from './Topbar' 7 | 8 | export default class Timeline extends Component { 9 | constructor(props) { 10 | super(props) 11 | this.state = { 12 | date: Date.now() 13 | } 14 | } 15 | 16 | render() { 17 | return ( 18 |
19 | this.setState({ date: Date.now() }) }/> 20 |
21 | 22 |
23 |
24 | ); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/components/Topbar.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | var bencode = require('bencode') 3 | var JSONB = require('json-buffer') 4 | import { DhtStore, dht, opts} from '../api/DhtStore' 5 | import SkipList from './SkipList' 6 | import Tweet from './Tweet' 7 | import DhtSkipList from './DhtSkipList' 8 | import { tweetsStore } from '../stores' 9 | 10 | 11 | export default class Topbar extends Component { 12 | constructor(props) { 13 | super(props) 14 | this.state = { 15 | view: 'main' 16 | } 17 | } 18 | 19 | render() { 20 | var title; 21 | if (this.state.view == 'tweet') { 22 | title = 23 | } else if (this.state.view == 'search') { 24 | title = 25 | } else { 26 | title = this.props.title || (this.props.hashHex ? '@'+DhtStore.hashToBase58(this.props.hashHex) : 'Timeline') 27 | } 28 | return ( 29 |
30 |
this.setState({ view: 'tweet' })}>
31 |
this.setState({ view: 'search' })}>
32 | 33 | {title} 34 |
35 | ); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /app/components/Tweet.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | var bencode = require('bencode') 3 | var JSONB = require('json-buffer') 4 | var crypto = require('crypto') 5 | 6 | import { DhtStore, dht, opts, sha1 } from '../api/DhtStore' 7 | 8 | 9 | 10 | export default class Tweet extends Component { 11 | constructor(props) { 12 | super(props) 13 | this.state = { 14 | tweet: '' 15 | } 16 | } 17 | componentDidMount() { 18 | 19 | } 20 | 21 | onTweetChange(e) { 22 | this.setState({ tweet: e.target.value }) 23 | } 24 | 25 | findNext(head) { 26 | // it has to be 1 hop, 2 hops, 4 hops and 8 hops away 27 | var arr = [] 28 | var curr = head 29 | while (curr.v.next && arr.length <= 8) { 30 | // curr.next is a buffer of many bytes, only get the first 20 31 | var next = curr.v.next.slice(0, 20) 32 | arr.push(next) 33 | curr = JSONB.parse(localStorage[next.toString('hex')]) 34 | } 35 | // return max 4 items arrays 36 | var retArr = [] 37 | if (arr[0]) 38 | retArr.push(arr[0]) // 1 hop away 39 | if (arr[1]) 40 | retArr.push(arr[1]) // 2 hops 41 | if (arr[3]) 42 | retArr.push(arr[3]) // 4 hops 43 | if (arr[7]) 44 | retArr.push(arr[7]) // 8 hops 45 | 46 | var tot = 0 47 | for (var i=0; i { // published head 151 | if (err) return console.error(err); 152 | dht.put(iopts, (err, res) => { // published tweet 153 | if (err) return console.error(err); 154 | }) 155 | }) 156 | this.props.onTweetOrFollow() 157 | 158 | } 159 | 160 | tweetDHT() { 161 | // first get my hash to get the seq number 162 | var myHash = DhtStore.myHash() 163 | dht.get(myHash, (err, res) => { 164 | console.log('got my hash', res) 165 | if (!res) { // either it's first time posting or head expired from DHT 166 | 167 | // create first immutable tweet with no "next" 168 | // and have head point to it 169 | dht.put({ v: { t: this.state.tweet } }, (err, hash) => { 170 | console.log('put immutabile tweet', hash) 171 | 172 | // write head, and point next to this hash 173 | // must be a concatenated list of 3 Buffers 174 | // each being a hash of the next items in the skip list 175 | opts.v = { 176 | n: '@lmatteis', 177 | next: hash 178 | } 179 | opts.seq = 0 // it's the first tweet 180 | dht.put(opts, (err, res) => { 181 | console.log('put mutable head', res) 182 | 183 | }) 184 | 185 | }) 186 | } else { // we have already a head 187 | // still put the tweet, but now use the head's next value 188 | dht.put({ v: { t: this.state.tweet, next: res.v.next } }, (err, hash) => { 189 | console.log('put immutabile tweet', hash) 190 | 191 | // write head, and point next to this hash 192 | // must be a concatenated list of 3 Buffers 193 | // each being a hash of the next items in the skip list 194 | opts.v = { 195 | n: '@lmatteis', 196 | next: hash 197 | } 198 | opts.seq = res.seq + 1 199 | dht.put(opts, (err, res) => { 200 | console.log('put mutable head', res) 201 | 202 | }) 203 | 204 | }) 205 | 206 | } 207 | 208 | 209 | // create the immutable tweet 210 | }) 211 | 212 | } 213 | 214 | render() { 215 | return ( 216 |
217 | 218 | 219 | 220 |
221 | ); 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /app/css/ionicons.min.css: -------------------------------------------------------------------------------- 1 | @charset "UTF-8";/*! 2 | Ionicons, v2.0.0 3 | Created by Ben Sperry for the Ionic Framework, http://ionicons.com/ 4 | https://twitter.com/benjsperry https://twitter.com/ionicframework 5 | MIT License: https://github.com/driftyco/ionicons 6 | 7 | Android-style icons originally built by Google’s 8 | Material Design Icons: https://github.com/google/material-design-icons 9 | used under CC BY http://creativecommons.org/licenses/by/4.0/ 10 | Modified icons to fit ionicon’s grid from original. 11 | */@font-face{font-family:"Ionicons";src:url("../fonts/ionicons.woff?v=2.0.0") format("woff");font-weight:normal;font-style:normal}.ion,.ionicons,.ion-alert:before,.ion-alert-circled:before,.ion-android-add:before,.ion-android-add-circle:before,.ion-android-alarm-clock:before,.ion-android-alert:before,.ion-android-apps:before,.ion-android-archive:before,.ion-android-arrow-back:before,.ion-android-arrow-down:before,.ion-android-arrow-dropdown:before,.ion-android-arrow-dropdown-circle:before,.ion-android-arrow-dropleft:before,.ion-android-arrow-dropleft-circle:before,.ion-android-arrow-dropright:before,.ion-android-arrow-dropright-circle:before,.ion-android-arrow-dropup:before,.ion-android-arrow-dropup-circle:before,.ion-android-arrow-forward:before,.ion-android-arrow-up:before,.ion-android-attach:before,.ion-android-bar:before,.ion-android-bicycle:before,.ion-android-boat:before,.ion-android-bookmark:before,.ion-android-bulb:before,.ion-android-bus:before,.ion-android-calendar:before,.ion-android-call:before,.ion-android-camera:before,.ion-android-cancel:before,.ion-android-car:before,.ion-android-cart:before,.ion-android-chat:before,.ion-android-checkbox:before,.ion-android-checkbox-blank:before,.ion-android-checkbox-outline:before,.ion-android-checkbox-outline-blank:before,.ion-android-checkmark-circle:before,.ion-android-clipboard:before,.ion-android-close:before,.ion-android-cloud:before,.ion-android-cloud-circle:before,.ion-android-cloud-done:before,.ion-android-cloud-outline:before,.ion-android-color-palette:before,.ion-android-compass:before,.ion-android-contact:before,.ion-android-contacts:before,.ion-android-contract:before,.ion-android-create:before,.ion-android-delete:before,.ion-android-desktop:before,.ion-android-document:before,.ion-android-done:before,.ion-android-done-all:before,.ion-android-download:before,.ion-android-drafts:before,.ion-android-exit:before,.ion-android-expand:before,.ion-android-favorite:before,.ion-android-favorite-outline:before,.ion-android-film:before,.ion-android-folder:before,.ion-android-folder-open:before,.ion-android-funnel:before,.ion-android-globe:before,.ion-android-hand:before,.ion-android-hangout:before,.ion-android-happy:before,.ion-android-home:before,.ion-android-image:before,.ion-android-laptop:before,.ion-android-list:before,.ion-android-locate:before,.ion-android-lock:before,.ion-android-mail:before,.ion-android-map:before,.ion-android-menu:before,.ion-android-microphone:before,.ion-android-microphone-off:before,.ion-android-more-horizontal:before,.ion-android-more-vertical:before,.ion-android-navigate:before,.ion-android-notifications:before,.ion-android-notifications-none:before,.ion-android-notifications-off:before,.ion-android-open:before,.ion-android-options:before,.ion-android-people:before,.ion-android-person:before,.ion-android-person-add:before,.ion-android-phone-landscape:before,.ion-android-phone-portrait:before,.ion-android-pin:before,.ion-android-plane:before,.ion-android-playstore:before,.ion-android-print:before,.ion-android-radio-button-off:before,.ion-android-radio-button-on:before,.ion-android-refresh:before,.ion-android-remove:before,.ion-android-remove-circle:before,.ion-android-restaurant:before,.ion-android-sad:before,.ion-android-search:before,.ion-android-send:before,.ion-android-settings:before,.ion-android-share:before,.ion-android-share-alt:before,.ion-android-star:before,.ion-android-star-half:before,.ion-android-star-outline:before,.ion-android-stopwatch:before,.ion-android-subway:before,.ion-android-sunny:before,.ion-android-sync:before,.ion-android-textsms:before,.ion-android-time:before,.ion-android-train:before,.ion-android-unlock:before,.ion-android-upload:before,.ion-android-volume-down:before,.ion-android-volume-mute:before,.ion-android-volume-off:before,.ion-android-volume-up:before,.ion-android-walk:before,.ion-android-warning:before,.ion-android-watch:before,.ion-android-wifi:before,.ion-aperture:before,.ion-archive:before,.ion-arrow-down-a:before,.ion-arrow-down-b:before,.ion-arrow-down-c:before,.ion-arrow-expand:before,.ion-arrow-graph-down-left:before,.ion-arrow-graph-down-right:before,.ion-arrow-graph-up-left:before,.ion-arrow-graph-up-right:before,.ion-arrow-left-a:before,.ion-arrow-left-b:before,.ion-arrow-left-c:before,.ion-arrow-move:before,.ion-arrow-resize:before,.ion-arrow-return-left:before,.ion-arrow-return-right:before,.ion-arrow-right-a:before,.ion-arrow-right-b:before,.ion-arrow-right-c:before,.ion-arrow-shrink:before,.ion-arrow-swap:before,.ion-arrow-up-a:before,.ion-arrow-up-b:before,.ion-arrow-up-c:before,.ion-asterisk:before,.ion-at:before,.ion-backspace:before,.ion-backspace-outline:before,.ion-bag:before,.ion-battery-charging:before,.ion-battery-empty:before,.ion-battery-full:before,.ion-battery-half:before,.ion-battery-low:before,.ion-beaker:before,.ion-beer:before,.ion-bluetooth:before,.ion-bonfire:before,.ion-bookmark:before,.ion-bowtie:before,.ion-briefcase:before,.ion-bug:before,.ion-calculator:before,.ion-calendar:before,.ion-camera:before,.ion-card:before,.ion-cash:before,.ion-chatbox:before,.ion-chatbox-working:before,.ion-chatboxes:before,.ion-chatbubble:before,.ion-chatbubble-working:before,.ion-chatbubbles:before,.ion-checkmark:before,.ion-checkmark-circled:before,.ion-checkmark-round:before,.ion-chevron-down:before,.ion-chevron-left:before,.ion-chevron-right:before,.ion-chevron-up:before,.ion-clipboard:before,.ion-clock:before,.ion-close:before,.ion-close-circled:before,.ion-close-round:before,.ion-closed-captioning:before,.ion-cloud:before,.ion-code:before,.ion-code-download:before,.ion-code-working:before,.ion-coffee:before,.ion-compass:before,.ion-compose:before,.ion-connection-bars:before,.ion-contrast:before,.ion-crop:before,.ion-cube:before,.ion-disc:before,.ion-document:before,.ion-document-text:before,.ion-drag:before,.ion-earth:before,.ion-easel:before,.ion-edit:before,.ion-egg:before,.ion-eject:before,.ion-email:before,.ion-email-unread:before,.ion-erlenmeyer-flask:before,.ion-erlenmeyer-flask-bubbles:before,.ion-eye:before,.ion-eye-disabled:before,.ion-female:before,.ion-filing:before,.ion-film-marker:before,.ion-fireball:before,.ion-flag:before,.ion-flame:before,.ion-flash:before,.ion-flash-off:before,.ion-folder:before,.ion-fork:before,.ion-fork-repo:before,.ion-forward:before,.ion-funnel:before,.ion-gear-a:before,.ion-gear-b:before,.ion-grid:before,.ion-hammer:before,.ion-happy:before,.ion-happy-outline:before,.ion-headphone:before,.ion-heart:before,.ion-heart-broken:before,.ion-help:before,.ion-help-buoy:before,.ion-help-circled:before,.ion-home:before,.ion-icecream:before,.ion-image:before,.ion-images:before,.ion-information:before,.ion-information-circled:before,.ion-ionic:before,.ion-ios-alarm:before,.ion-ios-alarm-outline:before,.ion-ios-albums:before,.ion-ios-albums-outline:before,.ion-ios-americanfootball:before,.ion-ios-americanfootball-outline:before,.ion-ios-analytics:before,.ion-ios-analytics-outline:before,.ion-ios-arrow-back:before,.ion-ios-arrow-down:before,.ion-ios-arrow-forward:before,.ion-ios-arrow-left:before,.ion-ios-arrow-right:before,.ion-ios-arrow-thin-down:before,.ion-ios-arrow-thin-left:before,.ion-ios-arrow-thin-right:before,.ion-ios-arrow-thin-up:before,.ion-ios-arrow-up:before,.ion-ios-at:before,.ion-ios-at-outline:before,.ion-ios-barcode:before,.ion-ios-barcode-outline:before,.ion-ios-baseball:before,.ion-ios-baseball-outline:before,.ion-ios-basketball:before,.ion-ios-basketball-outline:before,.ion-ios-bell:before,.ion-ios-bell-outline:before,.ion-ios-body:before,.ion-ios-body-outline:before,.ion-ios-bolt:before,.ion-ios-bolt-outline:before,.ion-ios-book:before,.ion-ios-book-outline:before,.ion-ios-bookmarks:before,.ion-ios-bookmarks-outline:before,.ion-ios-box:before,.ion-ios-box-outline:before,.ion-ios-briefcase:before,.ion-ios-briefcase-outline:before,.ion-ios-browsers:before,.ion-ios-browsers-outline:before,.ion-ios-calculator:before,.ion-ios-calculator-outline:before,.ion-ios-calendar:before,.ion-ios-calendar-outline:before,.ion-ios-camera:before,.ion-ios-camera-outline:before,.ion-ios-cart:before,.ion-ios-cart-outline:before,.ion-ios-chatboxes:before,.ion-ios-chatboxes-outline:before,.ion-ios-chatbubble:before,.ion-ios-chatbubble-outline:before,.ion-ios-checkmark:before,.ion-ios-checkmark-empty:before,.ion-ios-checkmark-outline:before,.ion-ios-circle-filled:before,.ion-ios-circle-outline:before,.ion-ios-clock:before,.ion-ios-clock-outline:before,.ion-ios-close:before,.ion-ios-close-empty:before,.ion-ios-close-outline:before,.ion-ios-cloud:before,.ion-ios-cloud-download:before,.ion-ios-cloud-download-outline:before,.ion-ios-cloud-outline:before,.ion-ios-cloud-upload:before,.ion-ios-cloud-upload-outline:before,.ion-ios-cloudy:before,.ion-ios-cloudy-night:before,.ion-ios-cloudy-night-outline:before,.ion-ios-cloudy-outline:before,.ion-ios-cog:before,.ion-ios-cog-outline:before,.ion-ios-color-filter:before,.ion-ios-color-filter-outline:before,.ion-ios-color-wand:before,.ion-ios-color-wand-outline:before,.ion-ios-compose:before,.ion-ios-compose-outline:before,.ion-ios-contact:before,.ion-ios-contact-outline:before,.ion-ios-copy:before,.ion-ios-copy-outline:before,.ion-ios-crop:before,.ion-ios-crop-strong:before,.ion-ios-download:before,.ion-ios-download-outline:before,.ion-ios-drag:before,.ion-ios-email:before,.ion-ios-email-outline:before,.ion-ios-eye:before,.ion-ios-eye-outline:before,.ion-ios-fastforward:before,.ion-ios-fastforward-outline:before,.ion-ios-filing:before,.ion-ios-filing-outline:before,.ion-ios-film:before,.ion-ios-film-outline:before,.ion-ios-flag:before,.ion-ios-flag-outline:before,.ion-ios-flame:before,.ion-ios-flame-outline:before,.ion-ios-flask:before,.ion-ios-flask-outline:before,.ion-ios-flower:before,.ion-ios-flower-outline:before,.ion-ios-folder:before,.ion-ios-folder-outline:before,.ion-ios-football:before,.ion-ios-football-outline:before,.ion-ios-game-controller-a:before,.ion-ios-game-controller-a-outline:before,.ion-ios-game-controller-b:before,.ion-ios-game-controller-b-outline:before,.ion-ios-gear:before,.ion-ios-gear-outline:before,.ion-ios-glasses:before,.ion-ios-glasses-outline:before,.ion-ios-grid-view:before,.ion-ios-grid-view-outline:before,.ion-ios-heart:before,.ion-ios-heart-outline:before,.ion-ios-help:before,.ion-ios-help-empty:before,.ion-ios-help-outline:before,.ion-ios-home:before,.ion-ios-home-outline:before,.ion-ios-infinite:before,.ion-ios-infinite-outline:before,.ion-ios-information:before,.ion-ios-information-empty:before,.ion-ios-information-outline:before,.ion-ios-ionic-outline:before,.ion-ios-keypad:before,.ion-ios-keypad-outline:before,.ion-ios-lightbulb:before,.ion-ios-lightbulb-outline:before,.ion-ios-list:before,.ion-ios-list-outline:before,.ion-ios-location:before,.ion-ios-location-outline:before,.ion-ios-locked:before,.ion-ios-locked-outline:before,.ion-ios-loop:before,.ion-ios-loop-strong:before,.ion-ios-medical:before,.ion-ios-medical-outline:before,.ion-ios-medkit:before,.ion-ios-medkit-outline:before,.ion-ios-mic:before,.ion-ios-mic-off:before,.ion-ios-mic-outline:before,.ion-ios-minus:before,.ion-ios-minus-empty:before,.ion-ios-minus-outline:before,.ion-ios-monitor:before,.ion-ios-monitor-outline:before,.ion-ios-moon:before,.ion-ios-moon-outline:before,.ion-ios-more:before,.ion-ios-more-outline:before,.ion-ios-musical-note:before,.ion-ios-musical-notes:before,.ion-ios-navigate:before,.ion-ios-navigate-outline:before,.ion-ios-nutrition:before,.ion-ios-nutrition-outline:before,.ion-ios-paper:before,.ion-ios-paper-outline:before,.ion-ios-paperplane:before,.ion-ios-paperplane-outline:before,.ion-ios-partlysunny:before,.ion-ios-partlysunny-outline:before,.ion-ios-pause:before,.ion-ios-pause-outline:before,.ion-ios-paw:before,.ion-ios-paw-outline:before,.ion-ios-people:before,.ion-ios-people-outline:before,.ion-ios-person:before,.ion-ios-person-outline:before,.ion-ios-personadd:before,.ion-ios-personadd-outline:before,.ion-ios-photos:before,.ion-ios-photos-outline:before,.ion-ios-pie:before,.ion-ios-pie-outline:before,.ion-ios-pint:before,.ion-ios-pint-outline:before,.ion-ios-play:before,.ion-ios-play-outline:before,.ion-ios-plus:before,.ion-ios-plus-empty:before,.ion-ios-plus-outline:before,.ion-ios-pricetag:before,.ion-ios-pricetag-outline:before,.ion-ios-pricetags:before,.ion-ios-pricetags-outline:before,.ion-ios-printer:before,.ion-ios-printer-outline:before,.ion-ios-pulse:before,.ion-ios-pulse-strong:before,.ion-ios-rainy:before,.ion-ios-rainy-outline:before,.ion-ios-recording:before,.ion-ios-recording-outline:before,.ion-ios-redo:before,.ion-ios-redo-outline:before,.ion-ios-refresh:before,.ion-ios-refresh-empty:before,.ion-ios-refresh-outline:before,.ion-ios-reload:before,.ion-ios-reverse-camera:before,.ion-ios-reverse-camera-outline:before,.ion-ios-rewind:before,.ion-ios-rewind-outline:before,.ion-ios-rose:before,.ion-ios-rose-outline:before,.ion-ios-search:before,.ion-ios-search-strong:before,.ion-ios-settings:before,.ion-ios-settings-strong:before,.ion-ios-shuffle:before,.ion-ios-shuffle-strong:before,.ion-ios-skipbackward:before,.ion-ios-skipbackward-outline:before,.ion-ios-skipforward:before,.ion-ios-skipforward-outline:before,.ion-ios-snowy:before,.ion-ios-speedometer:before,.ion-ios-speedometer-outline:before,.ion-ios-star:before,.ion-ios-star-half:before,.ion-ios-star-outline:before,.ion-ios-stopwatch:before,.ion-ios-stopwatch-outline:before,.ion-ios-sunny:before,.ion-ios-sunny-outline:before,.ion-ios-telephone:before,.ion-ios-telephone-outline:before,.ion-ios-tennisball:before,.ion-ios-tennisball-outline:before,.ion-ios-thunderstorm:before,.ion-ios-thunderstorm-outline:before,.ion-ios-time:before,.ion-ios-time-outline:before,.ion-ios-timer:before,.ion-ios-timer-outline:before,.ion-ios-toggle:before,.ion-ios-toggle-outline:before,.ion-ios-trash:before,.ion-ios-trash-outline:before,.ion-ios-undo:before,.ion-ios-undo-outline:before,.ion-ios-unlocked:before,.ion-ios-unlocked-outline:before,.ion-ios-upload:before,.ion-ios-upload-outline:before,.ion-ios-videocam:before,.ion-ios-videocam-outline:before,.ion-ios-volume-high:before,.ion-ios-volume-low:before,.ion-ios-wineglass:before,.ion-ios-wineglass-outline:before,.ion-ios-world:before,.ion-ios-world-outline:before,.ion-ipad:before,.ion-iphone:before,.ion-ipod:before,.ion-jet:before,.ion-key:before,.ion-knife:before,.ion-laptop:before,.ion-leaf:before,.ion-levels:before,.ion-lightbulb:before,.ion-link:before,.ion-load-a:before,.ion-load-b:before,.ion-load-c:before,.ion-load-d:before,.ion-location:before,.ion-lock-combination:before,.ion-locked:before,.ion-log-in:before,.ion-log-out:before,.ion-loop:before,.ion-magnet:before,.ion-male:before,.ion-man:before,.ion-map:before,.ion-medkit:before,.ion-merge:before,.ion-mic-a:before,.ion-mic-b:before,.ion-mic-c:before,.ion-minus:before,.ion-minus-circled:before,.ion-minus-round:before,.ion-model-s:before,.ion-monitor:before,.ion-more:before,.ion-mouse:before,.ion-music-note:before,.ion-navicon:before,.ion-navicon-round:before,.ion-navigate:before,.ion-network:before,.ion-no-smoking:before,.ion-nuclear:before,.ion-outlet:before,.ion-paintbrush:before,.ion-paintbucket:before,.ion-paper-airplane:before,.ion-paperclip:before,.ion-pause:before,.ion-person:before,.ion-person-add:before,.ion-person-stalker:before,.ion-pie-graph:before,.ion-pin:before,.ion-pinpoint:before,.ion-pizza:before,.ion-plane:before,.ion-planet:before,.ion-play:before,.ion-playstation:before,.ion-plus:before,.ion-plus-circled:before,.ion-plus-round:before,.ion-podium:before,.ion-pound:before,.ion-power:before,.ion-pricetag:before,.ion-pricetags:before,.ion-printer:before,.ion-pull-request:before,.ion-qr-scanner:before,.ion-quote:before,.ion-radio-waves:before,.ion-record:before,.ion-refresh:before,.ion-reply:before,.ion-reply-all:before,.ion-ribbon-a:before,.ion-ribbon-b:before,.ion-sad:before,.ion-sad-outline:before,.ion-scissors:before,.ion-search:before,.ion-settings:before,.ion-share:before,.ion-shuffle:before,.ion-skip-backward:before,.ion-skip-forward:before,.ion-social-android:before,.ion-social-android-outline:before,.ion-social-angular:before,.ion-social-angular-outline:before,.ion-social-apple:before,.ion-social-apple-outline:before,.ion-social-bitcoin:before,.ion-social-bitcoin-outline:before,.ion-social-buffer:before,.ion-social-buffer-outline:before,.ion-social-chrome:before,.ion-social-chrome-outline:before,.ion-social-codepen:before,.ion-social-codepen-outline:before,.ion-social-css3:before,.ion-social-css3-outline:before,.ion-social-designernews:before,.ion-social-designernews-outline:before,.ion-social-dribbble:before,.ion-social-dribbble-outline:before,.ion-social-dropbox:before,.ion-social-dropbox-outline:before,.ion-social-euro:before,.ion-social-euro-outline:before,.ion-social-facebook:before,.ion-social-facebook-outline:before,.ion-social-foursquare:before,.ion-social-foursquare-outline:before,.ion-social-freebsd-devil:before,.ion-social-github:before,.ion-social-github-outline:before,.ion-social-google:before,.ion-social-google-outline:before,.ion-social-googleplus:before,.ion-social-googleplus-outline:before,.ion-social-hackernews:before,.ion-social-hackernews-outline:before,.ion-social-html5:before,.ion-social-html5-outline:before,.ion-social-instagram:before,.ion-social-instagram-outline:before,.ion-social-javascript:before,.ion-social-javascript-outline:before,.ion-social-linkedin:before,.ion-social-linkedin-outline:before,.ion-social-markdown:before,.ion-social-nodejs:before,.ion-social-octocat:before,.ion-social-pinterest:before,.ion-social-pinterest-outline:before,.ion-social-python:before,.ion-social-reddit:before,.ion-social-reddit-outline:before,.ion-social-rss:before,.ion-social-rss-outline:before,.ion-social-sass:before,.ion-social-skype:before,.ion-social-skype-outline:before,.ion-social-snapchat:before,.ion-social-snapchat-outline:before,.ion-social-tumblr:before,.ion-social-tumblr-outline:before,.ion-social-tux:before,.ion-social-twitch:before,.ion-social-twitch-outline:before,.ion-social-twitter:before,.ion-social-twitter-outline:before,.ion-social-usd:before,.ion-social-usd-outline:before,.ion-social-vimeo:before,.ion-social-vimeo-outline:before,.ion-social-whatsapp:before,.ion-social-whatsapp-outline:before,.ion-social-windows:before,.ion-social-windows-outline:before,.ion-social-wordpress:before,.ion-social-wordpress-outline:before,.ion-social-yahoo:before,.ion-social-yahoo-outline:before,.ion-social-yen:before,.ion-social-yen-outline:before,.ion-social-youtube:before,.ion-social-youtube-outline:before,.ion-soup-can:before,.ion-soup-can-outline:before,.ion-speakerphone:before,.ion-speedometer:before,.ion-spoon:before,.ion-star:before,.ion-stats-bars:before,.ion-steam:before,.ion-stop:before,.ion-thermometer:before,.ion-thumbsdown:before,.ion-thumbsup:before,.ion-toggle:before,.ion-toggle-filled:before,.ion-transgender:before,.ion-trash-a:before,.ion-trash-b:before,.ion-trophy:before,.ion-tshirt:before,.ion-tshirt-outline:before,.ion-umbrella:before,.ion-university:before,.ion-unlocked:before,.ion-upload:before,.ion-usb:before,.ion-videocamera:before,.ion-volume-high:before,.ion-volume-low:before,.ion-volume-medium:before,.ion-volume-mute:before,.ion-wand:before,.ion-waterdrop:before,.ion-wifi:before,.ion-wineglass:before,.ion-woman:before,.ion-wrench:before,.ion-xbox:before{display:inline-block;font-family:"Ionicons";speak:none;font-style:normal;font-weight:normal;font-variant:normal;text-transform:none;text-rendering:auto;line-height:1;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.ion-alert:before{content:"\f101"}.ion-alert-circled:before{content:"\f100"}.ion-android-add:before{content:"\f2c7"}.ion-android-add-circle:before{content:"\f359"}.ion-android-alarm-clock:before{content:"\f35a"}.ion-android-alert:before{content:"\f35b"}.ion-android-apps:before{content:"\f35c"}.ion-android-archive:before{content:"\f2c9"}.ion-android-arrow-back:before{content:"\f2ca"}.ion-android-arrow-down:before{content:"\f35d"}.ion-android-arrow-dropdown:before{content:"\f35f"}.ion-android-arrow-dropdown-circle:before{content:"\f35e"}.ion-android-arrow-dropleft:before{content:"\f361"}.ion-android-arrow-dropleft-circle:before{content:"\f360"}.ion-android-arrow-dropright:before{content:"\f363"}.ion-android-arrow-dropright-circle:before{content:"\f362"}.ion-android-arrow-dropup:before{content:"\f365"}.ion-android-arrow-dropup-circle:before{content:"\f364"}.ion-android-arrow-forward:before{content:"\f30f"}.ion-android-arrow-up:before{content:"\f366"}.ion-android-attach:before{content:"\f367"}.ion-android-bar:before{content:"\f368"}.ion-android-bicycle:before{content:"\f369"}.ion-android-boat:before{content:"\f36a"}.ion-android-bookmark:before{content:"\f36b"}.ion-android-bulb:before{content:"\f36c"}.ion-android-bus:before{content:"\f36d"}.ion-android-calendar:before{content:"\f2d1"}.ion-android-call:before{content:"\f2d2"}.ion-android-camera:before{content:"\f2d3"}.ion-android-cancel:before{content:"\f36e"}.ion-android-car:before{content:"\f36f"}.ion-android-cart:before{content:"\f370"}.ion-android-chat:before{content:"\f2d4"}.ion-android-checkbox:before{content:"\f374"}.ion-android-checkbox-blank:before{content:"\f371"}.ion-android-checkbox-outline:before{content:"\f373"}.ion-android-checkbox-outline-blank:before{content:"\f372"}.ion-android-checkmark-circle:before{content:"\f375"}.ion-android-clipboard:before{content:"\f376"}.ion-android-close:before{content:"\f2d7"}.ion-android-cloud:before{content:"\f37a"}.ion-android-cloud-circle:before{content:"\f377"}.ion-android-cloud-done:before{content:"\f378"}.ion-android-cloud-outline:before{content:"\f379"}.ion-android-color-palette:before{content:"\f37b"}.ion-android-compass:before{content:"\f37c"}.ion-android-contact:before{content:"\f2d8"}.ion-android-contacts:before{content:"\f2d9"}.ion-android-contract:before{content:"\f37d"}.ion-android-create:before{content:"\f37e"}.ion-android-delete:before{content:"\f37f"}.ion-android-desktop:before{content:"\f380"}.ion-android-document:before{content:"\f381"}.ion-android-done:before{content:"\f383"}.ion-android-done-all:before{content:"\f382"}.ion-android-download:before{content:"\f2dd"}.ion-android-drafts:before{content:"\f384"}.ion-android-exit:before{content:"\f385"}.ion-android-expand:before{content:"\f386"}.ion-android-favorite:before{content:"\f388"}.ion-android-favorite-outline:before{content:"\f387"}.ion-android-film:before{content:"\f389"}.ion-android-folder:before{content:"\f2e0"}.ion-android-folder-open:before{content:"\f38a"}.ion-android-funnel:before{content:"\f38b"}.ion-android-globe:before{content:"\f38c"}.ion-android-hand:before{content:"\f2e3"}.ion-android-hangout:before{content:"\f38d"}.ion-android-happy:before{content:"\f38e"}.ion-android-home:before{content:"\f38f"}.ion-android-image:before{content:"\f2e4"}.ion-android-laptop:before{content:"\f390"}.ion-android-list:before{content:"\f391"}.ion-android-locate:before{content:"\f2e9"}.ion-android-lock:before{content:"\f392"}.ion-android-mail:before{content:"\f2eb"}.ion-android-map:before{content:"\f393"}.ion-android-menu:before{content:"\f394"}.ion-android-microphone:before{content:"\f2ec"}.ion-android-microphone-off:before{content:"\f395"}.ion-android-more-horizontal:before{content:"\f396"}.ion-android-more-vertical:before{content:"\f397"}.ion-android-navigate:before{content:"\f398"}.ion-android-notifications:before{content:"\f39b"}.ion-android-notifications-none:before{content:"\f399"}.ion-android-notifications-off:before{content:"\f39a"}.ion-android-open:before{content:"\f39c"}.ion-android-options:before{content:"\f39d"}.ion-android-people:before{content:"\f39e"}.ion-android-person:before{content:"\f3a0"}.ion-android-person-add:before{content:"\f39f"}.ion-android-phone-landscape:before{content:"\f3a1"}.ion-android-phone-portrait:before{content:"\f3a2"}.ion-android-pin:before{content:"\f3a3"}.ion-android-plane:before{content:"\f3a4"}.ion-android-playstore:before{content:"\f2f0"}.ion-android-print:before{content:"\f3a5"}.ion-android-radio-button-off:before{content:"\f3a6"}.ion-android-radio-button-on:before{content:"\f3a7"}.ion-android-refresh:before{content:"\f3a8"}.ion-android-remove:before{content:"\f2f4"}.ion-android-remove-circle:before{content:"\f3a9"}.ion-android-restaurant:before{content:"\f3aa"}.ion-android-sad:before{content:"\f3ab"}.ion-android-search:before{content:"\f2f5"}.ion-android-send:before{content:"\f2f6"}.ion-android-settings:before{content:"\f2f7"}.ion-android-share:before{content:"\f2f8"}.ion-android-share-alt:before{content:"\f3ac"}.ion-android-star:before{content:"\f2fc"}.ion-android-star-half:before{content:"\f3ad"}.ion-android-star-outline:before{content:"\f3ae"}.ion-android-stopwatch:before{content:"\f2fd"}.ion-android-subway:before{content:"\f3af"}.ion-android-sunny:before{content:"\f3b0"}.ion-android-sync:before{content:"\f3b1"}.ion-android-textsms:before{content:"\f3b2"}.ion-android-time:before{content:"\f3b3"}.ion-android-train:before{content:"\f3b4"}.ion-android-unlock:before{content:"\f3b5"}.ion-android-upload:before{content:"\f3b6"}.ion-android-volume-down:before{content:"\f3b7"}.ion-android-volume-mute:before{content:"\f3b8"}.ion-android-volume-off:before{content:"\f3b9"}.ion-android-volume-up:before{content:"\f3ba"}.ion-android-walk:before{content:"\f3bb"}.ion-android-warning:before{content:"\f3bc"}.ion-android-watch:before{content:"\f3bd"}.ion-android-wifi:before{content:"\f305"}.ion-aperture:before{content:"\f313"}.ion-archive:before{content:"\f102"}.ion-arrow-down-a:before{content:"\f103"}.ion-arrow-down-b:before{content:"\f104"}.ion-arrow-down-c:before{content:"\f105"}.ion-arrow-expand:before{content:"\f25e"}.ion-arrow-graph-down-left:before{content:"\f25f"}.ion-arrow-graph-down-right:before{content:"\f260"}.ion-arrow-graph-up-left:before{content:"\f261"}.ion-arrow-graph-up-right:before{content:"\f262"}.ion-arrow-left-a:before{content:"\f106"}.ion-arrow-left-b:before{content:"\f107"}.ion-arrow-left-c:before{content:"\f108"}.ion-arrow-move:before{content:"\f263"}.ion-arrow-resize:before{content:"\f264"}.ion-arrow-return-left:before{content:"\f265"}.ion-arrow-return-right:before{content:"\f266"}.ion-arrow-right-a:before{content:"\f109"}.ion-arrow-right-b:before{content:"\f10a"}.ion-arrow-right-c:before{content:"\f10b"}.ion-arrow-shrink:before{content:"\f267"}.ion-arrow-swap:before{content:"\f268"}.ion-arrow-up-a:before{content:"\f10c"}.ion-arrow-up-b:before{content:"\f10d"}.ion-arrow-up-c:before{content:"\f10e"}.ion-asterisk:before{content:"\f314"}.ion-at:before{content:"\f10f"}.ion-backspace:before{content:"\f3bf"}.ion-backspace-outline:before{content:"\f3be"}.ion-bag:before{content:"\f110"}.ion-battery-charging:before{content:"\f111"}.ion-battery-empty:before{content:"\f112"}.ion-battery-full:before{content:"\f113"}.ion-battery-half:before{content:"\f114"}.ion-battery-low:before{content:"\f115"}.ion-beaker:before{content:"\f269"}.ion-beer:before{content:"\f26a"}.ion-bluetooth:before{content:"\f116"}.ion-bonfire:before{content:"\f315"}.ion-bookmark:before{content:"\f26b"}.ion-bowtie:before{content:"\f3c0"}.ion-briefcase:before{content:"\f26c"}.ion-bug:before{content:"\f2be"}.ion-calculator:before{content:"\f26d"}.ion-calendar:before{content:"\f117"}.ion-camera:before{content:"\f118"}.ion-card:before{content:"\f119"}.ion-cash:before{content:"\f316"}.ion-chatbox:before{content:"\f11b"}.ion-chatbox-working:before{content:"\f11a"}.ion-chatboxes:before{content:"\f11c"}.ion-chatbubble:before{content:"\f11e"}.ion-chatbubble-working:before{content:"\f11d"}.ion-chatbubbles:before{content:"\f11f"}.ion-checkmark:before{content:"\f122"}.ion-checkmark-circled:before{content:"\f120"}.ion-checkmark-round:before{content:"\f121"}.ion-chevron-down:before{content:"\f123"}.ion-chevron-left:before{content:"\f124"}.ion-chevron-right:before{content:"\f125"}.ion-chevron-up:before{content:"\f126"}.ion-clipboard:before{content:"\f127"}.ion-clock:before{content:"\f26e"}.ion-close:before{content:"\f12a"}.ion-close-circled:before{content:"\f128"}.ion-close-round:before{content:"\f129"}.ion-closed-captioning:before{content:"\f317"}.ion-cloud:before{content:"\f12b"}.ion-code:before{content:"\f271"}.ion-code-download:before{content:"\f26f"}.ion-code-working:before{content:"\f270"}.ion-coffee:before{content:"\f272"}.ion-compass:before{content:"\f273"}.ion-compose:before{content:"\f12c"}.ion-connection-bars:before{content:"\f274"}.ion-contrast:before{content:"\f275"}.ion-crop:before{content:"\f3c1"}.ion-cube:before{content:"\f318"}.ion-disc:before{content:"\f12d"}.ion-document:before{content:"\f12f"}.ion-document-text:before{content:"\f12e"}.ion-drag:before{content:"\f130"}.ion-earth:before{content:"\f276"}.ion-easel:before{content:"\f3c2"}.ion-edit:before{content:"\f2bf"}.ion-egg:before{content:"\f277"}.ion-eject:before{content:"\f131"}.ion-email:before{content:"\f132"}.ion-email-unread:before{content:"\f3c3"}.ion-erlenmeyer-flask:before{content:"\f3c5"}.ion-erlenmeyer-flask-bubbles:before{content:"\f3c4"}.ion-eye:before{content:"\f133"}.ion-eye-disabled:before{content:"\f306"}.ion-female:before{content:"\f278"}.ion-filing:before{content:"\f134"}.ion-film-marker:before{content:"\f135"}.ion-fireball:before{content:"\f319"}.ion-flag:before{content:"\f279"}.ion-flame:before{content:"\f31a"}.ion-flash:before{content:"\f137"}.ion-flash-off:before{content:"\f136"}.ion-folder:before{content:"\f139"}.ion-fork:before{content:"\f27a"}.ion-fork-repo:before{content:"\f2c0"}.ion-forward:before{content:"\f13a"}.ion-funnel:before{content:"\f31b"}.ion-gear-a:before{content:"\f13d"}.ion-gear-b:before{content:"\f13e"}.ion-grid:before{content:"\f13f"}.ion-hammer:before{content:"\f27b"}.ion-happy:before{content:"\f31c"}.ion-happy-outline:before{content:"\f3c6"}.ion-headphone:before{content:"\f140"}.ion-heart:before{content:"\f141"}.ion-heart-broken:before{content:"\f31d"}.ion-help:before{content:"\f143"}.ion-help-buoy:before{content:"\f27c"}.ion-help-circled:before{content:"\f142"}.ion-home:before{content:"\f144"}.ion-icecream:before{content:"\f27d"}.ion-image:before{content:"\f147"}.ion-images:before{content:"\f148"}.ion-information:before{content:"\f14a"}.ion-information-circled:before{content:"\f149"}.ion-ionic:before{content:"\f14b"}.ion-ios-alarm:before{content:"\f3c8"}.ion-ios-alarm-outline:before{content:"\f3c7"}.ion-ios-albums:before{content:"\f3ca"}.ion-ios-albums-outline:before{content:"\f3c9"}.ion-ios-americanfootball:before{content:"\f3cc"}.ion-ios-americanfootball-outline:before{content:"\f3cb"}.ion-ios-analytics:before{content:"\f3ce"}.ion-ios-analytics-outline:before{content:"\f3cd"}.ion-ios-arrow-back:before{content:"\f3cf"}.ion-ios-arrow-down:before{content:"\f3d0"}.ion-ios-arrow-forward:before{content:"\f3d1"}.ion-ios-arrow-left:before{content:"\f3d2"}.ion-ios-arrow-right:before{content:"\f3d3"}.ion-ios-arrow-thin-down:before{content:"\f3d4"}.ion-ios-arrow-thin-left:before{content:"\f3d5"}.ion-ios-arrow-thin-right:before{content:"\f3d6"}.ion-ios-arrow-thin-up:before{content:"\f3d7"}.ion-ios-arrow-up:before{content:"\f3d8"}.ion-ios-at:before{content:"\f3da"}.ion-ios-at-outline:before{content:"\f3d9"}.ion-ios-barcode:before{content:"\f3dc"}.ion-ios-barcode-outline:before{content:"\f3db"}.ion-ios-baseball:before{content:"\f3de"}.ion-ios-baseball-outline:before{content:"\f3dd"}.ion-ios-basketball:before{content:"\f3e0"}.ion-ios-basketball-outline:before{content:"\f3df"}.ion-ios-bell:before{content:"\f3e2"}.ion-ios-bell-outline:before{content:"\f3e1"}.ion-ios-body:before{content:"\f3e4"}.ion-ios-body-outline:before{content:"\f3e3"}.ion-ios-bolt:before{content:"\f3e6"}.ion-ios-bolt-outline:before{content:"\f3e5"}.ion-ios-book:before{content:"\f3e8"}.ion-ios-book-outline:before{content:"\f3e7"}.ion-ios-bookmarks:before{content:"\f3ea"}.ion-ios-bookmarks-outline:before{content:"\f3e9"}.ion-ios-box:before{content:"\f3ec"}.ion-ios-box-outline:before{content:"\f3eb"}.ion-ios-briefcase:before{content:"\f3ee"}.ion-ios-briefcase-outline:before{content:"\f3ed"}.ion-ios-browsers:before{content:"\f3f0"}.ion-ios-browsers-outline:before{content:"\f3ef"}.ion-ios-calculator:before{content:"\f3f2"}.ion-ios-calculator-outline:before{content:"\f3f1"}.ion-ios-calendar:before{content:"\f3f4"}.ion-ios-calendar-outline:before{content:"\f3f3"}.ion-ios-camera:before{content:"\f3f6"}.ion-ios-camera-outline:before{content:"\f3f5"}.ion-ios-cart:before{content:"\f3f8"}.ion-ios-cart-outline:before{content:"\f3f7"}.ion-ios-chatboxes:before{content:"\f3fa"}.ion-ios-chatboxes-outline:before{content:"\f3f9"}.ion-ios-chatbubble:before{content:"\f3fc"}.ion-ios-chatbubble-outline:before{content:"\f3fb"}.ion-ios-checkmark:before{content:"\f3ff"}.ion-ios-checkmark-empty:before{content:"\f3fd"}.ion-ios-checkmark-outline:before{content:"\f3fe"}.ion-ios-circle-filled:before{content:"\f400"}.ion-ios-circle-outline:before{content:"\f401"}.ion-ios-clock:before{content:"\f403"}.ion-ios-clock-outline:before{content:"\f402"}.ion-ios-close:before{content:"\f406"}.ion-ios-close-empty:before{content:"\f404"}.ion-ios-close-outline:before{content:"\f405"}.ion-ios-cloud:before{content:"\f40c"}.ion-ios-cloud-download:before{content:"\f408"}.ion-ios-cloud-download-outline:before{content:"\f407"}.ion-ios-cloud-outline:before{content:"\f409"}.ion-ios-cloud-upload:before{content:"\f40b"}.ion-ios-cloud-upload-outline:before{content:"\f40a"}.ion-ios-cloudy:before{content:"\f410"}.ion-ios-cloudy-night:before{content:"\f40e"}.ion-ios-cloudy-night-outline:before{content:"\f40d"}.ion-ios-cloudy-outline:before{content:"\f40f"}.ion-ios-cog:before{content:"\f412"}.ion-ios-cog-outline:before{content:"\f411"}.ion-ios-color-filter:before{content:"\f414"}.ion-ios-color-filter-outline:before{content:"\f413"}.ion-ios-color-wand:before{content:"\f416"}.ion-ios-color-wand-outline:before{content:"\f415"}.ion-ios-compose:before{content:"\f418"}.ion-ios-compose-outline:before{content:"\f417"}.ion-ios-contact:before{content:"\f41a"}.ion-ios-contact-outline:before{content:"\f419"}.ion-ios-copy:before{content:"\f41c"}.ion-ios-copy-outline:before{content:"\f41b"}.ion-ios-crop:before{content:"\f41e"}.ion-ios-crop-strong:before{content:"\f41d"}.ion-ios-download:before{content:"\f420"}.ion-ios-download-outline:before{content:"\f41f"}.ion-ios-drag:before{content:"\f421"}.ion-ios-email:before{content:"\f423"}.ion-ios-email-outline:before{content:"\f422"}.ion-ios-eye:before{content:"\f425"}.ion-ios-eye-outline:before{content:"\f424"}.ion-ios-fastforward:before{content:"\f427"}.ion-ios-fastforward-outline:before{content:"\f426"}.ion-ios-filing:before{content:"\f429"}.ion-ios-filing-outline:before{content:"\f428"}.ion-ios-film:before{content:"\f42b"}.ion-ios-film-outline:before{content:"\f42a"}.ion-ios-flag:before{content:"\f42d"}.ion-ios-flag-outline:before{content:"\f42c"}.ion-ios-flame:before{content:"\f42f"}.ion-ios-flame-outline:before{content:"\f42e"}.ion-ios-flask:before{content:"\f431"}.ion-ios-flask-outline:before{content:"\f430"}.ion-ios-flower:before{content:"\f433"}.ion-ios-flower-outline:before{content:"\f432"}.ion-ios-folder:before{content:"\f435"}.ion-ios-folder-outline:before{content:"\f434"}.ion-ios-football:before{content:"\f437"}.ion-ios-football-outline:before{content:"\f436"}.ion-ios-game-controller-a:before{content:"\f439"}.ion-ios-game-controller-a-outline:before{content:"\f438"}.ion-ios-game-controller-b:before{content:"\f43b"}.ion-ios-game-controller-b-outline:before{content:"\f43a"}.ion-ios-gear:before{content:"\f43d"}.ion-ios-gear-outline:before{content:"\f43c"}.ion-ios-glasses:before{content:"\f43f"}.ion-ios-glasses-outline:before{content:"\f43e"}.ion-ios-grid-view:before{content:"\f441"}.ion-ios-grid-view-outline:before{content:"\f440"}.ion-ios-heart:before{content:"\f443"}.ion-ios-heart-outline:before{content:"\f442"}.ion-ios-help:before{content:"\f446"}.ion-ios-help-empty:before{content:"\f444"}.ion-ios-help-outline:before{content:"\f445"}.ion-ios-home:before{content:"\f448"}.ion-ios-home-outline:before{content:"\f447"}.ion-ios-infinite:before{content:"\f44a"}.ion-ios-infinite-outline:before{content:"\f449"}.ion-ios-information:before{content:"\f44d"}.ion-ios-information-empty:before{content:"\f44b"}.ion-ios-information-outline:before{content:"\f44c"}.ion-ios-ionic-outline:before{content:"\f44e"}.ion-ios-keypad:before{content:"\f450"}.ion-ios-keypad-outline:before{content:"\f44f"}.ion-ios-lightbulb:before{content:"\f452"}.ion-ios-lightbulb-outline:before{content:"\f451"}.ion-ios-list:before{content:"\f454"}.ion-ios-list-outline:before{content:"\f453"}.ion-ios-location:before{content:"\f456"}.ion-ios-location-outline:before{content:"\f455"}.ion-ios-locked:before{content:"\f458"}.ion-ios-locked-outline:before{content:"\f457"}.ion-ios-loop:before{content:"\f45a"}.ion-ios-loop-strong:before{content:"\f459"}.ion-ios-medical:before{content:"\f45c"}.ion-ios-medical-outline:before{content:"\f45b"}.ion-ios-medkit:before{content:"\f45e"}.ion-ios-medkit-outline:before{content:"\f45d"}.ion-ios-mic:before{content:"\f461"}.ion-ios-mic-off:before{content:"\f45f"}.ion-ios-mic-outline:before{content:"\f460"}.ion-ios-minus:before{content:"\f464"}.ion-ios-minus-empty:before{content:"\f462"}.ion-ios-minus-outline:before{content:"\f463"}.ion-ios-monitor:before{content:"\f466"}.ion-ios-monitor-outline:before{content:"\f465"}.ion-ios-moon:before{content:"\f468"}.ion-ios-moon-outline:before{content:"\f467"}.ion-ios-more:before{content:"\f46a"}.ion-ios-more-outline:before{content:"\f469"}.ion-ios-musical-note:before{content:"\f46b"}.ion-ios-musical-notes:before{content:"\f46c"}.ion-ios-navigate:before{content:"\f46e"}.ion-ios-navigate-outline:before{content:"\f46d"}.ion-ios-nutrition:before{content:"\f470"}.ion-ios-nutrition-outline:before{content:"\f46f"}.ion-ios-paper:before{content:"\f472"}.ion-ios-paper-outline:before{content:"\f471"}.ion-ios-paperplane:before{content:"\f474"}.ion-ios-paperplane-outline:before{content:"\f473"}.ion-ios-partlysunny:before{content:"\f476"}.ion-ios-partlysunny-outline:before{content:"\f475"}.ion-ios-pause:before{content:"\f478"}.ion-ios-pause-outline:before{content:"\f477"}.ion-ios-paw:before{content:"\f47a"}.ion-ios-paw-outline:before{content:"\f479"}.ion-ios-people:before{content:"\f47c"}.ion-ios-people-outline:before{content:"\f47b"}.ion-ios-person:before{content:"\f47e"}.ion-ios-person-outline:before{content:"\f47d"}.ion-ios-personadd:before{content:"\f480"}.ion-ios-personadd-outline:before{content:"\f47f"}.ion-ios-photos:before{content:"\f482"}.ion-ios-photos-outline:before{content:"\f481"}.ion-ios-pie:before{content:"\f484"}.ion-ios-pie-outline:before{content:"\f483"}.ion-ios-pint:before{content:"\f486"}.ion-ios-pint-outline:before{content:"\f485"}.ion-ios-play:before{content:"\f488"}.ion-ios-play-outline:before{content:"\f487"}.ion-ios-plus:before{content:"\f48b"}.ion-ios-plus-empty:before{content:"\f489"}.ion-ios-plus-outline:before{content:"\f48a"}.ion-ios-pricetag:before{content:"\f48d"}.ion-ios-pricetag-outline:before{content:"\f48c"}.ion-ios-pricetags:before{content:"\f48f"}.ion-ios-pricetags-outline:before{content:"\f48e"}.ion-ios-printer:before{content:"\f491"}.ion-ios-printer-outline:before{content:"\f490"}.ion-ios-pulse:before{content:"\f493"}.ion-ios-pulse-strong:before{content:"\f492"}.ion-ios-rainy:before{content:"\f495"}.ion-ios-rainy-outline:before{content:"\f494"}.ion-ios-recording:before{content:"\f497"}.ion-ios-recording-outline:before{content:"\f496"}.ion-ios-redo:before{content:"\f499"}.ion-ios-redo-outline:before{content:"\f498"}.ion-ios-refresh:before{content:"\f49c"}.ion-ios-refresh-empty:before{content:"\f49a"}.ion-ios-refresh-outline:before{content:"\f49b"}.ion-ios-reload:before{content:"\f49d"}.ion-ios-reverse-camera:before{content:"\f49f"}.ion-ios-reverse-camera-outline:before{content:"\f49e"}.ion-ios-rewind:before{content:"\f4a1"}.ion-ios-rewind-outline:before{content:"\f4a0"}.ion-ios-rose:before{content:"\f4a3"}.ion-ios-rose-outline:before{content:"\f4a2"}.ion-ios-search:before{content:"\f4a5"}.ion-ios-search-strong:before{content:"\f4a4"}.ion-ios-settings:before{content:"\f4a7"}.ion-ios-settings-strong:before{content:"\f4a6"}.ion-ios-shuffle:before{content:"\f4a9"}.ion-ios-shuffle-strong:before{content:"\f4a8"}.ion-ios-skipbackward:before{content:"\f4ab"}.ion-ios-skipbackward-outline:before{content:"\f4aa"}.ion-ios-skipforward:before{content:"\f4ad"}.ion-ios-skipforward-outline:before{content:"\f4ac"}.ion-ios-snowy:before{content:"\f4ae"}.ion-ios-speedometer:before{content:"\f4b0"}.ion-ios-speedometer-outline:before{content:"\f4af"}.ion-ios-star:before{content:"\f4b3"}.ion-ios-star-half:before{content:"\f4b1"}.ion-ios-star-outline:before{content:"\f4b2"}.ion-ios-stopwatch:before{content:"\f4b5"}.ion-ios-stopwatch-outline:before{content:"\f4b4"}.ion-ios-sunny:before{content:"\f4b7"}.ion-ios-sunny-outline:before{content:"\f4b6"}.ion-ios-telephone:before{content:"\f4b9"}.ion-ios-telephone-outline:before{content:"\f4b8"}.ion-ios-tennisball:before{content:"\f4bb"}.ion-ios-tennisball-outline:before{content:"\f4ba"}.ion-ios-thunderstorm:before{content:"\f4bd"}.ion-ios-thunderstorm-outline:before{content:"\f4bc"}.ion-ios-time:before{content:"\f4bf"}.ion-ios-time-outline:before{content:"\f4be"}.ion-ios-timer:before{content:"\f4c1"}.ion-ios-timer-outline:before{content:"\f4c0"}.ion-ios-toggle:before{content:"\f4c3"}.ion-ios-toggle-outline:before{content:"\f4c2"}.ion-ios-trash:before{content:"\f4c5"}.ion-ios-trash-outline:before{content:"\f4c4"}.ion-ios-undo:before{content:"\f4c7"}.ion-ios-undo-outline:before{content:"\f4c6"}.ion-ios-unlocked:before{content:"\f4c9"}.ion-ios-unlocked-outline:before{content:"\f4c8"}.ion-ios-upload:before{content:"\f4cb"}.ion-ios-upload-outline:before{content:"\f4ca"}.ion-ios-videocam:before{content:"\f4cd"}.ion-ios-videocam-outline:before{content:"\f4cc"}.ion-ios-volume-high:before{content:"\f4ce"}.ion-ios-volume-low:before{content:"\f4cf"}.ion-ios-wineglass:before{content:"\f4d1"}.ion-ios-wineglass-outline:before{content:"\f4d0"}.ion-ios-world:before{content:"\f4d3"}.ion-ios-world-outline:before{content:"\f4d2"}.ion-ipad:before{content:"\f1f9"}.ion-iphone:before{content:"\f1fa"}.ion-ipod:before{content:"\f1fb"}.ion-jet:before{content:"\f295"}.ion-key:before{content:"\f296"}.ion-knife:before{content:"\f297"}.ion-laptop:before{content:"\f1fc"}.ion-leaf:before{content:"\f1fd"}.ion-levels:before{content:"\f298"}.ion-lightbulb:before{content:"\f299"}.ion-link:before{content:"\f1fe"}.ion-load-a:before{content:"\f29a"}.ion-load-b:before{content:"\f29b"}.ion-load-c:before{content:"\f29c"}.ion-load-d:before{content:"\f29d"}.ion-location:before{content:"\f1ff"}.ion-lock-combination:before{content:"\f4d4"}.ion-locked:before{content:"\f200"}.ion-log-in:before{content:"\f29e"}.ion-log-out:before{content:"\f29f"}.ion-loop:before{content:"\f201"}.ion-magnet:before{content:"\f2a0"}.ion-male:before{content:"\f2a1"}.ion-man:before{content:"\f202"}.ion-map:before{content:"\f203"}.ion-medkit:before{content:"\f2a2"}.ion-merge:before{content:"\f33f"}.ion-mic-a:before{content:"\f204"}.ion-mic-b:before{content:"\f205"}.ion-mic-c:before{content:"\f206"}.ion-minus:before{content:"\f209"}.ion-minus-circled:before{content:"\f207"}.ion-minus-round:before{content:"\f208"}.ion-model-s:before{content:"\f2c1"}.ion-monitor:before{content:"\f20a"}.ion-more:before{content:"\f20b"}.ion-mouse:before{content:"\f340"}.ion-music-note:before{content:"\f20c"}.ion-navicon:before{content:"\f20e"}.ion-navicon-round:before{content:"\f20d"}.ion-navigate:before{content:"\f2a3"}.ion-network:before{content:"\f341"}.ion-no-smoking:before{content:"\f2c2"}.ion-nuclear:before{content:"\f2a4"}.ion-outlet:before{content:"\f342"}.ion-paintbrush:before{content:"\f4d5"}.ion-paintbucket:before{content:"\f4d6"}.ion-paper-airplane:before{content:"\f2c3"}.ion-paperclip:before{content:"\f20f"}.ion-pause:before{content:"\f210"}.ion-person:before{content:"\f213"}.ion-person-add:before{content:"\f211"}.ion-person-stalker:before{content:"\f212"}.ion-pie-graph:before{content:"\f2a5"}.ion-pin:before{content:"\f2a6"}.ion-pinpoint:before{content:"\f2a7"}.ion-pizza:before{content:"\f2a8"}.ion-plane:before{content:"\f214"}.ion-planet:before{content:"\f343"}.ion-play:before{content:"\f215"}.ion-playstation:before{content:"\f30a"}.ion-plus:before{content:"\f218"}.ion-plus-circled:before{content:"\f216"}.ion-plus-round:before{content:"\f217"}.ion-podium:before{content:"\f344"}.ion-pound:before{content:"\f219"}.ion-power:before{content:"\f2a9"}.ion-pricetag:before{content:"\f2aa"}.ion-pricetags:before{content:"\f2ab"}.ion-printer:before{content:"\f21a"}.ion-pull-request:before{content:"\f345"}.ion-qr-scanner:before{content:"\f346"}.ion-quote:before{content:"\f347"}.ion-radio-waves:before{content:"\f2ac"}.ion-record:before{content:"\f21b"}.ion-refresh:before{content:"\f21c"}.ion-reply:before{content:"\f21e"}.ion-reply-all:before{content:"\f21d"}.ion-ribbon-a:before{content:"\f348"}.ion-ribbon-b:before{content:"\f349"}.ion-sad:before{content:"\f34a"}.ion-sad-outline:before{content:"\f4d7"}.ion-scissors:before{content:"\f34b"}.ion-search:before{content:"\f21f"}.ion-settings:before{content:"\f2ad"}.ion-share:before{content:"\f220"}.ion-shuffle:before{content:"\f221"}.ion-skip-backward:before{content:"\f222"}.ion-skip-forward:before{content:"\f223"}.ion-social-android:before{content:"\f225"}.ion-social-android-outline:before{content:"\f224"}.ion-social-angular:before{content:"\f4d9"}.ion-social-angular-outline:before{content:"\f4d8"}.ion-social-apple:before{content:"\f227"}.ion-social-apple-outline:before{content:"\f226"}.ion-social-bitcoin:before{content:"\f2af"}.ion-social-bitcoin-outline:before{content:"\f2ae"}.ion-social-buffer:before{content:"\f229"}.ion-social-buffer-outline:before{content:"\f228"}.ion-social-chrome:before{content:"\f4db"}.ion-social-chrome-outline:before{content:"\f4da"}.ion-social-codepen:before{content:"\f4dd"}.ion-social-codepen-outline:before{content:"\f4dc"}.ion-social-css3:before{content:"\f4df"}.ion-social-css3-outline:before{content:"\f4de"}.ion-social-designernews:before{content:"\f22b"}.ion-social-designernews-outline:before{content:"\f22a"}.ion-social-dribbble:before{content:"\f22d"}.ion-social-dribbble-outline:before{content:"\f22c"}.ion-social-dropbox:before{content:"\f22f"}.ion-social-dropbox-outline:before{content:"\f22e"}.ion-social-euro:before{content:"\f4e1"}.ion-social-euro-outline:before{content:"\f4e0"}.ion-social-facebook:before{content:"\f231"}.ion-social-facebook-outline:before{content:"\f230"}.ion-social-foursquare:before{content:"\f34d"}.ion-social-foursquare-outline:before{content:"\f34c"}.ion-social-freebsd-devil:before{content:"\f2c4"}.ion-social-github:before{content:"\f233"}.ion-social-github-outline:before{content:"\f232"}.ion-social-google:before{content:"\f34f"}.ion-social-google-outline:before{content:"\f34e"}.ion-social-googleplus:before{content:"\f235"}.ion-social-googleplus-outline:before{content:"\f234"}.ion-social-hackernews:before{content:"\f237"}.ion-social-hackernews-outline:before{content:"\f236"}.ion-social-html5:before{content:"\f4e3"}.ion-social-html5-outline:before{content:"\f4e2"}.ion-social-instagram:before{content:"\f351"}.ion-social-instagram-outline:before{content:"\f350"}.ion-social-javascript:before{content:"\f4e5"}.ion-social-javascript-outline:before{content:"\f4e4"}.ion-social-linkedin:before{content:"\f239"}.ion-social-linkedin-outline:before{content:"\f238"}.ion-social-markdown:before{content:"\f4e6"}.ion-social-nodejs:before{content:"\f4e7"}.ion-social-octocat:before{content:"\f4e8"}.ion-social-pinterest:before{content:"\f2b1"}.ion-social-pinterest-outline:before{content:"\f2b0"}.ion-social-python:before{content:"\f4e9"}.ion-social-reddit:before{content:"\f23b"}.ion-social-reddit-outline:before{content:"\f23a"}.ion-social-rss:before{content:"\f23d"}.ion-social-rss-outline:before{content:"\f23c"}.ion-social-sass:before{content:"\f4ea"}.ion-social-skype:before{content:"\f23f"}.ion-social-skype-outline:before{content:"\f23e"}.ion-social-snapchat:before{content:"\f4ec"}.ion-social-snapchat-outline:before{content:"\f4eb"}.ion-social-tumblr:before{content:"\f241"}.ion-social-tumblr-outline:before{content:"\f240"}.ion-social-tux:before{content:"\f2c5"}.ion-social-twitch:before{content:"\f4ee"}.ion-social-twitch-outline:before{content:"\f4ed"}.ion-social-twitter:before{content:"\f243"}.ion-social-twitter-outline:before{content:"\f242"}.ion-social-usd:before{content:"\f353"}.ion-social-usd-outline:before{content:"\f352"}.ion-social-vimeo:before{content:"\f245"}.ion-social-vimeo-outline:before{content:"\f244"}.ion-social-whatsapp:before{content:"\f4f0"}.ion-social-whatsapp-outline:before{content:"\f4ef"}.ion-social-windows:before{content:"\f247"}.ion-social-windows-outline:before{content:"\f246"}.ion-social-wordpress:before{content:"\f249"}.ion-social-wordpress-outline:before{content:"\f248"}.ion-social-yahoo:before{content:"\f24b"}.ion-social-yahoo-outline:before{content:"\f24a"}.ion-social-yen:before{content:"\f4f2"}.ion-social-yen-outline:before{content:"\f4f1"}.ion-social-youtube:before{content:"\f24d"}.ion-social-youtube-outline:before{content:"\f24c"}.ion-soup-can:before{content:"\f4f4"}.ion-soup-can-outline:before{content:"\f4f3"}.ion-speakerphone:before{content:"\f2b2"}.ion-speedometer:before{content:"\f2b3"}.ion-spoon:before{content:"\f2b4"}.ion-star:before{content:"\f24e"}.ion-stats-bars:before{content:"\f2b5"}.ion-steam:before{content:"\f30b"}.ion-stop:before{content:"\f24f"}.ion-thermometer:before{content:"\f2b6"}.ion-thumbsdown:before{content:"\f250"}.ion-thumbsup:before{content:"\f251"}.ion-toggle:before{content:"\f355"}.ion-toggle-filled:before{content:"\f354"}.ion-transgender:before{content:"\f4f5"}.ion-trash-a:before{content:"\f252"}.ion-trash-b:before{content:"\f253"}.ion-trophy:before{content:"\f356"}.ion-tshirt:before{content:"\f4f7"}.ion-tshirt-outline:before{content:"\f4f6"}.ion-umbrella:before{content:"\f2b7"}.ion-university:before{content:"\f357"}.ion-unlocked:before{content:"\f254"}.ion-upload:before{content:"\f255"}.ion-usb:before{content:"\f2b8"}.ion-videocamera:before{content:"\f256"}.ion-volume-high:before{content:"\f257"}.ion-volume-low:before{content:"\f258"}.ion-volume-medium:before{content:"\f259"}.ion-volume-mute:before{content:"\f25a"}.ion-wand:before{content:"\f358"}.ion-waterdrop:before{content:"\f25b"}.ion-wifi:before{content:"\f25c"}.ion-wineglass:before{content:"\f2b9"}.ion-woman:before{content:"\f25d"}.ion-wrench:before{content:"\f2ba"}.ion-xbox:before{content:"\f30c"} 12 | -------------------------------------------------------------------------------- /app/fonts/ionicons.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmatteis/peer-tweet/469ec0a56926d8ad074b15f7042394a85e824fca/app/fonts/ionicons.woff -------------------------------------------------------------------------------- /app/hot-dev-app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PeerTweet 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /app/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'react-dom'; 3 | import Main from './components/Main'; 4 | import './app.css'; 5 | import './css/ionicons.min.css'; 6 | 7 | render( 8 |
, 9 | document.getElementById('root') 10 | ); 11 | 12 | if (process.env.NODE_ENV !== 'production') { 13 | // Use require because imports can't be conditional. 14 | // In production, you should ensure process.env.NODE_ENV 15 | // is envified so that Uglify can eliminate this 16 | // module and its dependencies as dead code. 17 | // require('./createDevToolsWindow')(store); 18 | } 19 | -------------------------------------------------------------------------------- /app/routes.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Route, IndexRoute } from 'react-router'; 3 | import App from './containers/App'; 4 | import HomePage from './containers/HomePage'; 5 | import CounterPage from './containers/CounterPage'; 6 | 7 | 8 | export default ( 9 | 10 | /* 11 | */ 12 | 13 | ); 14 | -------------------------------------------------------------------------------- /app/stores/index.js: -------------------------------------------------------------------------------- 1 | import { createStore } from 'redux' 2 | 3 | export const currentPageStore = createStore((state, action) => { 4 | switch (action.type) { 5 | case 'SET_CURRENT_PAGE': 6 | return { 7 | page: action.page, 8 | hashHex: action.hashHex 9 | } 10 | default: 11 | return state 12 | } 13 | }) 14 | 15 | export const tweetsStore = createStore((state, action) => { 16 | switch (action.type) { 17 | case 'RESET': 18 | return 'RESET' 19 | case 'ADD_TWEET': 20 | return action.tweet 21 | default: 22 | return state 23 | } 24 | }) 25 | -------------------------------------------------------------------------------- /app/utils/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmatteis/peer-tweet/469ec0a56926d8ad074b15f7042394a85e824fca/app/utils/.gitkeep -------------------------------------------------------------------------------- /erb-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmatteis/peer-tweet/469ec0a56926d8ad074b15f7042394a85e824fca/erb-logo.png -------------------------------------------------------------------------------- /main.js: -------------------------------------------------------------------------------- 1 | /* eslint strict: 0 */ 2 | 'use strict'; 3 | 4 | const electron = require('electron'); 5 | const app = electron.app; 6 | const BrowserWindow = electron.BrowserWindow; 7 | const Menu = electron.Menu; 8 | const crashReporter = electron.crashReporter; 9 | const shell = electron.shell; 10 | let menu; 11 | let template; 12 | let mainWindow = null; 13 | 14 | 15 | crashReporter.start(); 16 | 17 | if (process.env.NODE_ENV === 'development') { 18 | require('electron-debug')(); 19 | } 20 | 21 | 22 | app.on('window-all-closed', () => { 23 | if (process.platform !== 'darwin') app.quit(); 24 | }); 25 | 26 | 27 | if (process.argv.length >= 3 && process.env.NODE_ENV === 'development') 28 | app.setPath('userData', process.argv[2]) 29 | 30 | app.on('ready', () => { 31 | mainWindow = new BrowserWindow({ width: 450, height: 500 }); 32 | 33 | if (process.env.HOT) { 34 | mainWindow.loadURL(`file://${__dirname}/app/hot-dev-app.html`); 35 | } else { 36 | mainWindow.loadURL(`file://${__dirname}/app/app.html`); 37 | } 38 | 39 | mainWindow.on('closed', () => { 40 | mainWindow = null; 41 | }); 42 | 43 | if (process.env.NODE_ENV === 'development') { 44 | mainWindow.openDevTools(); 45 | } 46 | 47 | if (process.platform === 'darwin') { 48 | template = [{ 49 | label: 'PeerTweet', 50 | submenu: [{ 51 | label: 'About PeerTweet', 52 | selector: 'orderFrontStandardAboutPanel:' 53 | }, { 54 | type: 'separator' 55 | }, { 56 | label: 'Services', 57 | submenu: [] 58 | }, { 59 | type: 'separator' 60 | }, { 61 | label: 'Hide PeerTweet', 62 | accelerator: 'Command+H', 63 | selector: 'hide:' 64 | }, { 65 | label: 'Hide Others', 66 | accelerator: 'Command+Shift+H', 67 | selector: 'hideOtherApplications:' 68 | }, { 69 | label: 'Show All', 70 | selector: 'unhideAllApplications:' 71 | }, { 72 | type: 'separator' 73 | }, { 74 | label: 'Quit', 75 | accelerator: 'Command+Q', 76 | click() { 77 | app.quit(); 78 | } 79 | }] 80 | }, { 81 | label: 'Edit', 82 | submenu: [{ 83 | label: 'Undo', 84 | accelerator: 'Command+Z', 85 | selector: 'undo:' 86 | }, { 87 | label: 'Redo', 88 | accelerator: 'Shift+Command+Z', 89 | selector: 'redo:' 90 | }, { 91 | type: 'separator' 92 | }, { 93 | label: 'Cut', 94 | accelerator: 'Command+X', 95 | selector: 'cut:' 96 | }, { 97 | label: 'Copy', 98 | accelerator: 'Command+C', 99 | selector: 'copy:' 100 | }, { 101 | label: 'Paste', 102 | accelerator: 'Command+V', 103 | selector: 'paste:' 104 | }, { 105 | label: 'Select All', 106 | accelerator: 'Command+A', 107 | selector: 'selectAll:' 108 | }] 109 | }, { 110 | label: 'View', 111 | submenu: (process.env.NODE_ENV === 'development') ? [{ 112 | label: 'Reload', 113 | accelerator: 'Command+R', 114 | click() { 115 | mainWindow.restart(); 116 | } 117 | }, { 118 | label: 'Toggle Full Screen', 119 | accelerator: 'Ctrl+Command+F', 120 | click() { 121 | mainWindow.setFullScreen(!mainWindow.isFullScreen()); 122 | } 123 | }, { 124 | label: 'Toggle Developer Tools', 125 | accelerator: 'Alt+Command+I', 126 | click() { 127 | mainWindow.toggleDevTools(); 128 | } 129 | }] : [{ 130 | label: 'Toggle Full Screen', 131 | accelerator: 'Ctrl+Command+F', 132 | click() { 133 | mainWindow.setFullScreen(!mainWindow.isFullScreen()); 134 | } 135 | }] 136 | }, { 137 | label: 'Window', 138 | submenu: [{ 139 | label: 'Minimize', 140 | accelerator: 'Command+M', 141 | selector: 'performMiniaturize:' 142 | }, { 143 | label: 'Close', 144 | accelerator: 'Command+W', 145 | selector: 'performClose:' 146 | }, { 147 | type: 'separator' 148 | }, { 149 | label: 'Bring All to Front', 150 | selector: 'arrangeInFront:' 151 | }] 152 | }, { 153 | label: 'Help', 154 | submenu: [{ 155 | label: 'Learn More', 156 | click() { 157 | shell.openExternal('https://github.com/lmatteis/peer-tweet'); 158 | } 159 | }/*, { 160 | label: 'Documentation', 161 | click() { 162 | shell.openExternal('https://github.com/atom/electron/tree/master/docs#readme'); 163 | } 164 | }, { 165 | label: 'Community Discussions', 166 | click() { 167 | shell.openExternal('https://discuss.atom.io/c/electron'); 168 | } 169 | }, { 170 | label: 'Search Issues', 171 | click() { 172 | shell.openExternal('https://github.com/atom/electron/issues'); 173 | } 174 | }*/] 175 | }]; 176 | 177 | menu = Menu.buildFromTemplate(template); 178 | Menu.setApplicationMenu(menu); 179 | } else { 180 | template = [{ 181 | label: '&File', 182 | submenu: [{ 183 | label: '&Open', 184 | accelerator: 'Ctrl+O' 185 | }, { 186 | label: '&Close', 187 | accelerator: 'Ctrl+W', 188 | click() { 189 | mainWindow.close(); 190 | } 191 | }] 192 | }, { 193 | label: '&View', 194 | submenu: (process.env.NODE_ENV === 'development') ? [{ 195 | label: '&Reload', 196 | accelerator: 'Ctrl+R', 197 | click() { 198 | mainWindow.restart(); 199 | } 200 | }, { 201 | label: 'Toggle &Full Screen', 202 | accelerator: 'F11', 203 | click() { 204 | mainWindow.setFullScreen(!mainWindow.isFullScreen()); 205 | } 206 | }, { 207 | label: 'Toggle &Developer Tools', 208 | accelerator: 'Alt+Ctrl+I', 209 | click() { 210 | mainWindow.toggleDevTools(); 211 | } 212 | }] : [{ 213 | label: 'Toggle &Full Screen', 214 | accelerator: 'F11', 215 | click() { 216 | mainWindow.setFullScreen(!mainWindow.isFullScreen()); 217 | } 218 | }] 219 | }, { 220 | label: 'Help', 221 | submenu: [{ 222 | label: 'Learn More', 223 | click() { 224 | shell.openExternal('https://github.com/lmatteis/peer-tweet'); 225 | } 226 | }/*, { 227 | label: 'Documentation', 228 | click() { 229 | shell.openExternal('https://github.com/atom/electron/tree/master/docs#readme'); 230 | } 231 | }, { 232 | label: 'Community Discussions', 233 | click() { 234 | shell.openExternal('https://discuss.atom.io/c/electron'); 235 | } 236 | }, { 237 | label: 'Search Issues', 238 | click() { 239 | shell.openExternal('https://github.com/atom/electron/issues'); 240 | } 241 | }*/] 242 | }]; 243 | menu = Menu.buildFromTemplate(template); 244 | mainWindow.setMenu(menu); 245 | } 246 | }); 247 | -------------------------------------------------------------------------------- /package.js: -------------------------------------------------------------------------------- 1 | /* eslint strict: 0, no-shadow: 0, no-unused-vars: 0, no-console: 0 */ 2 | 'use strict'; 3 | 4 | const os = require('os'); 5 | const webpack = require('webpack'); 6 | const cfg = require('./webpack.config.production.js'); 7 | const packager = require('electron-packager'); 8 | const del = require('del'); 9 | const exec = require('child_process').exec; 10 | const argv = require('minimist')(process.argv.slice(2)); 11 | const pkg = require('./package.json'); 12 | const devDeps = Object.keys(pkg.devDependencies); 13 | 14 | const appName = argv.name || argv.n || pkg.productName; 15 | const shouldUseAsar = argv.asar || argv.a || false; 16 | const shouldBuildAll = argv.all || false; 17 | 18 | 19 | const DEFAULT_OPTS = { 20 | dir: './', 21 | name: appName, 22 | asar: shouldUseAsar, 23 | ignore: [ 24 | '/test($|/)', 25 | '/tools($|/)', 26 | '/release($|/)' 27 | ].concat(devDeps.map(name => `/node_modules/${name}($|/)`)) 28 | }; 29 | 30 | const icon = argv.icon || argv.i || 'app/app'; 31 | 32 | if (icon) { 33 | DEFAULT_OPTS.icon = icon; 34 | } 35 | 36 | const version = argv.version || argv.v; 37 | 38 | if (version) { 39 | DEFAULT_OPTS.version = version; 40 | startPack(); 41 | } else { 42 | // use the same version as the currently-installed electron-prebuilt 43 | exec('npm list electron-prebuilt', (err, stdout) => { 44 | if (err) { 45 | DEFAULT_OPTS.version = '0.36.9'; 46 | } else { 47 | DEFAULT_OPTS.version = stdout.split('electron-prebuilt@')[1].replace(/\s/g, ''); 48 | } 49 | 50 | startPack(); 51 | }); 52 | } 53 | 54 | 55 | function startPack() { 56 | console.log('start pack...'); 57 | webpack(cfg, (err, stats) => { 58 | if (err) return console.error(err); 59 | del('release') 60 | .then(paths => { 61 | if (shouldBuildAll) { 62 | // build for all platforms 63 | const archs = ['ia32', 'x64']; 64 | const platforms = ['linux', 'win32', 'darwin']; 65 | 66 | platforms.forEach(plat => { 67 | archs.forEach(arch => { 68 | pack(plat, arch, log(plat, arch)); 69 | }); 70 | }); 71 | } else { 72 | // build for current platform only 73 | pack(os.platform(), os.arch(), log(os.platform(), os.arch())); 74 | } 75 | }) 76 | .catch(err => { 77 | console.error(err); 78 | }); 79 | }); 80 | } 81 | 82 | function pack(plat, arch, cb) { 83 | // there is no darwin ia32 electron 84 | if (plat === 'darwin' && arch === 'ia32') return; 85 | 86 | const iconObj = { 87 | icon: DEFAULT_OPTS.icon + (() => { 88 | let extension = '.png'; 89 | if (plat === 'darwin') { 90 | extension = '.icns'; 91 | } else if (plat === 'win32') { 92 | extension = '.ico'; 93 | } 94 | return extension; 95 | })() 96 | }; 97 | 98 | const opts = Object.assign({}, DEFAULT_OPTS, iconObj, { 99 | platform: plat, 100 | arch, 101 | prune: true, 102 | 'app-version': pkg.version || DEFAULT_OPTS.version, 103 | out: `release/${plat}-${arch}` 104 | }); 105 | 106 | packager(opts, cb); 107 | } 108 | 109 | 110 | function log(plat, arch) { 111 | return (err, filepath) => { 112 | if (err) return console.error(err); 113 | console.log(`${plat}-${arch} finished!`); 114 | }; 115 | } 116 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "PeerTweet", 3 | "productName": "PeerTweet", 4 | "version": "0.1.1", 5 | "description": "Decentralized feeds using BitTorrent's DHT", 6 | "main": "main.js", 7 | "scripts": { 8 | "test": "cross-env NODE_ENV=test mocha --compilers js:babel-core/register --recursive --require ./test/setup.js test/**/*.spec.js", 9 | "test-watch": "npm test -- --watch", 10 | "test-e2e": "cross-env NODE_ENV=test mocha --compilers js:babel-core/register --require ./test/setup.js --require co-mocha ./test/e2e.js", 11 | "lint": "eslint app test *.js", 12 | "hot-server": "node server.js", 13 | "build": "cross-env NODE_ENV=production webpack --config webpack.config.production.js --progress --profile --colors", 14 | "start": "cross-env NODE_ENV=production electron ./", 15 | "start-hot": "cross-env HOT=1 NODE_ENV=development electron ./", 16 | "package": "cross-env NODE_ENV=production node package.js", 17 | "package-all": "npm run package -- --all", 18 | "postinstall": "node node_modules/fbjs-scripts/node/check-dev-engines.js package.json", 19 | "dev": "concurrently --kill-others \"npm run hot-server\" \"npm run start-hot\"" 20 | }, 21 | "bin": { 22 | "electron": "./node_modules/.bin/electron" 23 | }, 24 | "repository": { 25 | "type": "git", 26 | "url": "git+https://github.com/lmatteis/peer-tweet.git" 27 | }, 28 | "author": { 29 | "name": "Luca Matteis", 30 | "email": "lmatteis@gmail.com", 31 | "url": "https://github.com/lmatteis" 32 | }, 33 | "license": "MIT", 34 | "bugs": { 35 | "url": "https://github.com/lmatteis/peer-tweet/issues" 36 | }, 37 | "keywords": [ 38 | "bittorrent", 39 | "dht", 40 | "microblogging", 41 | "decentralized", 42 | "p2p" 43 | ], 44 | "homepage": "https://github.com/lmatteis/peer-tweet", 45 | "devDependencies": { 46 | "asar": "^0.10.0", 47 | "babel-core": "^6.6.0", 48 | "babel-eslint": "^5.0.0", 49 | "babel-loader": "^6.2.4", 50 | "babel-plugin-add-module-exports": "^0.1.2", 51 | "babel-plugin-webpack-loaders": "^0.3.7", 52 | "babel-polyfill": "^6.6.1", 53 | "babel-preset-es2015": "^6.6.0", 54 | "babel-preset-react": "^6.3.13", 55 | "babel-preset-react-hmre": "^1.0.1", 56 | "babel-preset-stage-0": "^6.3.13", 57 | "chai": "^3.3.0", 58 | "chromedriver": "^2.19.0", 59 | "co-mocha": "^1.1.2", 60 | "concurrently": "^2.0.0", 61 | "cross-env": "^1.0.7", 62 | "css-loader": "^0.23.1", 63 | "del": "^2.0.1", 64 | "electron-packager": "^5.2.0", 65 | "electron-prebuilt": "^0.36.9", 66 | "electron-rebuild": "^1.0.0", 67 | "eslint": "^2.2.0", 68 | "eslint-config-airbnb": "^6.0.2", 69 | "eslint-plugin-react": "^4.1.0", 70 | "express": "^4.13.3", 71 | "extract-text-webpack-plugin": "^1.0.1", 72 | "fbjs-scripts": "^0.5.0", 73 | "file-loader": "^0.8.5", 74 | "jsdom": "^7.2.2", 75 | "json-loader": "^0.5.4", 76 | "minimist": "^1.2.0", 77 | "mocha": "^2.3.0", 78 | "node-libs-browser": "^1.0.0", 79 | "react-addons-test-utils": "^0.14.2", 80 | "redux-devtools": "^3.0.1", 81 | "redux-devtools-dock-monitor": "^1.0.1", 82 | "redux-devtools-log-monitor": "^1.0.5", 83 | "redux-logger": "^2.6.1", 84 | "selenium-webdriver": "^2.48.2", 85 | "sinon": "^1.17.2", 86 | "style-loader": "^0.13.0", 87 | "url-loader": "^0.5.7", 88 | "webpack": "^1.12.1", 89 | "webpack-dev-middleware": "^1.2.0", 90 | "webpack-hot-middleware": "^2.9.0", 91 | "webpack-target-electron-renderer": "^0.4.0", 92 | "base-x": "^1.0.3", 93 | "bencode": "^0.7.0", 94 | "bittorrent-dht": "^6.4.0", 95 | "json-buffer": "^2.0.11", 96 | "react": "^0.14.2", 97 | "react-dom": "^0.14.2", 98 | "react-redux": "^4.0.5", 99 | "redux": "^3.2.1", 100 | "redux-promise": "^0.5.0", 101 | "redux-thunk": "^1.0.2" 102 | }, 103 | "dependencies": { 104 | "ed25519-supercop": "^1.0.2" 105 | }, 106 | "devEngines": { 107 | "node": "4.x || 5.x", 108 | "npm": "2.x || 3.x" 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | /* eslint strict: 0, no-console: 0 */ 2 | 'use strict'; 3 | 4 | const path = require('path'); 5 | const express = require('express'); 6 | const webpack = require('webpack'); 7 | const config = require('./webpack.config.development'); 8 | 9 | const app = express(); 10 | const compiler = webpack(config); 11 | 12 | const PORT = 3000; 13 | 14 | app.use(require('webpack-dev-middleware')(compiler, { 15 | publicPath: config.output.publicPath, 16 | stats: { 17 | colors: true 18 | } 19 | })); 20 | 21 | app.use(require('webpack-hot-middleware')(compiler)); 22 | 23 | app.get('*', (req, res) => { 24 | res.sendFile(path.join(__dirname, 'app', 'hot-dev-app.html')); 25 | }); 26 | 27 | app.listen(PORT, 'localhost', err => { 28 | if (err) { 29 | console.log(err); 30 | return; 31 | } 32 | 33 | console.log(`Listening at http://localhost:${PORT}`); 34 | }); 35 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "mocha": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /test/actions/counter.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint no-unused-expressions: 0 */ 2 | import { expect } from 'chai'; 3 | import { spy } from 'sinon'; 4 | import * as actions from '../../app/actions/counter'; 5 | 6 | 7 | describe('actions', () => { 8 | it('increment should create increment action', () => { 9 | expect(actions.increment()).to.deep.equal({ type: actions.INCREMENT_COUNTER }); 10 | }); 11 | 12 | it('decrement should create decrement action', () => { 13 | expect(actions.decrement()).to.deep.equal({ type: actions.DECREMENT_COUNTER }); 14 | }); 15 | 16 | it('incrementIfOdd should create increment action', () => { 17 | const fn = actions.incrementIfOdd(); 18 | expect(fn).to.be.a('function'); 19 | const dispatch = spy(); 20 | const getState = () => ({ counter: 1 }); 21 | fn(dispatch, getState); 22 | expect(dispatch.calledWith({ type: actions.INCREMENT_COUNTER })).to.be.true; 23 | }); 24 | 25 | it('incrementIfOdd shouldnt create increment action if counter is even', () => { 26 | const fn = actions.incrementIfOdd(); 27 | const dispatch = spy(); 28 | const getState = () => ({ counter: 2 }); 29 | fn(dispatch, getState); 30 | expect(dispatch.called).to.be.false; 31 | }); 32 | 33 | // There's no nice way to test this at the moment... 34 | it('incrementAsync', (done) => { 35 | const fn = actions.incrementAsync(1); 36 | expect(fn).to.be.a('function'); 37 | const dispatch = spy(); 38 | fn(dispatch); 39 | setTimeout(() => { 40 | expect(dispatch.calledWith({ type: actions.INCREMENT_COUNTER })).to.be.true; 41 | done(); 42 | }, 5); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /test/components/Counter.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint no-unused-expressions: 0 */ 2 | import { expect } from 'chai'; 3 | import { spy } from 'sinon'; 4 | import React from 'react'; 5 | import { 6 | renderIntoDocument, 7 | scryRenderedDOMComponentsWithTag, 8 | findRenderedDOMComponentWithClass, 9 | Simulate 10 | } from 'react-addons-test-utils'; 11 | import Counter from '../../app/components/Counter'; 12 | 13 | 14 | function setup() { 15 | const actions = { 16 | increment: spy(), 17 | incrementIfOdd: spy(), 18 | incrementAsync: spy(), 19 | decrement: spy() 20 | }; 21 | const component = renderIntoDocument(); 22 | return { 23 | component, 24 | actions, 25 | buttons: scryRenderedDOMComponentsWithTag(component, 'button').map(button => { 26 | return button; 27 | }), 28 | p: findRenderedDOMComponentWithClass(component, 'counter') 29 | }; 30 | } 31 | 32 | 33 | describe('Counter component', () => { 34 | it('should display count', () => { 35 | const { p } = setup(); 36 | expect(p.textContent).to.match(/^1$/); 37 | }); 38 | 39 | it('first button should call increment', () => { 40 | const { buttons, actions } = setup(); 41 | Simulate.click(buttons[0]); 42 | expect(actions.increment.called).to.be.true; 43 | }); 44 | 45 | it('second button should call decrement', () => { 46 | const { buttons, actions } = setup(); 47 | Simulate.click(buttons[1]); 48 | expect(actions.decrement.called).to.be.true; 49 | }); 50 | 51 | it('third button should call incrementIfOdd', () => { 52 | const { buttons, actions } = setup(); 53 | Simulate.click(buttons[2]); 54 | expect(actions.incrementIfOdd.called).to.be.true; 55 | }); 56 | 57 | it('fourth button should call incrementAsync', () => { 58 | const { buttons, actions } = setup(); 59 | Simulate.click(buttons[3]); 60 | expect(actions.incrementAsync.called).to.be.true; 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /test/containers/CounterPage.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import React from 'react'; 3 | import { 4 | renderIntoDocument, 5 | scryRenderedDOMComponentsWithTag, 6 | findRenderedDOMComponentWithClass, 7 | Simulate 8 | } from 'react-addons-test-utils'; 9 | import { Provider } from 'react-redux'; 10 | import CounterPage from '../../app/containers/CounterPage'; 11 | import configureStore from '../../app/store/configureStore'; 12 | 13 | 14 | function setup(initialState) { 15 | const store = configureStore(initialState); 16 | const app = renderIntoDocument( 17 | 18 | 19 | 20 | ); 21 | return { 22 | app, 23 | buttons: scryRenderedDOMComponentsWithTag(app, 'button').map(button => { 24 | return button; 25 | }), 26 | p: findRenderedDOMComponentWithClass(app, 'counter') 27 | }; 28 | } 29 | 30 | 31 | describe('containers', () => { 32 | describe('App', () => { 33 | it('should display initial count', () => { 34 | const { p } = setup(); 35 | expect(p.textContent).to.match(/^0$/); 36 | }); 37 | 38 | it('should display updated count after increment button click', () => { 39 | const { buttons, p } = setup(); 40 | Simulate.click(buttons[0]); 41 | expect(p.textContent).to.match(/^1$/); 42 | }); 43 | 44 | it('should display updated count after descrement button click', () => { 45 | const { buttons, p } = setup(); 46 | Simulate.click(buttons[1]); 47 | expect(p.textContent).to.match(/^-1$/); 48 | }); 49 | 50 | it('shouldnt change if even and if odd button clicked', () => { 51 | const { buttons, p } = setup(); 52 | Simulate.click(buttons[2]); 53 | expect(p.textContent).to.match(/^0$/); 54 | }); 55 | 56 | it('should change if odd and if odd button clicked', () => { 57 | const { buttons, p } = setup({ counter: 1 }); 58 | Simulate.click(buttons[2]); 59 | expect(p.textContent).to.match(/^2$/); 60 | }); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /test/e2e.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import chromedriver from 'chromedriver'; 3 | import webdriver from 'selenium-webdriver'; 4 | import { expect } from 'chai'; 5 | import electronPath from 'electron-prebuilt'; 6 | import homeStyles from '../app/components/Home.module.css'; 7 | import counterStyles from '../app/components/Counter.module.css'; 8 | 9 | chromedriver.start(); // on port 9515 10 | process.on('exit', chromedriver.stop); 11 | 12 | const delay = time => new Promise(resolve => setTimeout(resolve, time)); 13 | 14 | describe('main window', function spec() { 15 | this.timeout(5000); 16 | 17 | before(async () => { 18 | await delay(1000); // wait chromedriver start time 19 | this.driver = new webdriver.Builder() 20 | .usingServer('http://localhost:9515') 21 | .withCapabilities({ 22 | chromeOptions: { 23 | binary: electronPath, 24 | args: ['app=' + path.resolve()] 25 | } 26 | }) 27 | .forBrowser('electron') 28 | .build(); 29 | }); 30 | 31 | after(async () => { 32 | await this.driver.quit(); 33 | }); 34 | 35 | const findCounter = () => { 36 | return this.driver.findElement(webdriver.By.className(counterStyles.counter)); 37 | }; 38 | 39 | const findButtons = () => { 40 | return this.driver.findElements(webdriver.By.className(counterStyles.btn)); 41 | }; 42 | 43 | it('should open window', async () => { 44 | const title = await this.driver.getTitle(); 45 | expect(title).to.equal('Hello Electron React!'); 46 | }); 47 | 48 | it('should to Counter with click "to Counter" link', async () => { 49 | const link = await this.driver.findElement(webdriver.By.css(`.${homeStyles.container} > a`)); 50 | link.click(); 51 | 52 | const counter = await findCounter(); 53 | expect(await counter.getText()).to.equal('0'); 54 | }); 55 | 56 | it('should display updated count after increment button click', async () => { 57 | const buttons = await findButtons(); 58 | buttons[0].click(); 59 | 60 | const counter = await findCounter(); 61 | expect(await counter.getText()).to.equal('1'); 62 | }); 63 | 64 | it('should display updated count after descrement button click', async () => { 65 | const buttons = await findButtons(); 66 | const counter = await findCounter(); 67 | 68 | buttons[1].click(); // - 69 | 70 | expect(await counter.getText()).to.equal('0'); 71 | }); 72 | 73 | it('shouldnt change if even and if odd button clicked', async () => { 74 | const buttons = await findButtons(); 75 | const counter = await findCounter(); 76 | buttons[2].click(); // odd 77 | 78 | expect(await counter.getText()).to.equal('0'); 79 | }); 80 | 81 | it('should change if odd and if odd button clicked', async () => { 82 | const buttons = await findButtons(); 83 | const counter = await findCounter(); 84 | 85 | buttons[0].click(); // + 86 | buttons[2].click(); // odd 87 | 88 | expect(await counter.getText()).to.equal('2'); 89 | }); 90 | 91 | it('should change if async button clicked and a second later', async () => { 92 | const buttons = await findButtons(); 93 | const counter = await findCounter(); 94 | buttons[3].click(); // async 95 | 96 | expect(await counter.getText()).to.equal('2'); 97 | 98 | await this.driver.wait(() => 99 | counter.getText().then(text => text === '3') 100 | , 1000, 'count not as expected'); 101 | }); 102 | 103 | it('should back to home if back button clicked', async () => { 104 | const link = await this.driver.findElement(webdriver.By.css(`.${counterStyles.backButton} > a`)); 105 | link.click(); 106 | 107 | await this.driver.findElement(webdriver.By.className(homeStyles.container)); 108 | }); 109 | }); 110 | -------------------------------------------------------------------------------- /test/example.js: -------------------------------------------------------------------------------- 1 | /* eslint func-names: 0 */ 2 | import { expect } from 'chai'; 3 | 4 | 5 | describe('description', () => { 6 | it('description', () => { 7 | expect(1 + 2).to.equal(3); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /test/reducers/counter.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import counter from '../../app/reducers/counter'; 3 | import { INCREMENT_COUNTER, DECREMENT_COUNTER } from '../../app/actions/counter'; 4 | 5 | 6 | describe('reducers', () => { 7 | describe('counter', () => { 8 | it('should handle initial state', () => { 9 | expect(counter(undefined, {})).to.equal(0); 10 | }); 11 | 12 | it('should handle INCREMENT_COUNTER', () => { 13 | expect(counter(1, { type: INCREMENT_COUNTER })).to.equal(2); 14 | }); 15 | 16 | it('should handle DECREMENT_COUNTER', () => { 17 | expect(counter(1, { type: DECREMENT_COUNTER })).to.equal(0); 18 | }); 19 | 20 | it('should handle unknown action type', () => { 21 | expect(counter(1, { type: 'unknown' })).to.equal(1); 22 | }); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | import 'babel-polyfill'; 2 | import { jsdom } from 'jsdom'; 3 | import hook from 'css-modules-require-hook'; 4 | 5 | hook({ 6 | generateScopedName: '[name]__[local]___[hash:base64:5]' 7 | }); 8 | 9 | global.document = jsdom(''); 10 | global.window = document.defaultView; 11 | global.navigator = global.window.navigator; 12 | -------------------------------------------------------------------------------- /webpack.config.base.js: -------------------------------------------------------------------------------- 1 | /* eslint strict: 0 */ 2 | 'use strict'; 3 | 4 | const path = require('path'); 5 | 6 | module.exports = { 7 | module: { 8 | loaders: [{ 9 | test: /\.jsx?$/, 10 | loaders: ['babel-loader'], 11 | exclude: /node_modules/ 12 | }, 13 | { 14 | test : /\.woff|\.woff2|\.svg|.eot|\.ttf/, 15 | loader : 'url?prefix=font/&limit=10000' 16 | } 17 | ] 18 | }, 19 | output: { 20 | path: path.join(__dirname, 'dist'), 21 | filename: 'bundle.js', 22 | libraryTarget: 'commonjs2' 23 | }, 24 | resolve: { 25 | extensions: ['', '.js', '.jsx'], 26 | packageMains: ['webpack', 'browser', 'web', 'browserify', ['jam', 'main'], 'main'] 27 | }, 28 | plugins: [ 29 | 30 | ], 31 | externals: [ 32 | // put your node 3rd party libraries which can't be built with webpack here (mysql, mongodb, and so on..) 33 | 'ed25519-supercop' 34 | ] 35 | }; 36 | -------------------------------------------------------------------------------- /webpack.config.development.js: -------------------------------------------------------------------------------- 1 | /* eslint strict: 0 */ 2 | 'use strict'; 3 | 4 | const webpack = require('webpack'); 5 | const webpackTargetElectronRenderer = require('webpack-target-electron-renderer'); 6 | const baseConfig = require('./webpack.config.base'); 7 | 8 | 9 | const config = Object.create(baseConfig); 10 | 11 | config.debug = true; 12 | 13 | config.devtool = 'cheap-module-eval-source-map'; 14 | 15 | config.entry = [ 16 | 'webpack-hot-middleware/client?path=http://localhost:3000/__webpack_hmr', 17 | './app/index' 18 | ]; 19 | 20 | config.output.publicPath = 'http://localhost:3000/dist/'; 21 | 22 | config.module.loaders.push({ 23 | test: /^((?!\.module).)*\.css$/, 24 | loaders: [ 25 | 'style-loader', 26 | 'css-loader' 27 | ] 28 | }, { 29 | test: /\.module\.css$/, 30 | loaders: [ 31 | 'style-loader', 32 | 'css-loader?modules&importLoaders=1&localIdentName=[name]__[local]___[hash:base64:5]!' 33 | ] 34 | }); 35 | 36 | 37 | config.plugins.push( 38 | new webpack.HotModuleReplacementPlugin(), 39 | new webpack.NoErrorsPlugin(), 40 | new webpack.DefinePlugin({ 41 | '__DEV__': true, 42 | 'process.env': { 43 | 'NODE_ENV': JSON.stringify('development') 44 | } 45 | }) 46 | ); 47 | 48 | config.target = webpackTargetElectronRenderer(config); 49 | 50 | module.exports = config; 51 | -------------------------------------------------------------------------------- /webpack.config.production.js: -------------------------------------------------------------------------------- 1 | /* eslint strict: 0 */ 2 | 'use strict'; 3 | 4 | const webpack = require('webpack'); 5 | const ExtractTextPlugin = require('extract-text-webpack-plugin'); 6 | const webpackTargetElectronRenderer = require('webpack-target-electron-renderer'); 7 | const baseConfig = require('./webpack.config.base'); 8 | 9 | 10 | const config = Object.create(baseConfig); 11 | 12 | config.devtool = 'source-map'; 13 | 14 | config.entry = './app/index'; 15 | 16 | config.output.publicPath = '../dist/'; 17 | 18 | config.module.loaders.push({ 19 | test: /^((?!\.module).)*\.css$/, 20 | loader: ExtractTextPlugin.extract( 21 | 'style-loader', 22 | 'css-loader' 23 | ) 24 | }, { 25 | test: /\.module\.css$/, 26 | loader: ExtractTextPlugin.extract( 27 | 'style-loader', 28 | 'css-loader?modules&importLoaders=1&localIdentName=[name]__[local]___[hash:base64:5]' 29 | ) 30 | }); 31 | 32 | config.plugins.push( 33 | new webpack.optimize.OccurenceOrderPlugin(), 34 | new webpack.DefinePlugin({ 35 | '__DEV__': false, 36 | 'process.env': { 37 | 'NODE_ENV': JSON.stringify('production') 38 | } 39 | }), 40 | new webpack.optimize.UglifyJsPlugin({ 41 | compressor: { 42 | screw_ie8: true, 43 | warnings: false 44 | } 45 | }), 46 | new ExtractTextPlugin('style.css', { allChunks: true }) 47 | ); 48 | 49 | config.target = webpackTargetElectronRenderer(config); 50 | 51 | module.exports = config; 52 | -------------------------------------------------------------------------------- /winstaller.js: -------------------------------------------------------------------------------- 1 | const electronInstaller = require('electron-winstaller'); 2 | const path = require('path') 3 | 4 | var p = path.normalize('./release/win32-x64/PeerTweet-win32-x64') 5 | 6 | resultPromise = electronInstaller.createWindowsInstaller({ 7 | appDirectory: p, 8 | outputDirectory: path.normalize('./release'), 9 | authors: 'Luca Matteis', 10 | exe: 'PeerTweet.exe' 11 | }); 12 | 13 | resultPromise.then(() => console.log("It worked!"), (e) => console.log(`No dice: ${e.message}`)); 14 | --------------------------------------------------------------------------------