├── .csscomb.json ├── .gitignore ├── .node-version ├── .travis.yml ├── LICENSE ├── README.md ├── css ├── layout.css └── ratchet.min.css ├── images ├── Icon.png ├── Icon@1024.icns ├── Icon@2x.png ├── icon-comment.svg ├── icon-new.png ├── icon-new@2x.png └── screenshot.png ├── package.json ├── script ├── compress └── package └── src ├── client ├── main.js ├── menu.js ├── spinner.js ├── story.js ├── story_box.js └── story_list.js ├── index.html ├── index.js ├── logger.js ├── model ├── read_cache.js ├── story_manager_status.js └── story_type.js └── server ├── auto_update_manager.js ├── story_manager.js └── tray_manager.js /.csscomb.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": [ 3 | ".git/**", 4 | "node_modules/**" 5 | ], 6 | "always-semicolon": true, 7 | "block-indent": 2, 8 | "colon-space": ["", " "], 9 | "color-case": "lower", 10 | "color-shorthand": true, 11 | "combinator-space": [" ", " "], 12 | "element-case": "lower", 13 | "eof-newline": true, 14 | "leading-zero": false, 15 | "quotes": "single", 16 | "remove-empty-rulesets": true, 17 | "rule-indent": 2, 18 | "space-after-opening-brace": "\n", 19 | "space-before-opening-brace": 1, 20 | "space-before-closing-brace": "\n", 21 | "space-after-selector-delimiter": 1, 22 | "space-after-colon": 1, 23 | "space-before-colon": 0, 24 | "space-between-declarations": "\n", 25 | "sort-order": [ 26 | [ 27 | "$variable", 28 | "$include", 29 | "$import" 30 | ] 31 | ], 32 | "sort-order-fallback": "abc", 33 | "strip-spaces": true, 34 | "unitless-zero": true, 35 | "vendor-prefix-align": true 36 | } 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | Hacker Menu* 4 | newrelic.js 5 | build 6 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | v4.1.1 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | before_install: 3 | - brew update && brew install nvm 4 | - mkdir ~/.nvm && cp $(brew --prefix nvm)/nvm-exec ~/.nvm/ 5 | - export NVM_DIR=~/.nvm && source $(brew --prefix nvm)/nvm.sh 6 | - nvm install 4.1.1 7 | - npm upgrade -g npm 8 | install: 9 | - npm install 10 | script: 11 | - npm test 12 | after_script: 13 | - npm run release 14 | os: 15 | - osx 16 | notifications: 17 | email: 18 | on_success: never 19 | on_failure: change 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Jingwen Owen Ou 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a 4 | copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included 12 | in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 15 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 17 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 18 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 19 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 20 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hacker Menu 2 | 3 | [Hacker Menu](https://hackermenu.io/) stays on your menu bar and delivers the top news stories from [Y Combinator news aggregator](https://news.ycombinator.com/), 4 | built with love by [@jingweno](https://github.com/jingweno) & [@lokywin](https://github.com/lokywin). It's powered by [Electron](http://electron.atom.io/) and [Node.js](https://nodejs.org). 5 | 6 | Website: [https://hackermenu.io](https://hackermenu.io). 7 | 8 | Screenshot: 9 | ![Hacker Menu Screenshot](images/screenshot.png) 10 | 11 | ## Installation 12 | 13 | Download the latest build for your platform from [releases](https://github.com/jingweno/hacker-menu/releases). We only have OSX build now, and we're working Windows and Linux builds. Feel free to contribute if you can't wait :heart:. 14 | 15 | ## Development 16 | 17 | You need to have the latest [io.js](https://iojs.org) or [node.js](https://nodejs.org/) installed. 18 | 19 | ```bash 20 | $ npm install # installs dependencies 21 | $ npm start # starts the app in the electron wrapper 22 | ``` 23 | 24 | Other useful tasks: 25 | 26 | ```bash 27 | $ npm test # runs tests 28 | $ npm run build # builds the app 29 | $ npm run watch # watches and rebuilds the app 30 | $ npm run package # packages the Mac app 31 | $ npm run release # packages and zips the Mac app, it requires a cert to sign the app 32 | ``` 33 | 34 | # Roadmap 35 | 36 | See [issues](https://github.com/jingweno/hacker-menu/issues?q=is%3Aopen+is%3Aissue+label%3Afeature). 37 | 38 | 39 | ## License 40 | 41 | See [LICENSE](https://github.com/jingweno/hacker-menu/blob/master/LICENSE). 42 | -------------------------------------------------------------------------------- /css/layout.css: -------------------------------------------------------------------------------- 1 | /* overrides ratchet */ 2 | .content { 3 | background: white; 4 | margin: 0 0 44px !important; 5 | } 6 | 7 | ul, code { 8 | font-size: 14px; 9 | } 10 | 11 | .bar .status { 12 | color: #979797; 13 | font-size: 12px; 14 | padding-top: 13px; 15 | } 16 | 17 | .comment { 18 | font-size: 11px; 19 | } 20 | 21 | /* global */ 22 | body { 23 | font-family: 'Roboto', sans-serif; 24 | } 25 | 26 | .clickable { 27 | cursor: pointer; 28 | } 29 | 30 | .clickable:hover { 31 | color: #ff6601; 32 | } 33 | 34 | /* spinner */ 35 | .spinner:before, 36 | .spinner:after, 37 | .spinner { 38 | border-radius: 50%; 39 | width: 1.5em; 40 | height: 1.5em; 41 | animation-fill-mode: both; 42 | animation: animate-spinner 1.8s infinite ease-in-out; 43 | } 44 | 45 | .spinner { 46 | font-size: 10px; 47 | margin: 50px auto; 48 | position: relative; 49 | text-indent: -9999em; 50 | transform: translateZ(0); 51 | animation-delay: -0.16s; 52 | } 53 | 54 | .spinner:before { 55 | left: -2.5em; 56 | animation-delay: -0.32s; 57 | } 58 | 59 | .spinner:after { 60 | left: 2.5em; 61 | } 62 | 63 | .spinner:before, 64 | .spinner:after { 65 | content: ''; 66 | position: absolute; 67 | top: 0; 68 | } 69 | 70 | @keyframes animate-spinner { 71 | 0%, 72 | 80%, 73 | 100% { 74 | box-shadow: 0 2.5em 0 -1.3em #ff6601; 75 | } 76 | 40% { 77 | box-shadow: 0 2.5em 0 0 #ff6601; 78 | } 79 | } 80 | 81 | /* header */ 82 | .bar-nav { 83 | background: #f0f0f0; 84 | } 85 | 86 | .bar.bar-nav { 87 | padding: 0; 88 | } 89 | 90 | .bar .segmented-control { 91 | background: transparent; 92 | border: 0; 93 | border-radius: 0; 94 | top: auto; 95 | } 96 | 97 | .segmented-control .control-item { 98 | cursor: pointer; 99 | padding-bottom: 14px; 100 | padding-top: 18px; 101 | text-transform: uppercase; 102 | } 103 | 104 | .segmented-control .control-item.active { 105 | background: white; 106 | } 107 | 108 | /* content */ 109 | .table-view { 110 | border-bottom: 0; 111 | } 112 | 113 | .story { 114 | border-left: 4px solid #ff6601; 115 | color: inherit; 116 | margin: -11px -65px -11px -15px; 117 | overflow: hidden; 118 | padding: inherit; 119 | position: relative; 120 | } 121 | 122 | .story:hover { 123 | background: #fdfdfd; 124 | } 125 | 126 | .story.read { 127 | border-left-color: #bdbdbd; 128 | } 129 | 130 | .story .badge { 131 | background: #f0f0f0; 132 | font-size: .85rem; 133 | position: absolute; 134 | right: 15px; 135 | top: 27%; 136 | -webkit-transform: translateY(-50%); 137 | -ms-transform: translateY(-50%); 138 | transform: translateY(-50%); 139 | } 140 | 141 | .story-title { 142 | display: block; 143 | font-size: 16px; 144 | } 145 | 146 | .story-host { 147 | display: inline-block; 148 | font-size: 12px; 149 | font-style: italic; 150 | margin-top: .45rem; 151 | } 152 | 153 | .icon-comment:before { 154 | background: url(../images/icon-comment.svg) no-repeat center center; 155 | content: ''; 156 | display: block; 157 | float: left; 158 | height: 20px; 159 | margin-right: 5px; 160 | width: 20px; 161 | } 162 | 163 | .story-poster { 164 | font-size: 12px; 165 | font-style: italic; 166 | } 167 | 168 | /* footer */ 169 | .bar-footer { 170 | background: #f0f0f0; 171 | } 172 | 173 | .bar .btn { 174 | background: transparent; 175 | border: 0; 176 | border-left: 1px solid #979797; 177 | border-radius: 0; 178 | padding: 17px 14px 14px 23px; 179 | top: 0; 180 | } 181 | .bar .btn:active { 182 | outline: none; 183 | } -------------------------------------------------------------------------------- /css/ratchet.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * ===================================================== 3 | * Ratchet v2.0.2 (http://goratchet.com) 4 | * Copyright 2014 Connor Sears 5 | * Licensed under MIT (https://github.com/twbs/ratchet/blob/master/LICENSE) 6 | * 7 | * v2.0.2 designed by @connors. 8 | * ===================================================== 9 | *//*! normalize.css v3.0.1 | MIT License | git.io/normalize */html{font-family:sans-serif;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,hgroup,main,nav,section,summary{display:block}audio,canvas,progress,video{display:inline-block;vertical-align:baseline}audio:not([controls]){display:none;height:0}[hidden],template{display:none}a{background:0 0}a:active,a:hover{outline:0}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:700}dfn{font-style:italic}h1{margin:.67em 0}mark{color:#000;background:#ff0}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sup{top:-.5em}sub{bottom:-.25em}img{border:0}svg:not(:root){overflow:hidden}figure{margin:1em 40px}hr{height:0;-moz-box-sizing:content-box;box-sizing:content-box}pre{overflow:auto}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}button,input,optgroup,select,textarea{margin:0;font:inherit;color:inherit}button{overflow:visible}button,select{text-transform:none}button,html input[type=button],input[type=reset],input[type=submit]{-webkit-appearance:button;cursor:pointer}button[disabled],html input[disabled]{cursor:default}button::-moz-focus-inner,input::-moz-focus-inner{padding:0;border:0}input{line-height:normal}input[type=checkbox],input[type=radio]{box-sizing:border-box;padding:0}input[type=number]::-webkit-inner-spin-button,input[type=number]::-webkit-outer-spin-button{height:auto}input[type=search]::-webkit-search-cancel-button,input[type=search]::-webkit-search-decoration{-webkit-appearance:none}fieldset{padding:.35em .625em .75em;margin:0 2px;border:1px solid silver}legend{padding:0;border:0}textarea{overflow:auto}optgroup{font-weight:700}table{border-spacing:0;border-collapse:collapse}td,th{padding:0}*{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}body{position:fixed;top:0;right:0;bottom:0;left:0;font-family:"Helvetica Neue",Helvetica,sans-serif;font-size:17px;line-height:21px;color:#000;background-color:#fff}a{color:#428bca;text-decoration:none;-webkit-tap-highlight-color:transparent}a:active{color:#3071a9}.content{position:absolute;top:0;right:0;bottom:0;left:0;overflow:auto;-webkit-overflow-scrolling:touch;background-color:#fff}.content>*{-webkit-transform:translateZ(0);-ms-transform:translateZ(0);transform:translateZ(0)}.bar-nav~.content{padding-top:44px}.bar-header-secondary~.content{padding-top:88px}.bar-footer~.content{padding-bottom:44px}.bar-footer-secondary~.content{padding-bottom:88px}.bar-tab~.content{padding-bottom:50px}.bar-footer-secondary-tab~.content{padding-bottom:94px}.content-padded{margin:10px}.pull-left{float:left}.pull-right{float:right}.clearfix:after,.clearfix:before{display:table;content:" "}.clearfix:after{clear:both}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:10px;line-height:1}.h1,h1{font-size:36px}.h2,h2{font-size:30px}.h3,h3{font-size:24px}.h4,h4{font-size:18px}.h5,h5{margin-top:20px;font-size:14px}.h6,h6{margin-top:20px;font-size:12px}p{margin-top:0;margin-bottom:10px;font-size:14px;color:#777}.btn{position:relative;display:inline-block;padding:6px 8px 7px;margin-bottom:0;font-size:12px;font-weight:400;line-height:1;color:#333;text-align:center;white-space:nowrap;vertical-align:top;cursor:pointer;background-color:#fff;border:1px solid #ccc;border-radius:3px}.btn.active,.btn:active{color:inherit;background-color:#ccc}.btn.disabled,.btn:disabled{opacity:.6}.btn-primary{color:#fff;background-color:#428bca;border:1px solid #428bca}.btn-primary.active,.btn-primary:active{color:#fff;background-color:#3071a9;border:1px solid #3071a9}.btn-positive{color:#fff;background-color:#5cb85c;border:1px solid #5cb85c}.btn-positive.active,.btn-positive:active{color:#fff;background-color:#449d44;border:1px solid #449d44}.btn-negative{color:#fff;background-color:#d9534f;border:1px solid #d9534f}.btn-negative.active,.btn-negative:active{color:#fff;background-color:#c9302c;border:1px solid #c9302c}.btn-outlined{background-color:transparent}.btn-outlined.btn-primary{color:#428bca}.btn-outlined.btn-positive{color:#5cb85c}.btn-outlined.btn-negative{color:#d9534f}.btn-outlined.btn-negative:active,.btn-outlined.btn-positive:active,.btn-outlined.btn-primary:active{color:#fff}.btn-link{padding-top:6px;padding-bottom:6px;color:#428bca;background-color:transparent;border:0}.btn-link.active,.btn-link:active{color:#3071a9;background-color:transparent}.btn-block{display:block;width:100%;padding:15px 0;margin-bottom:10px;font-size:18px}input[type=button],input[type=reset],input[type=submit]{width:100%}.btn .badge{margin:-2px -4px -2px 4px;font-size:12px;background-color:rgba(0,0,0,.15)}.btn .badge-inverted,.btn:active .badge-inverted{background-color:transparent}.btn-negative:active .badge-inverted,.btn-positive:active .badge-inverted,.btn-primary:active .badge-inverted{color:#fff}.btn-block .badge{position:absolute;right:0;margin-right:10px}.btn .icon{font-size:inherit}.bar{position:fixed;right:0;left:0;z-index:10;height:44px;padding-right:10px;padding-left:10px;background-color:#fff;border-bottom:1px solid #ddd;-webkit-backface-visibility:hidden;backface-visibility:hidden}.bar-header-secondary{top:44px}.bar-footer{bottom:0}.bar-footer-secondary{bottom:44px}.bar-footer-secondary-tab{bottom:50px}.bar-footer,.bar-footer-secondary,.bar-footer-secondary-tab{border-top:1px solid #ddd;border-bottom:0}.bar-nav{top:0}.title{position:absolute;display:block;width:100%;padding:0;margin:0 -10px;font-size:17px;font-weight:500;line-height:44px;color:#000;text-align:center;white-space:nowrap}.title a{color:inherit}.bar-tab{bottom:0;display:table;width:100%;height:50px;padding:0;table-layout:fixed;border-top:1px solid #ddd;border-bottom:0}.bar-tab .tab-item{display:table-cell;width:1%;height:50px;color:#929292;text-align:center;vertical-align:middle}.bar-tab .tab-item.active,.bar-tab .tab-item:active{color:#428bca}.bar-tab .tab-item .icon{top:3px;width:24px;height:24px;padding-top:0;padding-bottom:0}.bar-tab .tab-item .icon~.tab-label{display:block;font-size:11px}.bar .btn{position:relative;top:7px;z-index:20;padding:6px 12px 7px;margin-top:0;font-weight:400}.bar .btn.pull-right{margin-left:10px}.bar .btn.pull-left{margin-right:10px}.bar .btn-link{top:0;padding:0;font-size:16px;line-height:44px;color:#428bca;border:0}.bar .btn-link.active,.bar .btn-link:active{color:#3071a9}.bar .btn-block{top:6px;padding:7px 0;margin-bottom:0;font-size:16px}.bar .btn-nav.pull-left{margin-left:-5px}.bar .btn-nav.pull-left .icon-left-nav{margin-right:-3px}.bar .btn-nav.pull-right{margin-right:-5px}.bar .btn-nav.pull-right .icon-right-nav{margin-left:-3px}.bar .icon{position:relative;z-index:20;padding-top:10px;padding-bottom:10px;font-size:24px}.bar .btn .icon{top:3px;padding:0}.bar .title .icon{padding:0}.bar .title .icon.icon-caret{top:4px;margin-left:-5px}.bar input[type=search]{height:29px;margin:6px 0}.bar .segmented-control{top:7px;margin:0 auto}.badge{display:inline-block;padding:2px 9px 3px;font-size:12px;line-height:1;color:#333;background-color:rgba(0,0,0,.15);border-radius:100px}.badge.badge-inverted{padding:0 5px 0 0;background-color:transparent}.badge-primary{color:#fff;background-color:#428bca}.badge-primary.badge-inverted{color:#428bca}.badge-positive{color:#fff;background-color:#5cb85c}.badge-positive.badge-inverted{color:#5cb85c}.badge-negative{color:#fff;background-color:#d9534f}.badge-negative.badge-inverted{color:#d9534f}.card{margin:10px;overflow:hidden;background-color:#fff;border:1px solid #ddd;border-radius:6px}.card .table-view{margin-bottom:0;border-top:0;border-bottom:0}.card .table-view .table-view-divider:first-child{top:0;border-top-left-radius:6px;border-top-right-radius:6px}.card .table-view .table-view-divider:last-child{border-bottom-right-radius:6px;border-bottom-left-radius:6px}.card .table-view-cell:last-child{border-bottom:0}.table-view{padding-left:0;margin-top:0;margin-bottom:15px;list-style:none;background-color:#fff;border-top:1px solid #ddd;border-bottom:1px solid #ddd}.table-view-cell{position:relative;padding:11px 65px 11px 15px;overflow:hidden;border-bottom:1px solid #ddd}.table-view-cell:last-child{border-bottom:0}.table-view-cell>a:not(.btn){position:relative;display:block;padding:inherit;margin:-11px -65px -11px -15px;overflow:hidden;color:inherit}.table-view-cell>a:not(.btn):active{background-color:#eee}.table-view-cell p{margin-bottom:0}.table-view-divider{padding-top:6px;padding-bottom:6px;padding-left:15px;margin-top:-1px;margin-left:0;font-weight:500;color:#999;background-color:#fafafa;border-top:1px solid #ddd;border-bottom:1px solid #ddd}.table-view .media,.table-view .media-body{overflow:hidden}.table-view .media-object.pull-left{margin-right:10px}.table-view .media-object.pull-right{margin-left:10px}.table-view-cell>.badge,.table-view-cell>.btn,.table-view-cell>.toggle,.table-view-cell>a>.badge,.table-view-cell>a>.btn,.table-view-cell>a>.toggle{position:absolute;top:50%;right:15px;-webkit-transform:translateY(-50%);-ms-transform:translateY(-50%);transform:translateY(-50%)}.table-view-cell .navigate-left>.badge,.table-view-cell .navigate-left>.btn,.table-view-cell .navigate-left>.toggle,.table-view-cell .navigate-right>.badge,.table-view-cell .navigate-right>.btn,.table-view-cell .navigate-right>.toggle,.table-view-cell .push-left>.badge,.table-view-cell .push-left>.btn,.table-view-cell .push-left>.toggle,.table-view-cell .push-right>.badge,.table-view-cell .push-right>.btn,.table-view-cell .push-right>.toggle,.table-view-cell>a .navigate-left>.badge,.table-view-cell>a .navigate-left>.btn,.table-view-cell>a .navigate-left>.toggle,.table-view-cell>a .navigate-right>.badge,.table-view-cell>a .navigate-right>.btn,.table-view-cell>a .navigate-right>.toggle,.table-view-cell>a .push-left>.badge,.table-view-cell>a .push-left>.btn,.table-view-cell>a .push-left>.toggle,.table-view-cell>a .push-right>.badge,.table-view-cell>a .push-right>.btn,.table-view-cell>a .push-right>.toggle{right:35px}.content>.table-view:first-child{margin-top:15px}button,input,select,textarea{font-family:"Helvetica Neue",Helvetica,sans-serif;font-size:17px}input[type=color],input[type=date],input[type=datetime-local],input[type=datetime],input[type=email],input[type=month],input[type=number],input[type=password],input[type=search],input[type=tel],input[type=text],input[type=time],input[type=url],input[type=week],select,textarea{width:100%;height:35px;-webkit-appearance:none;padding:0 15px;margin-bottom:15px;line-height:21px;background-color:#fff;border:1px solid #ddd;border-radius:3px;outline:0}input[type=search]{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box;padding:0 10px;font-size:16px;border-radius:20px}input[type=search]:focus{text-align:left}textarea{height:auto}select{height:auto;font-size:14px;background-color:#f8f8f8;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.1);box-shadow:inset 0 1px 1px rgba(0,0,0,.1)}.input-group{background-color:#fff}.input-group input,.input-group textarea{margin-bottom:0;background-color:transparent;border-top:0;border-right:0;border-left:0;border-radius:0;-webkit-box-shadow:none;box-shadow:none}.input-row{height:35px;overflow:hidden;border-bottom:1px solid #ddd}.input-row label{float:left;width:35%;padding:8px 15px;font-family:"Helvetica Neue",Helvetica,sans-serif;line-height:1.1}.input-row input{float:right;width:65%;padding-left:0;margin-bottom:0;border:0}.segmented-control{position:relative;display:table;overflow:hidden;font-size:12px;font-weight:400;background-color:#fff;border:1px solid #ccc;border-radius:3px}.segmented-control .control-item{display:table-cell;width:1%;padding-top:6px;padding-bottom:7px;overflow:hidden;line-height:1;color:#333;text-align:center;text-overflow:ellipsis;white-space:nowrap;border-left:1px solid #ccc}.segmented-control .control-item:first-child{border-left-width:0}.segmented-control .control-item:active{background-color:#eee}.segmented-control .control-item.active{background-color:#ccc}.segmented-control-primary{border-color:#428bca}.segmented-control-primary .control-item{color:#428bca;border-color:inherit}.segmented-control-primary .control-item:active{background-color:#cde1f1}.segmented-control-primary .control-item.active{color:#fff;background-color:#428bca}.segmented-control-positive{border-color:#5cb85c}.segmented-control-positive .control-item{color:#5cb85c;border-color:inherit}.segmented-control-positive .control-item:active{background-color:#d8eed8}.segmented-control-positive .control-item.active{color:#fff;background-color:#5cb85c}.segmented-control-negative{border-color:#d9534f}.segmented-control-negative .control-item{color:#d9534f;border-color:inherit}.segmented-control-negative .control-item:active{background-color:#f9e2e2}.segmented-control-negative .control-item.active{color:#fff;background-color:#d9534f}.control-content{display:none}.control-content.active{display:block}.popover{position:fixed;top:55px;left:50%;z-index:20;display:none;width:280px;margin-left:-140px;background-color:#fff;border-radius:6px;-webkit-box-shadow:0 0 15px rgba(0,0,0,.1);box-shadow:0 0 15px rgba(0,0,0,.1);opacity:0;-webkit-transition:all .25s linear;-moz-transition:all .25s linear;transition:all .25s linear;-webkit-transform:translate3d(0,-15px,0);-ms-transform:translate3d(0,-15px,0);transform:translate3d(0,-15px,0)}.popover:before{position:absolute;top:-15px;left:50%;width:0;height:0;margin-left:-15px;content:'';border-right:15px solid transparent;border-bottom:15px solid #fff;border-left:15px solid transparent}.popover.visible{opacity:1;-webkit-transform:translate3d(0,0,0);-ms-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}.popover .bar~.table-view{padding-top:44px}.backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:15;background-color:rgba(0,0,0,.3)}.popover .btn-block{margin-bottom:5px}.popover .btn-block:last-child{margin-bottom:0}.popover .bar-nav{border-bottom:1px solid #ddd;border-top-left-radius:12px;border-top-right-radius:12px;-webkit-box-shadow:none;box-shadow:none}.popover .table-view{max-height:300px;margin-bottom:0;overflow:auto;-webkit-overflow-scrolling:touch;background-color:#fff;border-top:0;border-bottom:0;border-radius:6px}.modal{position:fixed;top:0;z-index:11;width:100%;min-height:100%;overflow:hidden;background-color:#fff;opacity:0;-webkit-transition:-webkit-transform .25s,opacity 1ms .25s;-moz-transition:-moz-transform .25s,opacity 1ms .25s;transition:transform .25s,opacity 1ms .25s;-webkit-transform:translate3d(0,100%,0);-ms-transform:translate3d(0,100%,0);transform:translate3d(0,100%,0)}.modal.active{height:100%;opacity:1;-webkit-transition:-webkit-transform .25s;-moz-transition:-moz-transform .25s;transition:transform .25s;-webkit-transform:translate3d(0,0,0);-ms-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}.slider{width:100%;overflow:hidden;background-color:#000}.slider .slide-group{position:relative;font-size:0;white-space:nowrap;-webkit-transition:all 0s linear;-moz-transition:all 0s linear;transition:all 0s linear}.slider .slide-group .slide{display:inline-block;width:100%;height:100%;font-size:14px;vertical-align:top}.toggle{position:relative;display:block;width:74px;height:30px;background-color:#fff;border:2px solid #ddd;border-radius:20px;-webkit-transition-duration:.2s;-moz-transition-duration:.2s;transition-duration:.2s;-webkit-transition-property:background-color,border;-moz-transition-property:background-color,border;transition-property:background-color,border}.toggle .toggle-handle{position:absolute;top:-1px;left:-1px;z-index:2;width:28px;height:28px;background-color:#fff;border:1px solid #ddd;border-radius:100px;-webkit-transition-duration:.2s;-moz-transition-duration:.2s;transition-duration:.2s;-webkit-transition-property:-webkit-transform,border,width;-moz-transition-property:-moz-transform,border,width;transition-property:transform,border,width}.toggle:before{position:absolute;top:3px;right:11px;font-size:13px;color:#999;text-transform:uppercase;content:"Off"}.toggle.active{background-color:#5cb85c;border:2px solid #5cb85c}.toggle.active .toggle-handle{border-color:#5cb85c;-webkit-transform:translate3d(44px,0,0);-ms-transform:translate3d(44px,0,0);transform:translate3d(44px,0,0)}.toggle.active:before{right:auto;left:15px;color:#fff;content:"On"}.toggle input[type=checkbox]{display:none}.content.fade{left:0;opacity:0}.content.fade.in{opacity:1}.content.sliding{z-index:2;-webkit-transition:-webkit-transform .4s;-moz-transition:-moz-transform .4s;transition:transform .4s;-webkit-transform:translate3d(0,0,0);-ms-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}.content.sliding.left{z-index:1;-webkit-transform:translate3d(-100%,0,0);-ms-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0)}.content.sliding.right{z-index:3;-webkit-transform:translate3d(100%,0,0);-ms-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0)}.navigate-left:after,.navigate-right:after,.push-left:after,.push-right:after{position:absolute;top:50%;display:inline-block;font-family:Ratchicons;font-size:inherit;line-height:1;color:#bbb;text-decoration:none;-webkit-transform:translateY(-50%);-ms-transform:translateY(-50%);transform:translateY(-50%);-webkit-font-smoothing:antialiased}.navigate-left:after,.push-left:after{left:15px;content:'\e822'}.navigate-right:after,.push-right:after{right:15px;content:'\e826'}@font-face{font-family:Ratchicons;font-style:normal;font-weight:400;src:url(../fonts/ratchicons.eot);src:url(../fonts/ratchicons.eot?#iefix) format("embedded-opentype"),url(../fonts/ratchicons.woff) format("woff"),url(../fonts/ratchicons.ttf) format("truetype"),url(../fonts/ratchicons.svg#svgFontName) format("svg")}.icon{display:inline-block;font-family:Ratchicons;font-size:24px;line-height:1;text-decoration:none;-webkit-font-smoothing:antialiased}.icon-back:before{content:'\e80a'}.icon-bars:before{content:'\e80e'}.icon-caret:before{content:'\e80f'}.icon-check:before{content:'\e810'}.icon-close:before{content:'\e811'}.icon-code:before{content:'\e812'}.icon-compose:before{content:'\e813'}.icon-download:before{content:'\e815'}.icon-edit:before{content:'\e829'}.icon-forward:before{content:'\e82a'}.icon-gear:before{content:'\e821'}.icon-home:before{content:'\e82b'}.icon-info:before{content:'\e82c'}.icon-list:before{content:'\e823'}.icon-more-vertical:before{content:'\e82e'}.icon-more:before{content:'\e82f'}.icon-pages:before{content:'\e824'}.icon-pause:before{content:'\e830'}.icon-person:before{content:'\e832'}.icon-play:before{content:'\e816'}.icon-plus:before{content:'\e817'}.icon-refresh:before{content:'\e825'}.icon-search:before{content:'\e819'}.icon-share:before{content:'\e81a'}.icon-sound:before{content:'\e827'}.icon-sound2:before{content:'\e828'}.icon-sound3:before{content:'\e80b'}.icon-sound4:before{content:'\e80c'}.icon-star-filled:before{content:'\e81b'}.icon-star:before{content:'\e81c'}.icon-stop:before{content:'\e81d'}.icon-trash:before{content:'\e81e'}.icon-up-nav:before{content:'\e81f'}.icon-up:before{content:'\e80d'}.icon-right-nav:before{content:'\e818'}.icon-right:before{content:'\e826'}.icon-down-nav:before{content:'\e814'}.icon-down:before{content:'\e820'}.icon-left-nav:before{content:'\e82d'}.icon-left:before{content:'\e822'} -------------------------------------------------------------------------------- /images/Icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/owenthereal/hacker-menu/8084c27a8e002c7817f7767ece3b2a81db1b2128/images/Icon.png -------------------------------------------------------------------------------- /images/Icon@1024.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/owenthereal/hacker-menu/8084c27a8e002c7817f7767ece3b2a81db1b2128/images/Icon@1024.icns -------------------------------------------------------------------------------- /images/Icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/owenthereal/hacker-menu/8084c27a8e002c7817f7767ece3b2a81db1b2128/images/Icon@2x.png -------------------------------------------------------------------------------- /images/icon-comment.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /images/icon-new.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/owenthereal/hacker-menu/8084c27a8e002c7817f7767ece3b2a81db1b2128/images/icon-new.png -------------------------------------------------------------------------------- /images/icon-new@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/owenthereal/hacker-menu/8084c27a8e002c7817f7767ece3b2a81db1b2128/images/icon-new@2x.png -------------------------------------------------------------------------------- /images/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/owenthereal/hacker-menu/8084c27a8e002c7817f7767ece3b2a81db1b2128/images/screenshot.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hacker-menu", 3 | "productName": "Hacker Menu", 4 | "version": "1.1.5", 5 | "description": "A menu to read Hacker News", 6 | "main": "./dist/index.js", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/jingweno/hacker-menu.git" 10 | }, 11 | "engines": { 12 | "node": "4.1.1", 13 | "npm": "3.3.5" 14 | }, 15 | "bugs": { 16 | "url": "https://github.com/jingweno/hacker-menu/issues" 17 | }, 18 | "license": "MIT", 19 | "scripts": { 20 | "build": "babel src -D -d dist --compact true", 21 | "csscomb": "csscomb css/layout.css", 22 | "watch": "babel src -D -d dist -w", 23 | "test": "standard", 24 | "prestart": "npm run build", 25 | "start": "electron ./", 26 | "compress": "script/compress", 27 | "package": "npm run build && node script/package", 28 | "package_sign": "npm run build && node script/package --sign", 29 | "release": "npm run package_sign && npm run compress" 30 | }, 31 | "author": "Jingwen Owen Ou", 32 | "config": { 33 | "electron_version": "0.33.4" 34 | }, 35 | "devDependencies": { 36 | "babel": "^5.8.23", 37 | "babel-eslint": "^4.1.3", 38 | "csscomb": "^3.1.8", 39 | "electron-packager": "^5.1.0", 40 | "electron-prebuilt": "^0.33.4", 41 | "shelljs": "^0.5.3", 42 | "standard": "^5.3.1" 43 | }, 44 | "dependencies": { 45 | "async": "^1.4.2", 46 | "electron-rpc": "^1.0.3", 47 | "electron-window-state": "^1.0.0", 48 | "firebase": "^2.3.1", 49 | "fs-plus": "^2.8.1", 50 | "lodash": "^3.10.1", 51 | "lru-cache": "^2.7.0", 52 | "menubar": "^2.2.1", 53 | "moment": "^2.10.6", 54 | "newrelic-winston": "0.0.1", 55 | "react": "^0.13.3", 56 | "winston": "^1.0.2" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /script/compress: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | rm -rf release 6 | mkdir release 7 | 8 | cd 'build/Hacker Menu-darwin-x64' 9 | zip -9 -r --symlinks hacker-menu-mac.zip Hacker\ Menu.app 10 | mv hacker-menu-mac.zip ../../release 11 | -------------------------------------------------------------------------------- /script/package: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | var packager = require('electron-packager') 4 | var package = require('../package.json') 5 | var os = require('os') 6 | var _ = require('lodash') 7 | var path = require('path') 8 | var shell = require('shelljs/global') 9 | 10 | var platform = os.platform() 11 | var arch = os.arch() 12 | if (process.argv.indexOf('--all') !== -1) { 13 | arch = platform = 'all' 14 | } 15 | 16 | function parseDeps(current, deps) { 17 | deps = _.map(deps, function(p) { 18 | var pp = path.relative(current, p) 19 | return path.join('node_modules', path.basename(pp)) 20 | }) 21 | 22 | return _.uniq(deps) 23 | } 24 | 25 | var current = path.join(__dirname, '..', 'node_modules') 26 | 27 | var prodDeps = exec('npm list --production --parseable', {silent:true}).output.split('\n') 28 | prodDeps = parseDeps(current, prodDeps) 29 | 30 | var devDeps = exec('npm list --dev --parseable', {silent:true}).output.split('\n') 31 | devDeps = parseDeps(current, devDeps) 32 | 33 | devDeps = _.difference(devDeps, prodDeps) 34 | 35 | var opts = { 36 | dir: '.', 37 | name: 'Hacker Menu', 38 | overwrite: true, 39 | icon: 'images/Icon@1024.icns', 40 | platform: platform, 41 | arch: arch, 42 | out: 'build', 43 | version: package.config.electron_version, 44 | ignore: _.union(devDeps, [ 45 | 'src', 46 | 'script', 47 | 'build', 48 | 'release', 49 | 'images/(Icon@1024.icns|screenshot.png)' 50 | ]) 51 | } 52 | if (!process.env.CI && process.argv.indexOf('--sign') !== -1) { 53 | opts.sign = 'Developer ID Application: Jingwen Ou' 54 | } 55 | packager(opts, function done (err, appPaths) { 56 | if (err) { 57 | if (err.message) { 58 | console.error(err.message) 59 | } else { 60 | console.error(err, err.stack) 61 | } 62 | 63 | process.exit(1) 64 | } 65 | 66 | if (appPaths.length > 1) { 67 | console.error('Wrote new apps to:\n' + appPaths.join('\n')) 68 | } else if (appPaths.length === 1) { 69 | console.error('Wrote new app to', appPaths[0]) 70 | } 71 | }) 72 | -------------------------------------------------------------------------------- /src/client/main.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import StoryBox from './story_box.js' 3 | 4 | React.render( 5 | , 6 | document.getElementById('container') 7 | ) 8 | -------------------------------------------------------------------------------- /src/client/menu.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export default class Menu extends React.Component { 4 | handleOnClick (e) { 5 | e.preventDefault() 6 | this.props.onQuitClick() 7 | } 8 | 9 | render () { 10 | var statusText = 'v' + this.props.version 11 | var buttonText = 'Quit' 12 | if (this.props.status === 'update-available') { 13 | statusText += ' (v' + this.props.upgradeVersion + ' available, restart to upgrade)' 14 | buttonText = 'Restart' 15 | } 16 | 17 | return ( 18 |
19 | {statusText} 20 | 23 |
24 | ) 25 | } 26 | } 27 | 28 | Menu.propTypes = { 29 | status: React.PropTypes.string.isRequired, 30 | version: React.PropTypes.string.isRequired, 31 | upgradeVersion: React.PropTypes.string.isRequired, 32 | onQuitClick: React.PropTypes.func.isRequired 33 | } 34 | -------------------------------------------------------------------------------- /src/client/spinner.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export default class Spinner extends React.Component { 4 | render () { 5 | return ( 6 |
7 | Loading... 8 |
9 | ) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/client/story.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export default class Story extends React.Component { 4 | markAsRead () { 5 | this.props.onMarkAsRead(this.props.story.id) 6 | } 7 | 8 | openUrl (url) { 9 | this.props.onUrlClick(url) 10 | } 11 | 12 | handleYurlOnClick (e) { 13 | e.preventDefault() 14 | this.openUrl(this.props.story.yurl) 15 | } 16 | 17 | handleByOnClick (e) { 18 | e.preventDefault() 19 | this.openUrl(this.props.story.by_url) 20 | } 21 | 22 | handleUrlClick (e) { 23 | e.preventDefault() 24 | this.markAsRead() 25 | this.openUrl(this.props.story.url) 26 | } 27 | 28 | render () { 29 | var story = this.props.story 30 | var storyState 31 | if (story.hasRead) { 32 | storyState = 'story read' 33 | } else { 34 | storyState = 'story' 35 | } 36 | return ( 37 |
38 | {story.score} 39 |
40 | {story.title} 41 | {story.host} 42 |

43 | 44 | {story.descendants} 45 | –  46 | 47 | {story.by} 48 | –  49 | 50 | {story.timeAgo} 51 | 52 |

53 |
54 |
55 | ) 56 | } 57 | } 58 | 59 | Story.propTypes = { 60 | onUrlClick: React.PropTypes.func.isRequired, 61 | onMarkAsRead: React.PropTypes.func.isRequired, 62 | story: React.PropTypes.object.isRequired 63 | } 64 | -------------------------------------------------------------------------------- /src/client/story_box.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import _ from 'lodash' 3 | import Client from 'electron-rpc/client' 4 | import StoryList from './story_list.js' 5 | import Spinner from './spinner.js' 6 | import Menu from './menu.js' 7 | import StoryType from '../model/story_type' 8 | 9 | export default class StoryBox extends React.Component { 10 | constructor (props) { 11 | super(props) 12 | 13 | this.client = new Client() 14 | this.state = { 15 | stories: [], 16 | selected: StoryType.TOP_TYPE, 17 | status: '', 18 | version: '', 19 | upgradeVersion: '' 20 | } 21 | } 22 | 23 | componentDidMount () { 24 | var self = this 25 | 26 | self.client.request('current-version', function (err, version) { 27 | if (err) { 28 | console.error(err) 29 | return 30 | } 31 | 32 | self.setState({ version: version }) 33 | }) 34 | 35 | self.onNavbarClick(self.state.selected) 36 | } 37 | 38 | onQuitClick () { 39 | this.client.request('terminate') 40 | } 41 | 42 | onUrlClick (url) { 43 | this.client.request('open-url', { url: url }) 44 | } 45 | 46 | onMarkAsRead (id) { 47 | this.client.request('mark-as-read', { id: id }, function () { 48 | var story = _.findWhere(this.state.stories, { id: id }) 49 | story.hasRead = true 50 | this.setState({ stories: this.state.stories }) 51 | }.bind(this)) 52 | } 53 | 54 | onNavbarClick (selected) { 55 | var self = this 56 | 57 | self.setState({ stories: [], selected: selected }) 58 | self.client.localEventEmitter.removeAllListeners() 59 | 60 | self.client.on('update-available', function (err, releaseVersion) { 61 | if (err) { 62 | console.error(err) 63 | return 64 | } 65 | 66 | self.setState({ status: 'update-available', upgradeVersion: releaseVersion }) 67 | }) 68 | 69 | var storycb = function (err, storiesMap) { 70 | if (err) { 71 | return 72 | } 73 | 74 | // console.log(JSON.stringify(Object.keys(storiesMap), null, 2)) 75 | 76 | var stories = storiesMap[self.state.selected] 77 | if (!stories) { 78 | return 79 | } 80 | 81 | // console.log(JSON.stringify(stories, null, 2)) 82 | self.setState({stories: stories}) 83 | } 84 | 85 | self.client.request(selected, storycb) 86 | self.client.on(selected, storycb) 87 | } 88 | 89 | render () { 90 | var navNodes = _.map(StoryType.ALL, function (selection) { 91 | var className = 'control-item' 92 | if (this.state.selected === selection) { 93 | className = className + ' active' 94 | } 95 | return ( 96 | {selection} 97 | ) 98 | }, this) 99 | 100 | var content = null 101 | if (_.isEmpty(this.state.stories)) { 102 | content = 103 | } else { 104 | content = 105 | } 106 | 107 | return ( 108 |
109 |
110 |
111 | {navNodes} 112 |
113 |
114 | {content} 115 | 116 |
117 | ) 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/client/story_list.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Story from './story.js' 3 | import _ from 'lodash' 4 | 5 | export default class StoryList extends React.Component { 6 | render () { 7 | var onUrlClick = this.props.onUrlClick 8 | var onMarkAsRead = this.props.onMarkAsRead 9 | var storyNodes = _.map(this.props.stories, function (story, index) { 10 | return ( 11 |
  • 12 | 13 |
  • 14 | ) 15 | }) 16 | return ( 17 |
      18 | {storyNodes} 19 |
    20 | ) 21 | } 22 | } 23 | 24 | StoryList.propTypes = { 25 | onUrlClick: React.PropTypes.func.isRequired, 26 | onMarkAsRead: React.PropTypes.func.isRequired, 27 | stories: React.PropTypes.array.isRequired 28 | } 29 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
    12 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import Menubar from 'menubar' 2 | import Shell from 'shell' 3 | import App from 'app' 4 | import Server from 'electron-rpc/server' 5 | import Path from 'path' 6 | import _ from 'lodash' 7 | import AutoUpdateManager from './server/auto_update_manager' 8 | import StoryManager from './server/story_manager' 9 | import TrayManager from './server/tray_manager' 10 | import StoryType from './model/story_type' 11 | import ReadCache from './model/read_cache' 12 | import Logger from './logger' 13 | import windowStateKeeper from 'electron-window-state' 14 | 15 | var server = new Server() 16 | 17 | var windowState = windowStateKeeper({ 18 | defaultWidth: 400, 19 | defaultHeight: 400, 20 | path: Path.join(App.getPath('userData'), 'Configuration') 21 | }) 22 | 23 | var opts = { 24 | dir: __dirname, 25 | icon: Path.join(__dirname, '..', 'images', 'Icon.png'), 26 | iconNew: Path.join(__dirname, '..', 'images', 'Icon-new.png'), 27 | preloadWindow: true, 28 | width: windowState.width, 29 | height: windowState.height 30 | } 31 | var menu = Menubar(opts) 32 | var appDataPath = Path.join(menu.app.getPath('appData'), menu.app.getName()) 33 | var logger = Logger(appDataPath, menu.app.getVersion()) 34 | var readCache = new ReadCache(appDataPath, 500, logger) 35 | 36 | process.on('uncaughtException', function (error) { 37 | if (error) { 38 | logger.error('uncaughtException', { message: error.message, stack: error.stack }) 39 | } 40 | }) 41 | 42 | menu.on('after-create-window', function () { 43 | server.configure(menu.window.webContents) 44 | readCache.load() 45 | 46 | menu.window.webContents.on('new-window', function (e, url, frameName, disposition) { 47 | e.preventDefault() 48 | Shell.openExternal(url) 49 | }) 50 | 51 | menu.window.on('closed', function () { 52 | menu.window = null 53 | readCache.store() 54 | }) 55 | 56 | menu.window.on('close', function () { 57 | windowState.saveState(menu.window) 58 | }) 59 | }) 60 | 61 | menu.on('ready', function () { 62 | menu.tray.setToolTip('Hacker Menu') 63 | 64 | var autoUpdateManager = new AutoUpdateManager(menu.app.getVersion(), logger) 65 | autoUpdateManager.on('update-available', function (releaseVersion) { 66 | server.send('update-available', releaseVersion) 67 | }) 68 | 69 | var trayManager = new TrayManager(menu.window, menu.tray, opts.icon, opts.iconNew) 70 | 71 | var storyManager = new StoryManager(20, readCache) 72 | storyManager.on('new-story', function () { 73 | trayManager.notifyNewStories() 74 | }) 75 | storyManager.on('story-manager-status', function (status) { 76 | logger.info('story-manager-status', status) 77 | }) 78 | 79 | _.each(StoryType.ALL, function (type) { 80 | server.on(type, function (req, next) { 81 | storyManager.fetch(type, function (err, stories) { 82 | if (err) { 83 | logger.error('story-manager-fetch-error', { message: err.message, stack: err.stack }) 84 | return next(err) 85 | } 86 | 87 | var body = {} 88 | body[type] = stories 89 | 90 | next(null, body) 91 | }) 92 | }) 93 | 94 | storyManager.watch(type, function (err, stories) { 95 | if (err) { 96 | logger.error('story-manager-watch-error', { message: err.message, stack: err.stack }) 97 | return 98 | } 99 | 100 | var body = {} 101 | body[type] = stories 102 | 103 | server.send(type, body) 104 | }) 105 | }) 106 | 107 | server.on('current-version', function (req, next) { 108 | next(null, menu.app.getVersion()) 109 | }) 110 | 111 | server.on('terminate', function (e) { 112 | server.destroy() 113 | 114 | if (autoUpdateManager.isUpdateAvailable()) { 115 | autoUpdateManager.quitAndInstall() 116 | } else { 117 | menu.app.terminate() 118 | } 119 | }) 120 | 121 | server.on('open-url', function (req) { 122 | var url = _.trim(req.body.url, '#') 123 | Shell.openExternal(url) 124 | }) 125 | 126 | server.on('mark-as-read', function (req, next) { 127 | readCache.set(req.body.id) 128 | next() 129 | }) 130 | }) 131 | -------------------------------------------------------------------------------- /src/logger.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import Winston from 'winston' 3 | import NewrelicWinston from 'newrelic-winston' 4 | import Path from 'path' 5 | 6 | module.exports = function (appDataPath, version) { 7 | var env = 'prod' 8 | try { 9 | fs.statSync(Path.join(__dirname, 'newrelic.js')) 10 | } catch (err) { 11 | env = 'dev' 12 | } 13 | 14 | var logDir = Path.join(appDataPath, 'Log') 15 | try { 16 | fs.mkdirSync(logDir) 17 | } catch (e) { 18 | // ignore 19 | } 20 | 21 | var versionRewriter = function (level, msg, meta) { 22 | if (!meta) { 23 | meta = {} 24 | } 25 | 26 | meta.version = version 27 | return meta 28 | } 29 | 30 | return new Winston.Logger({ 31 | transports: [ 32 | new Winston.transports.Console(), 33 | new Winston.transports.DailyRotateFile({ filename: Path.join(logDir, 'app.log') }), 34 | new NewrelicWinston({ env: env }) 35 | ], 36 | rewriters: [ versionRewriter ] 37 | }) 38 | } 39 | -------------------------------------------------------------------------------- /src/model/read_cache.js: -------------------------------------------------------------------------------- 1 | import LRU from 'lru-cache' 2 | import path from 'path' 3 | import fs from 'fs-plus' 4 | import _ from 'lodash' 5 | 6 | export default class ReadCache { 7 | constructor (folder, cacheSize, logger) { 8 | this.path = path.join(folder, 'Storage', 'db.json') 9 | this.cache = LRU({ 10 | max: cacheSize 11 | }) 12 | this.logger = logger 13 | 14 | setInterval(function () { 15 | this.store() 16 | }.bind(this), 1000 * 60 * 60) // every hour 17 | } 18 | 19 | load () { 20 | var result = {} 21 | try { 22 | result = JSON.parse(fs.readFileSync(this.path, 'utf8')) 23 | } catch (e) { 24 | if (e.code !== 'ENOENT') { 25 | this.logger.error('read-cache.error', { message: e.message, stack: e.stack }) 26 | } 27 | } 28 | 29 | if (result['read']) { 30 | _.each(result['read'].reverse(), function (val) { 31 | this.set(val) 32 | }, this) 33 | } 34 | } 35 | 36 | store () { 37 | this.logger.info('read-cache.store') 38 | 39 | var result = [] 40 | this.cache.forEach(function (value, key) { 41 | result.push(key) 42 | }) 43 | fs.writeFileSync(this.path, JSON.stringify({ read: result }), 'utf8') 44 | } 45 | 46 | set (val) { 47 | return this.cache.set(val, val) 48 | } 49 | 50 | contains (val) { 51 | return this.cache.has(val) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/model/story_manager_status.js: -------------------------------------------------------------------------------- 1 | export default class StoryManagerStatus {} 2 | 3 | StoryManagerStatus.SYNCING_STATUS = 'syncing' 4 | StoryManagerStatus.UPDATED_STATUS = 'updated' 5 | -------------------------------------------------------------------------------- /src/model/story_type.js: -------------------------------------------------------------------------------- 1 | export default class StoryType {} 2 | 3 | StoryType.TOP_TYPE = 'top' 4 | StoryType.SHOW_TYPE = 'show' 5 | StoryType.ASK_TYPE = 'ask' 6 | StoryType.ALL = [ StoryType.TOP_TYPE, StoryType.SHOW_TYPE, StoryType.ASK_TYPE ] 7 | -------------------------------------------------------------------------------- /src/server/auto_update_manager.js: -------------------------------------------------------------------------------- 1 | import Events from 'events' 2 | import autoUpdater from 'auto-updater' 3 | 4 | export default class AutoUpdateManager extends Events.EventEmitter { 5 | constructor (version, logger) { 6 | super() 7 | this.version = version 8 | // this.feedUrl = 'http://localhost:5000/updates?version=' + version 9 | this.feedUrl = 'https://hackermenu.io/updates?version=' + version 10 | this.state = AutoUpdateManager.IDLE_STATE 11 | this.logger = logger 12 | 13 | process.nextTick(this.setupAutoUpdater.bind(this)) 14 | } 15 | 16 | setupAutoUpdater () { 17 | var self = this 18 | 19 | autoUpdater.on('error', function (event, message) { 20 | self.setState(AutoUpdateManager.ERROR_STATE) 21 | self.logger.error('auto-updater-manager.error', { error: message }) 22 | }) 23 | 24 | autoUpdater.setFeedUrl(this.feedUrl) 25 | 26 | autoUpdater.on('checking-for-update', function () { 27 | self.setState(AutoUpdateManager.CHECKING_STATE) 28 | }) 29 | 30 | autoUpdater.on('update-not-available', function () { 31 | self.setState(AutoUpdateManager.NO_UPDATE_AVAILABLE_STATE) 32 | }) 33 | 34 | autoUpdater.on('update-available', function () { 35 | self.setState(AutoUpdateManager.DOWNLOADING_STATE) 36 | }) 37 | 38 | autoUpdater.on('update-downloaded', function (event, releaseNotes, releaseName, releaseDate, updateUrl) { 39 | self.setState(AutoUpdateManager.UPDATE_AVAILABLE_STATE) 40 | self.logger.info('auto-update-manager.update-downloaded', {releaseNotes: releaseNotes, releaseName: releaseName, releaseDate: releaseDate, updateUrl: updateUrl}) 41 | 42 | self.emit('update-available', releaseName) 43 | }) 44 | 45 | this.scheduleUpdateCheck() 46 | } 47 | 48 | setState (state) { 49 | this.state = state 50 | this.emit('state-changed', state) 51 | this.logger.info('auto-update-manager.state-changed', { state: state }) 52 | } 53 | 54 | scheduleUpdateCheck () { 55 | var fourHours = 1000 * 60 * 60 * 4 56 | setInterval(this.checkForUpdates.bind(this), fourHours) 57 | this.checkForUpdates() 58 | } 59 | 60 | checkForUpdates () { 61 | autoUpdater.checkForUpdates() 62 | } 63 | 64 | quitAndInstall () { 65 | autoUpdater.quitAndInstall() 66 | } 67 | 68 | isUpdateAvailable () { 69 | return this.state === AutoUpdateManager.UPDATE_AVAILABLE_STATE 70 | } 71 | } 72 | 73 | AutoUpdateManager.IDLE_STATE = 'idle' 74 | AutoUpdateManager.CHECKING_STATE = 'checking' 75 | AutoUpdateManager.DOWNLOADING_STATE = 'downloading' 76 | AutoUpdateManager.UPDATE_AVAILABLE_STATE = 'update-available' 77 | AutoUpdateManager.NO_UPDATE_AVAILABLE_STATE = 'no-update-available' 78 | AutoUpdateManager.ERROR_STATE = 'error' 79 | -------------------------------------------------------------------------------- /src/server/story_manager.js: -------------------------------------------------------------------------------- 1 | import Events from 'events' 2 | import Firebase from 'firebase' 3 | import Moment from 'moment' 4 | import URL from 'url' 5 | import _ from 'lodash' 6 | import async from 'async' 7 | import StoryType from '../model/story_type' 8 | import StoryManagerStatus from '../model/story_manager_status' 9 | 10 | export default class StoryManager extends Events.EventEmitter { 11 | constructor (maxNumOfStories, cache) { 12 | super() 13 | this.maxNumOfStories = maxNumOfStories 14 | this.fb = new Firebase('https://hacker-news.firebaseio.com/v0') 15 | this.cache = cache 16 | this.stories = {} 17 | } 18 | 19 | fetchStory (storyId, callback) { 20 | var self = this 21 | 22 | var error = function (err) { 23 | callback(err) 24 | } 25 | 26 | var success = function (storySnapshot) { 27 | var story = storySnapshot.val() 28 | if (!story) { 29 | callback(new Error('Error loading ' + storySnapshot.key())) 30 | return 31 | } 32 | 33 | story.timeAgo = Moment.unix(story.time).fromNow() 34 | story.yurl = self.getYurl(story.id) 35 | story.by_url = self.getByUrl(story.by) 36 | if (_.isEmpty(story.url)) { 37 | story.url = story.yurl 38 | } else { 39 | story.host = URL.parse(story.url).hostname 40 | } 41 | if (_.isUndefined(story.descendants)) { 42 | story.descendants = 0 43 | } 44 | 45 | if (self.cache.contains(story.id)) { 46 | story.hasRead = true 47 | } else { 48 | story.hasRead = false 49 | } 50 | 51 | // console.log(JSON.stringify(story, null, 2)) 52 | 53 | callback(null, story) 54 | } 55 | 56 | self.fb.child('item/' + storyId).once('value', success, error) 57 | } 58 | 59 | fetch (type, callback) { 60 | var self = this 61 | 62 | var error = function (err) { 63 | callback(err) 64 | } 65 | 66 | var success = function (storyIds) { 67 | self.emit('story-manager-status', { type: type, status: StoryManagerStatus.SYNCING_STATUS }) 68 | async.map(storyIds.val(), self.fetchStory.bind(self), function (err, stories) { 69 | callback(err, stories) 70 | if (err) { 71 | return 72 | } 73 | 74 | self.emit('story-manager-status', { type: type, status: StoryManagerStatus.UPDATED_STATUS }) 75 | }) 76 | } 77 | 78 | self.fb.child(self.getChildName(type)).limitToFirst(self.maxNumOfStories).once('value', success, error) 79 | } 80 | 81 | watch (type, callback) { 82 | var self = this 83 | 84 | if (callback) { 85 | self.on(type, callback) 86 | } 87 | 88 | var error = function (err) { 89 | self.emit(type, err) 90 | } 91 | 92 | var success = function (storyIds) { 93 | self.emit('story-manager-status', { type: type, status: StoryManagerStatus.SYNCING_STATUS }) 94 | async.map(storyIds.val(), self.fetchStory.bind(self), function (err, stories) { 95 | self.emit(type, err, stories) 96 | if (err) { 97 | return 98 | } 99 | 100 | self.emit('story-manager-status', { type: type, status: StoryManagerStatus.UPDATED_STATUS }) 101 | 102 | if (!self.stories[type]) { 103 | self.stories[type] = [] 104 | } 105 | var newStories = self.filterNewStories(stories, self.stories[type]) 106 | if (!_.isEmpty(newStories)) { 107 | self.emit('new-story', newStories) 108 | } 109 | self.stories[type] = stories 110 | }) 111 | } 112 | 113 | self.fb.child(self.getChildName(type)).limitToFirst(self.maxNumOfStories).on('value', success, error) 114 | } 115 | 116 | filterNewStories (updatedStories, oldStories) { 117 | return _.filter(updatedStories, function (story) { 118 | return !story.hasRead && !_.findWhere(oldStories, { id: story.id }) 119 | }) 120 | } 121 | 122 | getYurl (id) { 123 | return 'https://news.ycombinator.com/item?id=' + id 124 | } 125 | 126 | getByUrl (by) { 127 | return 'https://news.ycombinator.com/user?id=' + by 128 | } 129 | 130 | getChildName (type) { 131 | var child = '' 132 | if (type === StoryType.TOP_TYPE) { 133 | child = 'topstories' 134 | } else if (type === StoryType.SHOW_TYPE) { 135 | child = 'showstories' 136 | } else if (type === StoryType.ASK_TYPE) { 137 | child = 'askstories' 138 | } else { 139 | throw new Error('Unsupported watch type ' + type) 140 | } 141 | 142 | return child 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/server/tray_manager.js: -------------------------------------------------------------------------------- 1 | import NativeImage from 'native-image' 2 | 3 | export default class TrayManager { 4 | constructor (window, tray, icon, iconNew) { 5 | this.window = window 6 | this.tray = tray 7 | this.icon = icon 8 | this.iconNew = iconNew 9 | this.hasNewStories = false 10 | this.setUpTrayListeners() 11 | } 12 | 13 | setUpTrayListeners () { 14 | var self = this 15 | var setIcon = function () { 16 | if (self.hasNewStories) { 17 | self.tray.setImage(NativeImage.createFromPath(self.icon)) 18 | self.hasNewStories = false 19 | } 20 | } 21 | this.tray.on('clicked', setIcon) 22 | this.tray.on('double-clicked', setIcon) 23 | } 24 | 25 | notifyNewStories () { 26 | if (!this.hasNewStories && !this.window.isVisible()) { 27 | this.tray.setImage(NativeImage.createFromPath(this.iconNew)) 28 | this.hasNewStories = true 29 | } 30 | } 31 | } 32 | --------------------------------------------------------------------------------