├── .babelrc ├── .eslintignore ├── .gitignore ├── LICENSE.md ├── README.md ├── TODO.md ├── app.yaml ├── hn-server-fetch.js ├── nwb.config.js ├── package.json ├── public ├── css │ └── style.css ├── img │ ├── android-chrome-144x144.png │ ├── android-chrome-192x192.png │ ├── android-chrome-36x36.png │ ├── android-chrome-48x48.png │ ├── android-chrome-72x72.png │ ├── android-chrome-96x96.png │ ├── apple-touch-icon-114x114.png │ ├── apple-touch-icon-120x120.png │ ├── apple-touch-icon-144x144.png │ ├── apple-touch-icon-152x152.png │ ├── apple-touch-icon-180x180.png │ ├── apple-touch-icon-57x57.png │ ├── apple-touch-icon-60x60.png │ ├── apple-touch-icon-72x72.png │ ├── apple-touch-icon-76x76.png │ ├── apple-touch-icon-precomposed.png │ ├── apple-touch-icon.png │ ├── browserconfig.xml │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon-96x96.png │ ├── favicon.ico │ ├── logo.png │ ├── mstile-144x144.png │ ├── mstile-150x150.png │ ├── mstile-310x150.png │ ├── mstile-310x310.png │ ├── mstile-70x70.png │ ├── safari-pinned-tab.svg │ └── splashscreen-icon-384x384.png ├── index-static.html ├── manifest.json ├── runtime-caching.js └── service-worker.js ├── screenshot.png ├── server.js ├── src ├── App.js ├── Comment.js ├── DisplayComment.js ├── DisplayListItem.js ├── Item.js ├── NotFound.js ├── Paginator.js ├── PermalinkedComment.js ├── PollOption.js ├── Settings.js ├── Spinner.js ├── Stories.js ├── StoryListItem.js ├── Updates.js ├── UserProfile.js ├── index.js ├── mixins │ ├── CommentMixin.js │ ├── ItemMixin.js │ ├── ListItemMixin.js │ └── PageNumberMixin.js ├── routes.js ├── services │ ├── HNService.js │ └── HNServiceRest.js ├── stores │ ├── CommentThreadStore.js │ ├── ItemStore.js │ ├── SettingsStore.js │ ├── StoryCommentThreadStore.js │ ├── StoryStore.js │ └── UpdatesStore.js ├── utils │ ├── buildClassName.js │ ├── cancellableDebounce.js │ ├── constants.js │ ├── extend.js │ ├── pageCalc.js │ ├── pluralise.js │ ├── setTitle.js │ └── storage.js └── views │ └── index.ejs └── sw-precache-config.json /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-0", "react"] 3 | } -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | public/build/ 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /public/build 2 | /node_modules 3 | /public/sw-toolbox.js 4 | dist -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Jonny Buchanan 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 | 21 | --- 22 | 23 | `cancellableDebounce()` is based on the implementation of `_.debounce()` from 24 | [Underscore.js](http://underscorejs.org) 1.7.0: 25 | 26 | Copyright (c) 2009-2014 Jeremy Ashkenas, DocumentCloud and Investigative 27 | Reporters & Editors 28 | 29 | Permission is hereby granted, free of charge, to any person 30 | obtaining a copy of this software and associated documentation 31 | files (the "Software"), to deal in the Software without 32 | restriction, including without limitation the rights to use, 33 | copy, modify, merge, publish, distribute, sublicense, and/or sell 34 | copies of the Software, and to permit persons to whom the 35 | Software is furnished to do so, subject to the following 36 | conditions: 37 | 38 | The above copyright notice and this permission notice shall be 39 | included in all copies or substantial portions of the Software. 40 | 41 | --- 42 | 43 | CSS3 animated spinners from [SpinKit](https://github.com/tobiasahlin/SpinKit): 44 | 45 | Copyright (c) 2014 Tobias Ahlin 46 | 47 | Permission is hereby granted, free of charge, to any person obtaining a copy of 48 | this software and associated documentation files (the "Software"), to deal in 49 | the Software without restriction, including without limitation the rights to 50 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 51 | the Software, and to permit persons to whom the Software is furnished to do so, 52 | subject to the following conditions: 53 | 54 | The above copyright notice and this permission notice shall be included in all 55 | copies or substantial portions of the Software. 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [preact-hn](https://preact-hn.appspot.com) 2 | 3 | A Preact port of [ReactHN](https://react-hn.appspot.com) - a [React](http://facebook.github.io/react) & 4 | [react-router](https://github.com/rackt/react-router)-powered implementation of 5 | [Hacker News](https://news.ycombinator.com) using its 6 | [Firebase API](https://github.com/HackerNews/API). 7 | 8 | This is mostly made possible thanks to `nwb --preact`. We need both 9 | Preact and preact-compat to get this all working. [WebPageTest](https://www.webpagetest.org/video/compare.php?tests=161016_VD_CR0,161016_61_CR1) comparison of the React and Preact versions. 10 | 11 | [![react-hn screenshot](https://github.com/insin/react-hn/raw/master/screenshot.png "New comment highlighting in react-hn")](https://react-hn.appspot.com) 12 | 13 | Live version: https://preact-hn.appspot.com 14 | 15 | Build differences: 16 | 17 | React: 18 | 19 | ``` 20 | ✔ Building React app 21 | 22 | File sizes after gzip: 23 | 24 | dist/vendor.511483ef.js 114.19 KB 25 | dist/app.8b44e34e.js 9.69 KB 26 | dist/sw-toolbox.js 5.84 KB 27 | dist/css/style.css 1.8 KB 28 | dist/PermalinkedComment.7106819e.js 1.56 KB 29 | dist/UserProfile.60e03f1c.js 618 B 30 | dist/core.js 590 B 31 | dist/runtime-caching.js 511 B 32 | dist/NotFound.c4c69d8e.js 214 B 33 | dist/build/vendor.js 186 B 34 | dist/service-worker.js 131 B 35 | ``` 36 | 37 | Preact: 38 | 39 | ``` 40 | ✔ Building Preact app 41 | 42 | File sizes after gzip: 43 | 44 | dist/vendor.4833966e.js 79.34 KB 45 | dist/app.8b44e34e.js 9.68 KB 46 | dist/sw-toolbox.js 5.84 KB 47 | dist/service-worker.js 4.24 KB 48 | dist/css/style.css 1.8 KB 49 | dist/PermalinkedComment.7106819e.js 1.56 KB 50 | dist/UserProfile.60e03f1c.js 618 B 51 | dist/core.js 590 B 52 | dist/runtime-caching.js 511 B 53 | dist/NotFound.c4c69d8e.js 214 B 54 | dist/build/vendor.js 186 B 55 | ``` 56 | 57 | ## Features 58 | 59 | * Supports display of all item types: 60 | [stories](https://preact-hn.appspot.com/#/story/8863), 61 | [jobs](https://preact-hn.appspot.com/#/job/8426937), 62 | [polls](https://preact-hn.appspot.com/#/poll/126809) and 63 | [comments](https://preact-hn.appspot.com/#/comment/8054455) 64 | * Basic [user profiles](https://preact-hn.appspot.com/#/user/patio11) 65 | * Collapsible comment threads, with child counts 66 | * "Realtime" updates (free via Firebase!) 67 | * Last visit details for stories are cached in `localStorage` 68 | * New comments are highlighted: 69 | * Comments since your last visit to an item 70 | * New comments which load while you're reading an item 71 | * New comments in collapsed threads 72 | * Automatic or manual collapsing of comment threads which don't contain any new 73 | comments 74 | * Stories with new comments are marked on list pages 75 | * Stories can be marked as read to remove highighting from new comments 76 | * "comments" sections driven by the Changed Items API 77 | * Story listing pages are cached in `sessionStorage` for quick back button usage 78 | and pagination in the same session 79 | * Configurable settings: 80 | * auto collapse - automatically collapse comment threads without new comments 81 | on page load 82 | * show reply links - show "reply" links to Hacker News 83 | * show dead - show items flagged as dead 84 | * show deleted - show comments flagged as deleted in threads 85 | * Delayed comment detection - so tense! Who will it be? What will they say? 86 | 87 | [Feature requests are welcome!](https://github.com/addyosmani/preact-hn/issues/new) 88 | 89 | ## Building 90 | 91 | Install dependencies: 92 | 93 | ``` 94 | npm install 95 | ``` 96 | 97 | ### npm scripts 98 | 99 | * `npm start` - start development server 100 | * `npm run build` - build into the `public/` directory 101 | * `npm run lint` - lint `src/` 102 | * `npm run lint:fix` - lint `src/` and auto-fix issues where possible 103 | * `npm run precache` - generates Service Worker in `public/` directory 104 | 105 | ## MIT Licensed 106 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | Split caches into their own module 2 | 3 | Improve styling, offer HN-alike style as an option (see below) 4 | 5 | Filter items by type/title/date etc. etc. 6 | 7 | Filter stories you've read/aren't interested in 8 | 9 | (In lieu of API for saved stories) Manual saving of stories 10 | 11 | Settings 12 | * username 13 | * themes (alt CSS, user CSS) 14 | * max number of cached updates (stories / comments) 15 | * always poll topstories/updates options? 16 | 17 | User submissions 18 | * API: One big list of ids for stories, polls and comments 19 | 20 | ## Fancy or OTT 21 | 22 | Animation when stories change position as updates are received 23 | 24 | Highlighted minimap/scroll highlighter to show where new comments are 25 | 26 | Tracking of discussions as they happen: 27 | * Use shades of highlighting as the age of a new comment varies 28 | * Option to preserve thread ordering while reading? 29 | 30 | Nosiness setting: 31 | * Give comments which are deleted while the thread is being viewed a different 32 | highlight to the rest (dimmed?) 33 | * Give posts which are edited a different highlight to the rest and provide a 34 | means of viewing the diff 35 | 36 | ## Future: Server - topstories use case as POC 37 | 38 | Use Firebase client to listen to and cache topstories and their items 39 | 40 | Pre-render top stories URLs and send current cache to client 41 | * Client could then communicate with the server instead of Firebase for topstories 42 | -------------------------------------------------------------------------------- /app.yaml: -------------------------------------------------------------------------------- 1 | runtime: nodejs 2 | vm: true 3 | env_variables: 4 | NODE_ENV: production 5 | handlers: 6 | - url: /.* 7 | script: IGNORED 8 | secure: always 9 | skip_files: 10 | - ^(.*/)?.*/node_modules/.*$ 11 | manual_scaling: 12 | instances: 20 -------------------------------------------------------------------------------- /hn-server-fetch.js: -------------------------------------------------------------------------------- 1 | require('isomorphic-fetch') 2 | 3 | /* 4 | The official Firebase API (https://github.com/HackerNews/API) requires multiple network 5 | connections to be made in order to fetch the list of Top Stories (indices) and then the 6 | summary content of these stories. Directly requesting these resources makes server-side 7 | rendering cumbersome as it is slow and ultimately requires that you maintain your own 8 | cache to ensure full server renders are efficient. 9 | 10 | To work around this problem, we can use one of the unofficial Hacker News APIs, specifically 11 | https://github.com/cheeaun/node-hnapi which directly returns the Top Stories and can cache 12 | responses for 10 minutes. In ReactHN, we can use the unofficial API for a static server-side 13 | render and then 'hydrate' this once our components have mounted to display the real-time 14 | experience. 15 | 16 | The benefit of this is clients loading up the app that are on flakey networks (or lie-fi) 17 | can still get a fast render of content before the rest of our JavaScript bundle is loaded. 18 | */ 19 | 20 | /** 21 | * Fetch top stories 22 | */ 23 | exports.fetchNews = function(page) { 24 | page = page || '' 25 | return fetch('http://node-hnapi.herokuapp.com/news' + page).then(function(response) { 26 | return response.json() 27 | }).then(function(json) { 28 | var stories = '
    ' 29 | json.forEach(function(data, index) { 30 | var story = '
  1. ' + 31 | '
    ' + data.title + ' ' + 32 | '(' + data.domain + ')
    ' + 33 | '
    ' + data.points + ' points ' + 34 | 'by ' + data.user + ' ' + 35 | ' | ' + 36 | '' + data.comments_count + ' comments
    ' 37 | '
  2. ' 38 | stories += story 39 | }) 40 | stories += '
' 41 | return stories 42 | }) 43 | } 44 | 45 | function renderNestedComment(data) { 46 | return '
' + 47 | '
' + 48 | '
' + 49 | '
[–] ' + 50 | '' + data.user + ' ' + 51 | ' ' + 52 | 'link
' + 53 | '
' + 54 | '
' + data.content +'
' + 55 | '

reply

' + 56 | '
' + 57 | '
' + 58 | '
' + 59 | '
' 60 | } 61 | 62 | function generateNestedCommentString(data) { 63 | var output = '' 64 | data.comments.forEach(function(comment) { 65 | output+= renderNestedComment(comment) 66 | if (comment.comments) { 67 | output+= generateNestedCommentString(comment) 68 | } 69 | }) 70 | return output 71 | } 72 | 73 | /** 74 | * Fetch details of the story/post/item with (nested) comments 75 | * TODO: Add article summary at top of nested comment thread 76 | */ 77 | exports.fetchItem = function(itemId) { 78 | return fetch('https://node-hnapi.herokuapp.com/item/' + itemId).then(function(response) { 79 | return response.json() 80 | }).then(function(json) { 81 | var comments = '' 82 | json.comments.forEach(function(data, index) { 83 | var comment = '
' + 84 | '
' + 85 | '
' + 86 | '
[–] ' + 87 | '' + data.user + ' ' + 88 | ' ' + 89 | 'link
' + 90 | '
' + 91 | '
' + data.content +'
' + 92 | '

reply

' + 93 | '
' + 94 | '
' + 95 | '
' 96 | comments += generateNestedCommentString(data) + '
' + comment 97 | }) 98 | return comments 99 | }) 100 | } -------------------------------------------------------------------------------- /nwb.config.js: -------------------------------------------------------------------------------- 1 | var HtmlWebpackPlugin = require('html-webpack-plugin') 2 | var CommonsChunkPlugin = require('webpack/lib/optimize/CommonsChunkPlugin') 3 | 4 | module.exports = { 5 | type: 'react-app', 6 | babel: { 7 | loose: true, 8 | stage: false, 9 | presets: ['es2015', 'stage-0', 'react'] 10 | }, 11 | webpack: { 12 | loaders: { 13 | babel: { 14 | babelrc: true, 15 | cacheDirectory: true 16 | } 17 | }, 18 | plugins: { 19 | define: { 20 | __VERSION__: JSON.stringify(require('./package.json').version) 21 | } 22 | }, 23 | extra: { 24 | plugins: [ 25 | new CommonsChunkPlugin({ 26 | names: ['core'], 27 | filename: '[name].js', 28 | minChunks: Infinity 29 | }), 30 | new HtmlWebpackPlugin({ 31 | filename: 'views/index.ejs', 32 | template: 'src/views/index.ejs', 33 | markup: '
<%- markup %>
' 34 | }) 35 | ] 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-hn", 3 | "version": "1.6.0", 4 | "description": "React-powered frontend for Hacker News using its Firebase API", 5 | "author": "Jonny Buchanan", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "http://github.com/insin/react-hn.git" 10 | }, 11 | "scripts": { 12 | "build": "cp node_modules/sw-toolbox/sw-toolbox.js dist/sw-toolbox.js && ./node_modules/.bin/nwb build --preact && npm run copy-manifest && npm run precache", 13 | "deploy": "gcloud preview app deploy", 14 | "lint": "./node_modules/eslint-config-jonnybuchanan/bin/lint.js src", 15 | "lint:fix": "./node_modules/eslint-config-jonnybuchanan/bin/lint.js --fix .", 16 | "start": "node server.js", 17 | "postinstall": "npm run build", 18 | "serve": "./node_modules/.bin/nwb serve", 19 | "copy-manifest": "cp public/manifest.json dist/manifest.json", 20 | "clean": "rm -rf *.json.gzip dist/index.html", 21 | "precache": "./node_modules/sw-precache/cli.js --root=public --config=sw-precache-config.json && npm run clean" 22 | }, 23 | "engines": { 24 | "node": "6.1.0" 25 | }, 26 | "main": "server.js", 27 | "dependencies": { 28 | "babel-preset-es2015": "^6.16.0", 29 | "babel-preset-react": "^6.16.0", 30 | "babel-preset-stage-0": "^6.16.0", 31 | "babel-register": "^6.16.0", 32 | "ejs": "^2.4.1", 33 | "eslint-config-jonnybuchanan": "4.6.0", 34 | "events": "1.1.1", 35 | "express": "^4.13.4", 36 | "firebase": "3.4.1", 37 | "history": "^2.1.2", 38 | "html-webpack-plugin": "^2.22.0", 39 | "isomorphic-fetch": "^2.2.1", 40 | "nwb": "0.12.2", 41 | "object-assign": "^4.1.0", 42 | "preact": "^6.3.0", 43 | "preact-compat": "^3.8.2", 44 | "react": "15.3.2", 45 | "react-dom": "15.3.2", 46 | "react-router": "2.8.1", 47 | "react-router-scroll": "^0.3.2", 48 | "react-timeago": "3.1.3", 49 | "reactfire": "1.0.0", 50 | "scroll-behavior": "0.8.1", 51 | "setimmediate": "1.0.5", 52 | "sw-precache": "^4.1.0", 53 | "sw-toolbox": "^3.1.1", 54 | "url-parse": "^1.1.1", 55 | "webpack": "^1.13.2" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /public/css/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #fff; 3 | margin: 0; 4 | } 5 | form { 6 | margin: 0; 7 | } 8 | img { 9 | vertical-align: text-bottom; 10 | } 11 | pre { 12 | white-space: pre-wrap; 13 | } 14 | span.control { 15 | cursor: pointer; 16 | } 17 | span.control:hover { 18 | text-decoration: underline; 19 | } 20 | 21 | /* From https://github.com/tobiasahlin/SpinKit */ 22 | .Spinner { 23 | } 24 | .Spinner > div { 25 | background-color: #666; 26 | border-radius: 100%; 27 | display: inline-block; 28 | -webkit-animation: bouncedelay 1.4s infinite ease-in-out; 29 | animation: bouncedelay 1.4s infinite ease-in-out; 30 | /* Prevent first frame from flickering when animation starts */ 31 | -webkit-animation-fill-mode: both; 32 | animation-fill-mode: both; 33 | } 34 | .Spinner .bounce1 { 35 | -webkit-animation-delay: -0.32s; 36 | animation-delay: -0.32s; 37 | } 38 | .Spinner .bounce2 { 39 | -webkit-animation-delay: -0.16s; 40 | animation-delay: -0.16s; 41 | } 42 | @-webkit-keyframes bouncedelay { 43 | 0%, 80%, 100% { -webkit-transform: scale(0.0) } 44 | 40% { -webkit-transform: scale(1.0) } 45 | } 46 | @keyframes bouncedelay { 47 | 0%, 80%, 100% { 48 | transform: scale(0.0); 49 | -webkit-transform: scale(0.0); 50 | } 40% { 51 | transform: scale(1.0); 52 | -webkit-transform: scale(1.0); 53 | } 54 | } 55 | 56 | .App__wrap { 57 | width: 90%; 58 | max-width: 1280px; 59 | margin: 8px auto; 60 | color: #000; 61 | background-color: #f5f5f5; 62 | font-size: 13.3333px; 63 | font-family: Verdana, Geneva, sans-serif; 64 | } 65 | .App__header { 66 | color: #00d8ff; 67 | background-color: #222; 68 | padding: 6px; 69 | line-height: 18px; 70 | vertical-align: middle; 71 | position: relative; 72 | } 73 | .App__settings { 74 | position: absolute; 75 | top: 6px; 76 | right: 10px; 77 | cursor: pointer; 78 | } 79 | .Settings { 80 | box-sizing: border-box; 81 | padding: .75em; 82 | position: absolute; 83 | width: 36%%; 84 | background-color: inherit; 85 | right: 0; 86 | border-bottom-left-radius: 1em; 87 | border-bottom-right-radius: 1em; 88 | } 89 | .Settings__setting td:first-child { 90 | text-align: right; 91 | } 92 | .Settings p { 93 | color: #fff; 94 | } 95 | .Settings div:last-child > p:last-child { 96 | margin-bottom: 0; 97 | } 98 | .App__header img { 99 | border: 1px solid #00d8ff; 100 | margin-right: .25em; 101 | } 102 | .App__header a { 103 | color: inherit; 104 | text-decoration: none; 105 | } 106 | .App__header a.active { 107 | color: #fff; 108 | } 109 | .App__homelink { 110 | text-decoration: none; 111 | font-weight: bold; 112 | color: #00d8ff !important; 113 | margin-right: .75em; 114 | } 115 | .App__homelink.active { 116 | color: #fff !important; 117 | } 118 | .App__content { 119 | overflow-wrap: break-word; 120 | word-wrap: break-word; 121 | } 122 | .App__footer { 123 | margin-top: 1em; 124 | border-top: 1px solid #e7e7e7; 125 | text-align: center; 126 | color: #333; 127 | padding: 6px 0; 128 | } 129 | .App__footer a { 130 | color: inherit; 131 | text-decoration: underline; 132 | } 133 | 134 | .Items__list { 135 | padding-left: 3em; 136 | padding-right: 1.25em; 137 | margin-top: 1em; 138 | margin-bottom: .5em; 139 | } 140 | .ListItem { 141 | margin-bottom: 16px; 142 | } 143 | .ListItem--loading { 144 | min-height: 34px; 145 | } 146 | 147 | .Updates--loading { 148 | padding: 1em 1.25em 0 1.25em; 149 | margin-bottom: 1em; 150 | } 151 | .Updates__notice { 152 | padding: 0 1.25em; 153 | } 154 | .Updates .Comment { 155 | margin-bottom: .75em; 156 | } 157 | 158 | .Paginator { 159 | margin-left: 3em; 160 | padding: .5em 0; 161 | } 162 | .Paginator a { 163 | font-weight: bold; 164 | color: #000; 165 | text-decoration: none; 166 | } 167 | .Paginator a:hover { 168 | text-decoration: underline; 169 | } 170 | 171 | .Item__content, 172 | .Item--loading { 173 | padding: 1em 1.25em 0 1.25em; 174 | margin-bottom: 1em; 175 | } 176 | .Item__title { 177 | color: #666; 178 | font-size:18px; 179 | } 180 | .Item__title a { 181 | text-decoration: none; 182 | color: #000; 183 | } 184 | .Item__title a:hover { 185 | text-decoration: underline; 186 | } 187 | .Item__title a:visited { 188 | color: #666; 189 | } 190 | .Item__meta { 191 | color: #666; 192 | } 193 | .Item > .Item__meta { 194 | margin-bottom: 1em; 195 | } 196 | .Item__meta a { 197 | text-decoration: none; 198 | color: inherit; 199 | } 200 | .Item__meta a:hover { 201 | text-decoration: underline; 202 | } 203 | .Item__meta em { 204 | font-style: normal; 205 | background-color: #ffffde; 206 | color: #000; 207 | } 208 | .Item__by a, .ListItem__newcomments a { 209 | font-weight: bold; 210 | } 211 | .Item__text { 212 | margin-top: 1em; 213 | } 214 | .Item__poll { 215 | margin-top: 1em; 216 | padding-left: 2.5em; 217 | } 218 | 219 | .PollOption { 220 | margin-bottom: 10px; 221 | } 222 | .PollOption__score { 223 | color: #666; 224 | } 225 | 226 | .PermalinkedComment > .Comment__content { 227 | margin-bottom: 1em; 228 | } 229 | .Comment { 230 | } 231 | .Comment--new > .Comment__content { 232 | background-color: #ffffde; 233 | } 234 | /* Highlights a comment and its descendants on hover -- too distracting? 235 | .Comment:hover > .Comment__content { 236 | background-color: #fff; 237 | } 238 | */ 239 | .Comment__meta { 240 | color: #666; 241 | margin-bottom: .5em 242 | } 243 | .Comment__meta a { 244 | text-decoration: none; 245 | color: inherit; 246 | } 247 | .Comment__meta a:hover { 248 | text-decoration: underline; 249 | } 250 | .Comment__meta em { 251 | font-style: normal; 252 | background-color: #ffffde; 253 | color: #000; 254 | } 255 | .Comment__user { 256 | font-weight: bold; 257 | } 258 | .Comment__content, .Comment--loading { 259 | padding-right: 1.25em; 260 | padding-top: .65em; 261 | padding-bottom: .65em; 262 | } 263 | .Comment--level0 .Comment__content, .Comment--level0.Comment--loading { padding-left: 1.25em; } 264 | .Comment--level1 .Comment__content, .Comment--level1.Comment--loading { padding-left: 3.75em; } 265 | .Comment--level2 .Comment__content, .Comment--level2.Comment--loading { padding-left: 6.25em; } 266 | .Comment--level3 .Comment__content, .Comment--level3.Comment--loading { padding-left: 8.75em; } 267 | .Comment--level4 .Comment__content, .Comment--level4.Comment--loading { padding-left: 11.25em; } 268 | .Comment--level5 .Comment__content, .Comment--level5.Comment--loading { padding-left: 13.75em; } 269 | .Comment--level6 .Comment__content, .Comment--level6.Comment--loading { padding-left: 16.25em; } 270 | .Comment--level7 .Comment__content, .Comment--level7.Comment--loading { padding-left: 18.75em; } 271 | .Comment--level8 .Comment__content, .Comment--level8.Comment--loading { padding-left: 21.25em; } 272 | .Comment--level9 .Comment__content, .Comment--level9.Comment--loading { padding-left: 23.75em; } 273 | .Comment--level10 .Comment__content, .Comment--level10.Comment--loading { padding-left: 26.25em; } 274 | .Comment--level11 .Comment__content, .Comment--level11.Comment--loading { padding-left: 28.75em; } 275 | .Comment--level12 .Comment__content, .Comment--level12.Comment--loading { padding-left: 31.25em; } 276 | .Comment--level13 .Comment__content, .Comment--level13.Comment--loading { padding-left: 33.75em; } 277 | .Comment--level14 .Comment__content, .Comment--level14.Comment--loading { padding-left: 36.25em; } 278 | .Comment--level15 .Comment__content, .Comment--level15.Comment--loading { padding-left: 38.75em; } 279 | .Comment__kids { 280 | } 281 | .Comment__collapse { 282 | cursor: pointer; 283 | } 284 | .Comment--collapsed .Comment__text, 285 | .Comment--collapsed > .Comment__kids { 286 | display: none; 287 | } 288 | .Comment__text a { 289 | color: #000; 290 | } 291 | .Comment__text a:visited { 292 | color: #666; 293 | } 294 | .Comment__text p:last-child, .Comment__text pre:last-child { 295 | margin-bottom: 0; 296 | } 297 | .Comment--dead > .Comment__content > .Comment__text { 298 | color: #ddd !important; 299 | } 300 | .Comment--error .Comment__meta { 301 | color: #f33; 302 | } 303 | 304 | .UserProfile { 305 | padding-left: 1.25em; 306 | padding-top: 1em; 307 | } 308 | .UserProfile h4 { 309 | margin: 0 0 1em 0; 310 | } 311 | 312 | @media only screen and (max-width: 750px) and (min-width: 300px) { 313 | .App__wrap { 314 | width: 100%; 315 | margin: 0px auto; 316 | } 317 | 318 | /* Hide the App title homelink on narrow viewports */ 319 | .App__homelink { 320 | display: none; 321 | } 322 | 323 | /* Safari only fix to ensure Settings menu is full width */ 324 | _::-webkit-:not(:root:root), .Settings { 325 | width: 100%; 326 | } 327 | } 328 | 329 | /* Hide the Settings link on iPhone 5 */ 330 | @media (device-height: 568px) and (device-width: 320px) and (-webkit-min-device-pixel-ratio: 2) { 331 | .App__settings { 332 | display: none; 333 | } 334 | } 335 | -------------------------------------------------------------------------------- /public/img/android-chrome-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/addyosmani/preact-hn/70ed87fd8505178cd346cadcad01d5247a9daf2c/public/img/android-chrome-144x144.png -------------------------------------------------------------------------------- /public/img/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/addyosmani/preact-hn/70ed87fd8505178cd346cadcad01d5247a9daf2c/public/img/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/img/android-chrome-36x36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/addyosmani/preact-hn/70ed87fd8505178cd346cadcad01d5247a9daf2c/public/img/android-chrome-36x36.png -------------------------------------------------------------------------------- /public/img/android-chrome-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/addyosmani/preact-hn/70ed87fd8505178cd346cadcad01d5247a9daf2c/public/img/android-chrome-48x48.png -------------------------------------------------------------------------------- /public/img/android-chrome-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/addyosmani/preact-hn/70ed87fd8505178cd346cadcad01d5247a9daf2c/public/img/android-chrome-72x72.png -------------------------------------------------------------------------------- /public/img/android-chrome-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/addyosmani/preact-hn/70ed87fd8505178cd346cadcad01d5247a9daf2c/public/img/android-chrome-96x96.png -------------------------------------------------------------------------------- /public/img/apple-touch-icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/addyosmani/preact-hn/70ed87fd8505178cd346cadcad01d5247a9daf2c/public/img/apple-touch-icon-114x114.png -------------------------------------------------------------------------------- /public/img/apple-touch-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/addyosmani/preact-hn/70ed87fd8505178cd346cadcad01d5247a9daf2c/public/img/apple-touch-icon-120x120.png -------------------------------------------------------------------------------- /public/img/apple-touch-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/addyosmani/preact-hn/70ed87fd8505178cd346cadcad01d5247a9daf2c/public/img/apple-touch-icon-144x144.png -------------------------------------------------------------------------------- /public/img/apple-touch-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/addyosmani/preact-hn/70ed87fd8505178cd346cadcad01d5247a9daf2c/public/img/apple-touch-icon-152x152.png -------------------------------------------------------------------------------- /public/img/apple-touch-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/addyosmani/preact-hn/70ed87fd8505178cd346cadcad01d5247a9daf2c/public/img/apple-touch-icon-180x180.png -------------------------------------------------------------------------------- /public/img/apple-touch-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/addyosmani/preact-hn/70ed87fd8505178cd346cadcad01d5247a9daf2c/public/img/apple-touch-icon-57x57.png -------------------------------------------------------------------------------- /public/img/apple-touch-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/addyosmani/preact-hn/70ed87fd8505178cd346cadcad01d5247a9daf2c/public/img/apple-touch-icon-60x60.png -------------------------------------------------------------------------------- /public/img/apple-touch-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/addyosmani/preact-hn/70ed87fd8505178cd346cadcad01d5247a9daf2c/public/img/apple-touch-icon-72x72.png -------------------------------------------------------------------------------- /public/img/apple-touch-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/addyosmani/preact-hn/70ed87fd8505178cd346cadcad01d5247a9daf2c/public/img/apple-touch-icon-76x76.png -------------------------------------------------------------------------------- /public/img/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/addyosmani/preact-hn/70ed87fd8505178cd346cadcad01d5247a9daf2c/public/img/apple-touch-icon-precomposed.png -------------------------------------------------------------------------------- /public/img/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/addyosmani/preact-hn/70ed87fd8505178cd346cadcad01d5247a9daf2c/public/img/apple-touch-icon.png -------------------------------------------------------------------------------- /public/img/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | #222222 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /public/img/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/addyosmani/preact-hn/70ed87fd8505178cd346cadcad01d5247a9daf2c/public/img/favicon-16x16.png -------------------------------------------------------------------------------- /public/img/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/addyosmani/preact-hn/70ed87fd8505178cd346cadcad01d5247a9daf2c/public/img/favicon-32x32.png -------------------------------------------------------------------------------- /public/img/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/addyosmani/preact-hn/70ed87fd8505178cd346cadcad01d5247a9daf2c/public/img/favicon-96x96.png -------------------------------------------------------------------------------- /public/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/addyosmani/preact-hn/70ed87fd8505178cd346cadcad01d5247a9daf2c/public/img/favicon.ico -------------------------------------------------------------------------------- /public/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/addyosmani/preact-hn/70ed87fd8505178cd346cadcad01d5247a9daf2c/public/img/logo.png -------------------------------------------------------------------------------- /public/img/mstile-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/addyosmani/preact-hn/70ed87fd8505178cd346cadcad01d5247a9daf2c/public/img/mstile-144x144.png -------------------------------------------------------------------------------- /public/img/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/addyosmani/preact-hn/70ed87fd8505178cd346cadcad01d5247a9daf2c/public/img/mstile-150x150.png -------------------------------------------------------------------------------- /public/img/mstile-310x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/addyosmani/preact-hn/70ed87fd8505178cd346cadcad01d5247a9daf2c/public/img/mstile-310x150.png -------------------------------------------------------------------------------- /public/img/mstile-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/addyosmani/preact-hn/70ed87fd8505178cd346cadcad01d5247a9daf2c/public/img/mstile-310x310.png -------------------------------------------------------------------------------- /public/img/mstile-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/addyosmani/preact-hn/70ed87fd8505178cd346cadcad01d5247a9daf2c/public/img/mstile-70x70.png -------------------------------------------------------------------------------- /public/img/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.11, written by Peter Selinger 2001-2013 9 | 10 | 12 | 22 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /public/img/splashscreen-icon-384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/addyosmani/preact-hn/70ed87fd8505178cd346cadcad01d5247a9daf2c/public/img/splashscreen-icon-384x384.png -------------------------------------------------------------------------------- /public/index-static.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | React HN 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 |
37 | 67 | 68 | 73 | 74 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "React HN", 3 | "short_name": "React HN", 4 | "icons": [{ 5 | "src": "img/apple-touch-icon-120x120.png", 6 | "sizes": "120x120", 7 | "type": "image/png" 8 | }, { 9 | "src": "img/apple-touch-icon-152x152.png", 10 | "sizes": "152x152", 11 | "type": "image/png" 12 | }, { 13 | "src": "img/android-chrome-144x144.png", 14 | "sizes": "144x144", 15 | "type": "image/png" 16 | }, { 17 | "src": "img/android-chrome-192x192.png", 18 | "sizes": "192x192", 19 | "type": "image/png" 20 | },{ 21 | "src": "img/splashscreen-icon-384x384.png", 22 | "sizes": "384x384", 23 | "type": "image/png" 24 | }], 25 | "start_url": "./?utm_source=web_app_manifest", 26 | "background_color": "#4CC1FC", 27 | "display": "standalone", 28 | "theme_color": "#222222" 29 | } 30 | -------------------------------------------------------------------------------- /public/runtime-caching.js: -------------------------------------------------------------------------------- 1 | // global.toolbox is defined in a different script, sw-toolbox.js, which is part of the 2 | // https://github.com/GoogleChrome/sw-toolbox project. 3 | // That sw-toolbox.js script must be executed first, so it needs to be listed before this in the 4 | // importScripts() call that the parent service worker makes. 5 | (function(global) { 6 | 'use strict' 7 | 8 | // See https://github.com/GoogleChrome/sw-toolbox/blob/6e8242dc328d1f1cfba624269653724b26fa94f1/README.md#toolboxroutergeturlpattern-handler-options 9 | // and https://github.com/GoogleChrome/sw-toolbox/blob/6e8242dc328d1f1cfba624269653724b26fa94f1/README.md#toolboxfastest 10 | // for more details on how this handler is defined and what the toolbox.fastest strategy does. 11 | global.toolbox.router.get('/(.*)', global.toolbox.fastest, { 12 | origin: /\.(?:googleapis|gstatic|firebaseio|appspot)\.com$/ 13 | }) 14 | global.toolbox.router.get('/(.+)', global.toolbox.fastest, { 15 | origin: 'https://hacker-news.firebaseio.com' 16 | }) 17 | global.toolbox.router.get('/(.+)', global.toolbox.fastest, { 18 | origin: 'https://s-usc1c-nss-136.firebaseio.com' 19 | }) 20 | global.toolbox.router.get('/*', global.toolbox.fastest); 21 | })(self) 22 | 23 | -------------------------------------------------------------------------------- /public/service-worker.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2016 Google Inc. All rights reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | // DO NOT EDIT THIS GENERATED OUTPUT DIRECTLY! 18 | // This file should be overwritten as part of your build process. 19 | // If you need to extend the behavior of the generated service worker, the best approach is to write 20 | // additional code and include it using the importScripts option: 21 | // https://github.com/GoogleChrome/sw-precache#importscripts-arraystring 22 | // 23 | // Alternatively, it's possible to make changes to the underlying template file and then use that as the 24 | // new base for generating output, via the templateFilePath option: 25 | // https://github.com/GoogleChrome/sw-precache#templatefilepath-string 26 | // 27 | // If you go that route, make sure that whenever you update your sw-precache dependency, you reconcile any 28 | // changes made to this original template file with your modified copy. 29 | 30 | // This generated service worker JavaScript will precache your site's resources. 31 | // The code needs to be saved in a .js file at the top-level of your site, and registered 32 | // from your pages in order to be used. See 33 | // https://github.com/googlechrome/sw-precache/blob/master/demo/app/js/service-worker-registration.js 34 | // for an example of how you can register this script and handle various service worker events. 35 | 36 | /* eslint-env worker, serviceworker */ 37 | /* eslint-disable indent, no-unused-vars, no-multiple-empty-lines, max-nested-callbacks, space-before-function-paren, quotes, comma-spacing */ 38 | 'use strict'; 39 | 40 | var precacheConfig = [["public/build/vendor.js","d98a331d55fe40cf10d700294c557168"],["public/build/vendor.js.map","eb08187346adbe610cfdc84814aa85d4"],["public/css/style.css","e74e55d84f51b1d0e36bf1a4dec21772"],["public/img/android-chrome-144x144.png","31f44c8f8845e41196b389f2fdae392d"],["public/img/android-chrome-192x192.png","7c3470aa18f85e4454a35ef3ff8b8f6e"],["public/img/android-chrome-36x36.png","fc5a14316848badbd501e198f8607088"],["public/img/android-chrome-48x48.png","f8576bca4be18a4367e4847a09fc6945"],["public/img/android-chrome-72x72.png","6b26a8a135b07174d298489cc010083a"],["public/img/android-chrome-96x96.png","04cda150a70eb58221af3fdd4f7d4a6f"],["public/img/apple-touch-icon-114x114.png","e18affb685f0457672283a88c04084c9"],["public/img/apple-touch-icon-120x120.png","cd14469c7457cfc6d3aaf15d34faeddf"],["public/img/apple-touch-icon-144x144.png","95a8cb7d006c59252dd68ba73d31632a"],["public/img/apple-touch-icon-152x152.png","15dd03590ff7289c09cf10027597e699"],["public/img/apple-touch-icon-180x180.png","0b101591e8e263c6bff9133c7772194a"],["public/img/apple-touch-icon-57x57.png","628a477075d84a8d0996392aa6dec37c"],["public/img/apple-touch-icon-60x60.png","6b9fe001bc9e35320f9bb4eb28b1e6f1"],["public/img/apple-touch-icon-72x72.png","5830f2a4f9249b3bc3998481cc00825d"],["public/img/apple-touch-icon-76x76.png","812e9eb119b6bdd8f465a2d1118465b9"],["public/img/apple-touch-icon-precomposed.png","e45a9a06a4a9b850e3089c4e6e3ebc8d"],["public/img/apple-touch-icon.png","0b101591e8e263c6bff9133c7772194a"],["public/img/browserconfig.xml","f337354b6f80663075e7b32058c65149"],["public/img/favicon-16x16.png","9d784dc3f4da5477156423f5f106c1c6"],["public/img/favicon-32x32.png","21ea2cf9cd43cdc1f808cca76a1f6fa4"],["public/img/favicon-96x96.png","11e36fff4c95b572ffaeef9a848da568"],["public/img/favicon.ico","eaa33e22fc5dab05262d316b59160a45"],["public/img/logo.png","930a492dadf1ccb881bd91d424c8bf9e"],["public/img/mstile-144x144.png","3e9a3c273f9ac3b7a158132445534860"],["public/img/mstile-150x150.png","b0af3ec429e6828dc0606d8bb8e1421f"],["public/img/mstile-310x150.png","499b08d0d170e6ed89491d7e9691a8e8"],["public/img/mstile-310x310.png","625111493ee72a39db1420c9c235dfb3"],["public/img/mstile-70x70.png","4cdf64d2b55d8116c4ce8dd361a95772"],["public/img/safari-pinned-tab.svg","9bfe87bb482c5d6facab0d0084ce1e80"],["public/img/splashscreen-icon-384x384.png","e3080842f30a9137e1464f01ffb97e71"],["public/index-static.html","894331a8b8a9845d2cce2fac1d265466"],["public/manifest.json","88a82e030fa45aee1ea68a4f0a3811bb"],["public/runtime-caching.js","87003e567d298b1b58cf2f57b4fb0ee2"],["public/sw-toolbox.js","1ca0f60210ecd50f5b6b80ebc325e7c3"]]; 41 | var cacheName = 'sw-precache-v2-sw-precache-' + (self.registration ? self.registration.scope : ''); 42 | 43 | 44 | var ignoreUrlParametersMatching = [/^utm_/]; 45 | 46 | 47 | 48 | var addDirectoryIndex = function (originalUrl, index) { 49 | var url = new URL(originalUrl); 50 | if (url.pathname.slice(-1) === '/') { 51 | url.pathname += index; 52 | } 53 | return url.toString(); 54 | }; 55 | 56 | var createCacheKey = function (originalUrl, paramName, paramValue, 57 | dontCacheBustUrlsMatching) { 58 | // Create a new URL object to avoid modifying originalUrl. 59 | var url = new URL(originalUrl); 60 | 61 | // If dontCacheBustUrlsMatching is not set, or if we don't have a match, 62 | // then add in the extra cache-busting URL parameter. 63 | if (!dontCacheBustUrlsMatching || 64 | !(url.toString().match(dontCacheBustUrlsMatching))) { 65 | url.search += (url.search ? '&' : '') + 66 | encodeURIComponent(paramName) + '=' + encodeURIComponent(paramValue); 67 | } 68 | 69 | return url.toString(); 70 | }; 71 | 72 | var isPathWhitelisted = function (whitelist, absoluteUrlString) { 73 | // If the whitelist is empty, then consider all URLs to be whitelisted. 74 | if (whitelist.length === 0) { 75 | return true; 76 | } 77 | 78 | // Otherwise compare each path regex to the path of the URL passed in. 79 | var path = (new URL(absoluteUrlString)).pathname; 80 | return whitelist.some(function(whitelistedPathRegex) { 81 | return path.match(whitelistedPathRegex); 82 | }); 83 | }; 84 | 85 | var stripIgnoredUrlParameters = function (originalUrl, 86 | ignoreUrlParametersMatching) { 87 | var url = new URL(originalUrl); 88 | 89 | url.search = url.search.slice(1) // Exclude initial '?' 90 | .split('&') // Split into an array of 'key=value' strings 91 | .map(function(kv) { 92 | return kv.split('='); // Split each 'key=value' string into a [key, value] array 93 | }) 94 | .filter(function(kv) { 95 | return ignoreUrlParametersMatching.every(function(ignoredRegex) { 96 | return !ignoredRegex.test(kv[0]); // Return true iff the key doesn't match any of the regexes. 97 | }); 98 | }) 99 | .map(function(kv) { 100 | return kv.join('='); // Join each [key, value] array into a 'key=value' string 101 | }) 102 | .join('&'); // Join the array of 'key=value' strings into a string with '&' in between each 103 | 104 | return url.toString(); 105 | }; 106 | 107 | 108 | var hashParamName = '_sw-precache'; 109 | var urlsToCacheKeys = new Map( 110 | precacheConfig.map(function(item) { 111 | var relativeUrl = item[0]; 112 | var hash = item[1]; 113 | var absoluteUrl = new URL(relativeUrl, self.location); 114 | var cacheKey = createCacheKey(absoluteUrl, hashParamName, hash, false); 115 | return [absoluteUrl.toString(), cacheKey]; 116 | }) 117 | ); 118 | 119 | function setOfCachedUrls(cache) { 120 | return cache.keys().then(function(requests) { 121 | return requests.map(function(request) { 122 | return request.url; 123 | }); 124 | }).then(function(urls) { 125 | return new Set(urls); 126 | }); 127 | } 128 | 129 | self.addEventListener('install', function(event) { 130 | event.waitUntil( 131 | caches.open(cacheName).then(function(cache) { 132 | return setOfCachedUrls(cache).then(function(cachedUrls) { 133 | return Promise.all( 134 | Array.from(urlsToCacheKeys.values()).map(function(cacheKey) { 135 | // If we don't have a key matching url in the cache already, add it. 136 | if (!cachedUrls.has(cacheKey)) { 137 | return cache.add(new Request(cacheKey, {credentials: 'same-origin'})); 138 | } 139 | }) 140 | ); 141 | }); 142 | }).then(function() { 143 | 144 | // Force the SW to transition from installing -> active state 145 | return self.skipWaiting(); 146 | 147 | }) 148 | ); 149 | }); 150 | 151 | self.addEventListener('activate', function(event) { 152 | var setOfExpectedUrls = new Set(urlsToCacheKeys.values()); 153 | 154 | event.waitUntil( 155 | caches.open(cacheName).then(function(cache) { 156 | return cache.keys().then(function(existingRequests) { 157 | return Promise.all( 158 | existingRequests.map(function(existingRequest) { 159 | if (!setOfExpectedUrls.has(existingRequest.url)) { 160 | return cache.delete(existingRequest); 161 | } 162 | }) 163 | ); 164 | }); 165 | }).then(function() { 166 | 167 | return self.clients.claim(); 168 | 169 | }) 170 | ); 171 | }); 172 | 173 | 174 | self.addEventListener('fetch', function(event) { 175 | if (event.request.method === 'GET') { 176 | // Should we call event.respondWith() inside this fetch event handler? 177 | // This needs to be determined synchronously, which will give other fetch 178 | // handlers a chance to handle the request if need be. 179 | var shouldRespond; 180 | 181 | // First, remove all the ignored parameter and see if we have that URL 182 | // in our cache. If so, great! shouldRespond will be true. 183 | var url = stripIgnoredUrlParameters(event.request.url, ignoreUrlParametersMatching); 184 | shouldRespond = urlsToCacheKeys.has(url); 185 | 186 | // If shouldRespond is false, check again, this time with 'index.html' 187 | // (or whatever the directoryIndex option is set to) at the end. 188 | var directoryIndex = 'index.html'; 189 | if (!shouldRespond && directoryIndex) { 190 | url = addDirectoryIndex(url, directoryIndex); 191 | shouldRespond = urlsToCacheKeys.has(url); 192 | } 193 | 194 | // If shouldRespond is still false, check to see if this is a navigation 195 | // request, and if so, whether the URL matches navigateFallbackWhitelist. 196 | var navigateFallback = ''; 197 | if (!shouldRespond && 198 | navigateFallback && 199 | (event.request.mode === 'navigate') && 200 | isPathWhitelisted([], event.request.url)) { 201 | url = new URL(navigateFallback, self.location).toString(); 202 | shouldRespond = urlsToCacheKeys.has(url); 203 | } 204 | 205 | // If shouldRespond was set to true at any point, then call 206 | // event.respondWith(), using the appropriate cache key. 207 | if (shouldRespond) { 208 | event.respondWith( 209 | caches.open(cacheName).then(function(cache) { 210 | return cache.match(urlsToCacheKeys.get(url)).then(function(response) { 211 | if (response) { 212 | return response; 213 | } 214 | throw Error('The cached response that was expected is missing.'); 215 | }); 216 | }).catch(function(e) { 217 | // Fall back to just fetch()ing the request if some unexpected error 218 | // prevented the cached response from being valid. 219 | console.warn('Couldn\'t serve response for "%s" from cache: %O', event.request.url, e); 220 | return fetch(event.request); 221 | }) 222 | ); 223 | } 224 | } 225 | }); 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | importScripts("sw-toolbox.js","runtime-caching.js"); 234 | 235 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/addyosmani/preact-hn/70ed87fd8505178cd346cadcad01d5247a9daf2c/screenshot.png -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | var express = require('express') 2 | var React = require('react') 3 | var renderToString = require('react-dom/server').renderToString 4 | var ReactRouter = require('react-router') 5 | var objectAssign = require('object-assign') 6 | var HNServerFetch = require('./hn-server-fetch') 7 | 8 | require('babel-register') 9 | var routes = require('./src/routes') 10 | 11 | var app = express() 12 | app.set('view engine', 'ejs') 13 | app.set('views', process.cwd() + '/dist/views') 14 | app.set('port', (process.env.PORT || 5000)) 15 | app.use(express.static('dist')) 16 | 17 | 18 | app.get(['/', '/news'], function(req, res) { 19 | ReactRouter.match({ 20 | routes: routes, 21 | location: req.url 22 | }, function(err, redirectLocation, props) { 23 | if (err) { 24 | res.status(500).send(err.message) 25 | } 26 | else if (redirectLocation) { 27 | res.redirect(302, redirectLocation.pathname + redirectLocation.search) 28 | } 29 | else if (props) { 30 | HNServerFetch.fetchNews().then(function(stories) { 31 | objectAssign(props.params, { prebootHTML: stories }) 32 | var markup = renderToString(React.createElement(ReactRouter.RouterContext, props, null)) 33 | res.render('index', { markup: markup }) 34 | }) 35 | } 36 | else { 37 | res.sendStatus(404) 38 | } 39 | }) 40 | }) 41 | 42 | app.get('/news/story/:id', function (req, res, next) { 43 | var storyId = req.params.id 44 | ReactRouter.match({ 45 | routes: routes, 46 | location: req.url 47 | }, function(err, redirectLocation, props) { 48 | if (storyId) { 49 | HNServerFetch.fetchItem(storyId).then(function(comments) { 50 | objectAssign(props.params, { prebootHTML: comments }) 51 | var markup = renderToString(React.createElement(ReactRouter.RouterContext, props, null)) 52 | res.render('index', { markup: markup }) 53 | }) 54 | } 55 | }) 56 | }); 57 | 58 | app.get('*', function(req, res) { 59 | ReactRouter.match({ 60 | routes: routes, 61 | location: req.url 62 | }, function(err, redirectLocation, props) { 63 | if (err) { 64 | res.status(500).send(err.message) 65 | } 66 | else if (redirectLocation) { 67 | res.redirect(302, redirectLocation.pathname + redirectLocation.search) 68 | } 69 | else if (props) { 70 | var markup = renderToString(React.createElement(ReactRouter.RouterContext, props, null)) 71 | res.render('index', { markup: markup }) 72 | } 73 | else { 74 | res.sendStatus(404) 75 | } 76 | }) 77 | }) 78 | 79 | app.listen(app.get('port'), function(err) { 80 | if (err) { 81 | console.log(err) 82 | return 83 | } 84 | console.log('Running app at localhost:' + app.get('port')) 85 | }) 86 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | var React = require('react') 2 | var Link = require('react-router/lib/Link') 3 | 4 | var Settings = require('./Settings') 5 | 6 | var StoryStore = require('./stores/StoryStore') 7 | var UpdatesStore = require('./stores/UpdatesStore') 8 | var SettingsStore = require('./stores/SettingsStore') 9 | 10 | var App = React.createClass({ 11 | getInitialState() { 12 | return { 13 | showSettings: false, 14 | showChildren: false, 15 | prebootHTML: this.props.params.prebootHTML 16 | } 17 | }, 18 | 19 | componentWillMount() { 20 | SettingsStore.load() 21 | StoryStore.loadSession() 22 | UpdatesStore.loadSession() 23 | if (typeof window === 'undefined') return 24 | window.addEventListener('beforeunload', this.handleBeforeUnload) 25 | }, 26 | 27 | componentDidMount() { 28 | // Empty the prebooted HTML and hydrate using live results from Firebase 29 | this.setState({ prebootHTML: '', showChildren: true }) 30 | }, 31 | 32 | componentWillUnmount() { 33 | if (typeof window === 'undefined') return 34 | window.removeEventListener('beforeunload', this.handleBeforeUnload) 35 | }, 36 | 37 | /** 38 | * Give stores a chance to persist data to sessionStorage in case this is a 39 | * refresh or an external link in the same tab. 40 | */ 41 | handleBeforeUnload() { 42 | StoryStore.saveSession() 43 | UpdatesStore.saveSession() 44 | }, 45 | 46 | toggleSettings(e) { 47 | e.preventDefault() 48 | this.setState({showSettings: !this.state.showSettings}) 49 | }, 50 | 51 | render() { 52 | return
53 |
54 |
55 | {' '} 56 | React HN{' '} 57 | new{' | '} 58 | comments {' | '} 59 | show{' | '} 60 | ask{' | '} 61 | jobs 62 | 63 | {this.state.showSettings ? 'hide settings' : 'settings'} 64 | 65 | {this.state.showSettings && } 66 |
67 |
68 |
69 | {this.state.showChildren ? this.props.children : ''} 70 |
71 |
72 | insin/react-hn 73 |
74 |
75 |
76 | } 77 | }) 78 | 79 | module.exports = App 80 | -------------------------------------------------------------------------------- /src/Comment.js: -------------------------------------------------------------------------------- 1 | var React = require('react') 2 | var ReactFireMixin = require('reactfire') 3 | 4 | var CommentThreadStore = require('./stores/CommentThreadStore') 5 | var HNService = require('./services/HNService') 6 | var HNServiceRest = require('./services/HNServiceRest') 7 | var SettingsStore = require('./stores/SettingsStore') 8 | 9 | var CommentMixin = require('./mixins/CommentMixin') 10 | 11 | var cx = require('./utils/buildClassName') 12 | 13 | /** 14 | * A comment in a thread. 15 | */ 16 | var Comment = React.createClass({ 17 | mixins: [CommentMixin, ReactFireMixin], 18 | 19 | propTypes: { 20 | id: React.PropTypes.number.isRequired, 21 | level: React.PropTypes.number.isRequired, 22 | loadingSpinner: React.PropTypes.bool, 23 | threadStore: React.PropTypes.instanceOf(CommentThreadStore).isRequired 24 | }, 25 | 26 | getDefaultProps() { 27 | return { 28 | loadingSpinner: false 29 | } 30 | }, 31 | 32 | getInitialState() { 33 | return { 34 | comment: {} 35 | } 36 | }, 37 | 38 | componentWillMount() { 39 | this.bindFirebaseRef() 40 | }, 41 | 42 | componentWillUnmount() { 43 | this.clearDelayTimeout() 44 | }, 45 | 46 | componentDidUpdate(prevProps, prevState) { 47 | // Huge, fast-growing threads like https://news.ycombinator.com/item?id=9784470 48 | // seem to break the API - some comments are coming back from Firebase as null. 49 | if (!this.state.comment) { 50 | this.props.threadStore.adjustExpectedComments(-1) 51 | return 52 | } 53 | 54 | // On !prevState.comment: a comment which was initially null - see 55 | // above - may eventually load when the API catches up. 56 | if (!prevState.comment || !prevState.comment.id) { 57 | // Register a newly-loaded comment with the thread store 58 | if (this.state.comment.id) { 59 | // If the comment was delayed, cancel any pending timeout 60 | if (prevState.comment && prevState.comment.delayed) { 61 | this.clearDelayTimeout() 62 | } 63 | this.props.threadStore.commentAdded(this.state.comment) 64 | } 65 | if (prevState.comment && !prevState.comment.delayed && this.state.comment.delayed) { 66 | this.props.threadStore.commentDelayed(this.props.id) 67 | } 68 | } 69 | // The comment was already loaded, look for changes to it 70 | else { 71 | if (!prevState.comment.deleted && this.state.comment.deleted) { 72 | this.props.threadStore.commentDeleted(this.state.comment) 73 | } 74 | if (!prevState.comment.dead && this.state.comment.dead) { 75 | this.props.threadStore.commentDied(this.state.comment) 76 | } 77 | // If the comment has been updated and the initial set of comments is 78 | // still loading, the number of expected comments might need to be 79 | // adjusted. 80 | else if (prevState.comment !== this.state.comment && 81 | this.props.threadStore.loading) { 82 | var kids = (this.state.comment.kids ? this.state.comment.kids.length : 0) 83 | var prevKids = (prevState.comment.kids ? prevState.comment.kids.length : 0) 84 | this.props.threadStore.adjustExpectedComments(kids - prevKids) 85 | } 86 | } 87 | }, 88 | 89 | bindFirebaseRef() { 90 | if (SettingsStore.offlineMode) { 91 | HNServiceRest.itemRef(this.props.id).then(function(res) { 92 | return res.json() 93 | }).then(function(snapshot) { 94 | this.replaceState({ comment: snapshot }) 95 | }.bind(this)) 96 | } 97 | else { 98 | this.bindAsObject(HNService.itemRef(this.props.id), 'comment', this.handleFirebaseRefCancelled) 99 | } 100 | 101 | if (this.timeout) { 102 | this.timeout = null 103 | } 104 | }, 105 | 106 | /** 107 | * This is usually caused by a permissions error loading the comment due to 108 | * its author using the delay setting (note: this is conjecture), which is 109 | * measured in minutes - try again in 30 seconds. 110 | */ 111 | handleFirebaseRefCancelled(e) { 112 | if (process.env.NODE_ENV !== 'production') { 113 | console.error('Firebase ref for comment ' + this.props.id + ' was cancelled: ' + e.message) 114 | } 115 | this.unbind('comment') 116 | this.timeout = setTimeout(this.bindFirebaseRef, 30000) 117 | if (this.state.comment && !this.state.comment.delayed) { 118 | this.state.comment.delayed = true 119 | this.forceUpdate() 120 | } 121 | }, 122 | 123 | clearDelayTimeout() { 124 | if (this.timeout) { 125 | clearTimeout(this.timeout) 126 | this.timeout = null 127 | } 128 | }, 129 | 130 | toggleCollapse(e) { 131 | e.preventDefault() 132 | this.props.threadStore.toggleCollapse(this.state.comment.id) 133 | }, 134 | 135 | render() { 136 | var comment = this.state.comment 137 | var props = this.props 138 | if (!comment) { 139 | return this.renderError(comment, { 140 | id: this.props.id, 141 | className: 'Comment Comment--error Comment--level' + props.level 142 | }) 143 | } 144 | // Render a placeholder while we're waiting for the comment to load 145 | if (!comment.id) { return this.renderCommentLoading(comment) } 146 | // Don't show dead coments or their children, when configured 147 | if (comment.dead && !SettingsStore.showDead) { return null } 148 | // Render a link to HN for deleted comments if they're being displayed 149 | if (comment.deleted) { 150 | if (!SettingsStore.showDeleted) { return null } 151 | return this.renderCommentDeleted(comment, { 152 | className: 'Comment Comment--deleted Comment--level' + props.level 153 | }) 154 | } 155 | 156 | var isNew = props.threadStore.isNew[comment.id] 157 | var collapsed = !!props.threadStore.isCollapsed[comment.id] 158 | var childCounts = (collapsed && props.threadStore.getChildCounts(comment)) 159 | if (collapsed && isNew) { childCounts.newComments = 0 } 160 | var className = cx('Comment Comment--level' + props.level, { 161 | 'Comment--collapsed': collapsed, 162 | 'Comment--dead': comment.dead, 163 | 'Comment--new': isNew 164 | }) 165 | 166 | return
167 |
168 | {this.renderCommentMeta(comment, { 169 | collapsible: true, 170 | collapsed: collapsed, 171 | link: true, 172 | childCounts: childCounts 173 | })} 174 | {this.renderCommentText(comment, {replyLink: true})} 175 |
176 | {comment.kids &&
177 | {comment.kids.map(function(id) { 178 | return 183 | })} 184 |
} 185 |
186 | } 187 | }) 188 | 189 | module.exports = Comment 190 | -------------------------------------------------------------------------------- /src/DisplayComment.js: -------------------------------------------------------------------------------- 1 | var React = require('react') 2 | 3 | var SettingsStore = require('./stores/SettingsStore') 4 | 5 | var CommentMixin = require('./mixins/CommentMixin') 6 | 7 | var cx = require('./utils/buildClassName') 8 | 9 | /** 10 | * Displays a standalone comment passed as a prop. 11 | */ 12 | var DisplayComment = React.createClass({ 13 | mixins: [CommentMixin], 14 | 15 | propTypes: { 16 | comment: React.PropTypes.object.isRequired 17 | }, 18 | 19 | getInitialState() { 20 | return { 21 | op: {}, 22 | parent: {type: 'comment'} 23 | } 24 | }, 25 | 26 | componentWillMount() { 27 | this.fetchAncestors(this.props.comment) 28 | }, 29 | 30 | render() { 31 | if (this.props.comment.deleted) { return null } 32 | if (this.props.comment.dead && !SettingsStore.showDead) { return null } 33 | 34 | var comment = this.props.comment 35 | var className = cx('Comment Comment--level0', { 36 | 'Comment--dead': comment.dead 37 | }) 38 | 39 | return
40 |
41 | {this.renderCommentMeta(comment, { 42 | link: true, 43 | parent: !!this.state.parent.id && !!this.state.op.id && comment.parent !== this.state.op.id, 44 | op: !!this.state.op.id 45 | })} 46 | {this.renderCommentText(comment, {replyLink: false})} 47 |
48 |
49 | } 50 | }) 51 | 52 | module.exports = DisplayComment 53 | -------------------------------------------------------------------------------- /src/DisplayListItem.js: -------------------------------------------------------------------------------- 1 | var React = require('react') 2 | 3 | var StoryCommentThreadStore = require('./stores/StoryCommentThreadStore') 4 | 5 | var ItemMixin = require('./mixins/ItemMixin') 6 | var ListItemMixin = require('./mixins/ListItemMixin') 7 | 8 | /** 9 | * Display story title and metadata as a list item. 10 | * The story to display will be passed as a prop. 11 | */ 12 | var DisplayListItem = React.createClass({ 13 | mixins: [ItemMixin, ListItemMixin], 14 | 15 | propTypes: { 16 | item: React.PropTypes.object.isRequired 17 | }, 18 | 19 | componentWillMount() { 20 | this.threadState = StoryCommentThreadStore.loadState(this.props.item.id) 21 | }, 22 | 23 | render() { 24 | return this.renderListItem(this.props.item, this.threadState) 25 | } 26 | }) 27 | 28 | module.exports = DisplayListItem 29 | -------------------------------------------------------------------------------- /src/Item.js: -------------------------------------------------------------------------------- 1 | var React = require('react') 2 | var ReactFireMixin = require('reactfire') 3 | var TimeAgo = require('react-timeago').default 4 | 5 | var HNService = require('./services/HNService') 6 | var HNServiceRest = require('./services/HNServiceRest') 7 | var StoryCommentThreadStore = require('./stores/StoryCommentThreadStore') 8 | var ItemStore = require('./stores/ItemStore') 9 | 10 | var Comment = require('./Comment') 11 | var PollOption = require('./PollOption') 12 | var Spinner = require('./Spinner') 13 | var ItemMixin = require('./mixins/ItemMixin') 14 | 15 | var cx = require('./utils/buildClassName') 16 | var setTitle = require('./utils/setTitle') 17 | 18 | var SettingsStore = require('./stores/SettingsStore') 19 | 20 | function timeUnitsAgo(value, unit, suffix) { 21 | if (value === 1) { 22 | return unit 23 | } 24 | return `${value} ${unit}s` 25 | } 26 | 27 | var Item = React.createClass({ 28 | mixins: [ItemMixin, ReactFireMixin], 29 | 30 | getInitialState() { 31 | return { 32 | item: ItemStore.getCachedStory(Number(this.props.params.id)) || {} 33 | } 34 | }, 35 | 36 | componentWillMount() { 37 | if (SettingsStore.offlineMode) { 38 | HNServiceRest.itemRef(this.props.params.id).then(function(res) { 39 | return res.json() 40 | }).then(function(snapshot) { 41 | this.replaceState({ item: snapshot }) 42 | }.bind(this)) 43 | } 44 | else { 45 | this.bindAsObject(HNService.itemRef(this.props.params.id), 'item') 46 | } 47 | 48 | if (this.state.item.id) { 49 | this.threadStore = new StoryCommentThreadStore(this.state.item, this.handleCommentsChanged, {cached: true}) 50 | setTitle(this.state.item.title) 51 | } 52 | window.addEventListener('beforeunload', this.handleBeforeUnload) 53 | }, 54 | 55 | componentWillUnmount() { 56 | if (this.threadStore) { 57 | this.threadStore.dispose() 58 | } 59 | window.removeEventListener('beforeunload', this.handleBeforeUnload) 60 | }, 61 | 62 | componentWillReceiveProps(nextProps) { 63 | if (this.props.params.id !== nextProps.params.id) { 64 | // Tear it down... 65 | this.threadStore.dispose() 66 | this.threadStore = null 67 | this.unbind('item') 68 | // ...and set it up again 69 | var item = ItemStore.getCachedStory(Number(nextProps.params.id)) 70 | if (item) { 71 | this.threadStore = new StoryCommentThreadStore(item, this.handleCommentsChanged, {cached: true}) 72 | setTitle(item.title) 73 | } 74 | 75 | if (SettingsStore.offlineMode) { 76 | HNServiceRest.itemRef(nextProps.params.id).then(function(res) { 77 | return res.json() 78 | }).then(function(snapshot) { 79 | this.replaceState({ item: snapshot }) 80 | }.bind(this)) 81 | } 82 | else { 83 | this.bindAsObject(HNService.itemRef(nextProps.params.id), 'item') 84 | this.setState({item: item || {}}) 85 | } 86 | } 87 | }, 88 | 89 | componentWillUpdate(nextProps, nextState) { 90 | // Update the title when the item has loaded. 91 | if (!this.state.item.id && nextState.item.id) { 92 | setTitle(nextState.item.title) 93 | } 94 | }, 95 | 96 | componentDidUpdate(prevProps, prevState) { 97 | // If the state item id changed, an initial or new item must have loaded 98 | if (prevState.item.id !== this.state.item.id) { 99 | if (!this.threadStore || this.threadStore.itemId !== this.state.item.id) { 100 | this.threadStore = new StoryCommentThreadStore(this.state.item, this.handleCommentsChanged, {cached: false}) 101 | setTitle(this.state.item.title) 102 | this.forceUpdate() 103 | } 104 | } 105 | else if (prevState.item !== this.state.item) { 106 | // If the item has been updated from Firebase and the initial set 107 | // of comments is still loading, the number of expected comments might 108 | // need to be adjusted. 109 | // This triggers a check for thread load completion, completing it 110 | // immediately if a cached item had 0 kids and the latest version from 111 | // Firebase also has 0 kids. 112 | if (this.threadStore.loading) { 113 | var kids = (this.state.item.kids ? this.state.item.kids.length : 0) 114 | var prevKids = (prevState.item.kids ? prevState.item.kids.length : 0) 115 | var kidDiff = kids - prevKids 116 | if (kidDiff !== 0) { 117 | this.threadStore.adjustExpectedComments(kidDiff) 118 | } 119 | } 120 | this.threadStore.itemUpdated(this.state.item) 121 | } 122 | }, 123 | 124 | /** 125 | * Ensure the last visit time and comment details get stored for this item if 126 | * the user refreshes or otherwise navigates off the page. 127 | */ 128 | handleBeforeUnload() { 129 | if (this.threadStore) { 130 | this.threadStore.dispose() 131 | } 132 | }, 133 | 134 | handleCommentsChanged(payload) { 135 | this.forceUpdate() 136 | }, 137 | 138 | autoCollapse(e) { 139 | e.preventDefault() 140 | this.threadStore.collapseThreadsWithoutNewComments() 141 | }, 142 | 143 | markAsRead(e) { 144 | e.preventDefault() 145 | this.threadStore.markAsRead() 146 | this.forceUpdate() 147 | }, 148 | 149 | render() { 150 | var state = this.state 151 | var item = state.item 152 | var threadStore = this.threadStore 153 | if (!item.id || !threadStore) { return
} 154 | return
155 |
156 | {this.renderItemTitle(item)} 157 | {this.renderItemMeta(item, (threadStore.lastVisit !== null && threadStore.newCommentCount > 0 && {' '} 158 | ({threadStore.newCommentCount} new in the last {') | '} 159 | 160 | auto collapse 161 | {' | '} 162 | 163 | mark as read 164 | 165 | ))} 166 | {item.text &&
167 |
168 |
} 169 | {item.type === 'poll' &&
170 | {item.parts.map(function(id) { 171 | return 172 | })} 173 |
} 174 |
175 | {item.kids &&
176 | {item.kids.map(function(id, index) { 177 | return 181 | })} 182 |
} 183 |
184 | } 185 | }) 186 | 187 | module.exports = Item 188 | -------------------------------------------------------------------------------- /src/NotFound.js: -------------------------------------------------------------------------------- 1 | var React = require('react') 2 | 3 | var NotFound = React.createClass({ 4 | render() { 5 | return

Not found

6 | } 7 | }) 8 | 9 | module.exports = NotFound 10 | -------------------------------------------------------------------------------- /src/Paginator.js: -------------------------------------------------------------------------------- 1 | var React = require('react') 2 | var Link = require('react-router/lib/Link') 3 | 4 | var Paginator = React.createClass({ 5 | _onClick(e) { 6 | setTimeout(function() { window.scrollTo(0, 0) }, 0) 7 | }, 8 | 9 | render() { 10 | if (this.props.page === 1 && !this.props.hasNext) { return null } 11 | return
12 | {this.props.page > 1 && 13 | Prev 14 | } 15 | {this.props.page > 1 && this.props.hasNext && ' | '} 16 | {this.props.hasNext && 17 | More 18 | } 19 |
20 | } 21 | }) 22 | 23 | module.exports = Paginator 24 | -------------------------------------------------------------------------------- /src/PermalinkedComment.js: -------------------------------------------------------------------------------- 1 | var React = require('react') 2 | var ReactFireMixin = require('reactfire') 3 | var withRouter = require('react-router/lib/withRouter') 4 | 5 | var CommentThreadStore = require('./stores/CommentThreadStore') 6 | var HNService = require('./services/HNService') 7 | var HNServiceRest = require('./services/HNServiceRest') 8 | var SettingsStore = require('./stores/SettingsStore') 9 | var UpdatesStore = require('./stores/UpdatesStore') 10 | 11 | var Comment = require('./Comment') 12 | var CommentMixin = require('./mixins/CommentMixin') 13 | 14 | var cx = require('./utils/buildClassName') 15 | var setTitle = require('./utils/setTitle') 16 | 17 | var PermalinkedComment = React.createClass({ 18 | mixins: [CommentMixin, ReactFireMixin], 19 | 20 | getDefaultProps() { 21 | return { 22 | level: 0, 23 | loadingSpinner: true 24 | } 25 | }, 26 | 27 | getInitialState() { 28 | return { 29 | comment: UpdatesStore.getComment(this.props.params.id) || {}, 30 | parent: {type: 'comment'}, 31 | op: {} 32 | } 33 | }, 34 | 35 | componentWillMount() { 36 | if (SettingsStore.offlineMode) { 37 | HNServiceRest.itemRef(this.props.params.id).then(function(res) { 38 | return res.json() 39 | }).then(function(snapshot) { 40 | this.replaceState({ comment: snapshot }) 41 | }.bind(this)) 42 | } 43 | else { 44 | this.bindAsObject(HNService.itemRef(this.props.params.id), 'comment') 45 | } 46 | if (this.state.comment.id) { 47 | this.commentLoaded(this.state.comment) 48 | } 49 | }, 50 | 51 | componentWillReceiveProps(nextProps) { 52 | if (nextProps.params.id !== this.props.params.id) { 53 | var comment = UpdatesStore.getComment(nextProps.params.id) 54 | if (comment) { 55 | this.commentLoaded(comment) 56 | this.setState({comment: comment}) 57 | } 58 | this.unbind('comment') 59 | this.bindAsObject(HNService.itemRef(nextProps.params.id), 'comment') 60 | } 61 | }, 62 | 63 | componentWillUpdate(nextProps, nextState) { 64 | if (!nextState.comment) { 65 | return 66 | } 67 | 68 | if (this.state.comment.id !== nextState.comment.id) { 69 | if (!nextState.comment.deleted) { 70 | // Redirect to the appropriate route if a Comment "parent" link had a 71 | // non-comment item id. 72 | if (nextState.comment.type !== 'comment') { 73 | this.props.router.replace(`/${nextState.comment.type}/${nextState.comment.id}`) 74 | return 75 | } 76 | } 77 | if (!this.threadStore || this.threadStore.itemId !== nextState.comment.id) { 78 | this.commentLoaded(nextState.comment) 79 | } 80 | } 81 | }, 82 | 83 | commentLoaded(comment) { 84 | this.setTitle(comment) 85 | if (!comment.deleted) { 86 | this.threadStore = new CommentThreadStore(comment, this.handleCommentsChanged) 87 | this.fetchAncestors(comment) 88 | } 89 | }, 90 | 91 | setTitle(comment) { 92 | if (comment.deleted) { 93 | return setTitle('Deleted comment') 94 | } 95 | var title = 'Comment by ' + comment.by 96 | if (this.state.op.id) { 97 | title += ' | ' + this.state.op.title 98 | } 99 | setTitle(title) 100 | }, 101 | 102 | handleCommentsChanged(payload) { 103 | // We're only interested in re-rendering to update collapsed display 104 | if (payload.type === 'collapse') { 105 | this.forceUpdate() 106 | } 107 | }, 108 | 109 | render() { 110 | var comment = this.state.comment 111 | if (!comment) { 112 | return this.renderError(comment, { 113 | id: this.props.params.id, 114 | className: 'Comment Comment--level0 Comment--error' 115 | }) 116 | } 117 | // Render a placeholder while we're waiting for the comment to load 118 | if (!comment.id) { return this.renderCommentLoading(comment) } 119 | // Render a link to HN for deleted comments 120 | if (comment.deleted) { 121 | return this.renderCommentDeleted(comment, { 122 | className: 'Comment Comment--level0 Comment--deleted' 123 | }) 124 | } 125 | // XXX Don't render anything if we're replacing the route after loading a non-comment 126 | if (comment.type !== 'comment') { return null } 127 | 128 | var className = cx('PermalinkedComment Comment Comment--level0', {'Comment--dead': comment.dead}) 129 | var threadStore = this.threadStore 130 | 131 | return
132 |
133 | {this.renderCommentMeta(comment, { 134 | parent: !!this.state.parent.id && !!this.state.op.id && comment.parent !== this.state.op.id, 135 | op: !!this.state.op.id 136 | })} 137 | {(!comment.dead || SettingsStore.showDead) && this.renderCommentText(comment, {replyLink: true})} 138 |
139 | {comment.kids &&
140 | {comment.kids.map(function(id, index) { 141 | return 146 | })} 147 |
} 148 |
149 | } 150 | }) 151 | 152 | module.exports = withRouter(PermalinkedComment) 153 | -------------------------------------------------------------------------------- /src/PollOption.js: -------------------------------------------------------------------------------- 1 | var React = require('react') 2 | var ReactFireMixin = require('reactfire') 3 | 4 | var HNService = require('./services/HNService') 5 | 6 | var Spinner = require('./Spinner') 7 | 8 | var pluralise = require('./utils/pluralise') 9 | 10 | var PollOption = React.createClass({ 11 | mixins: [ReactFireMixin], 12 | 13 | getInitialState() { 14 | return {pollopt: {}} 15 | }, 16 | 17 | componentWillMount() { 18 | this.bindAsObject(HNService.itemRef(this.props.id), 'pollopt') 19 | }, 20 | 21 | render() { 22 | var pollopt = this.state.pollopt 23 | if (!pollopt.id) { return
} 24 | return
25 |
26 | {pollopt.text} 27 |
28 |
29 | {pollopt.score} point{pluralise(pollopt.score)} 30 |
31 |
32 | } 33 | }) 34 | 35 | module.exports = PollOption 36 | -------------------------------------------------------------------------------- /src/Settings.js: -------------------------------------------------------------------------------- 1 | var React = require('react') 2 | 3 | var SettingsStore = require('./stores/SettingsStore') 4 | 5 | var Settings = React.createClass({ 6 | componentDidMount() { 7 | this.refs.container.focus() 8 | }, 9 | 10 | onChange(e) { 11 | var el = e.target 12 | if (el.type === 'checkbox') { 13 | SettingsStore[el.name] = el.checked 14 | } 15 | else if (el.type === 'number' && el.value) { 16 | SettingsStore[el.name] = el.value 17 | } 18 | this.forceUpdate() 19 | SettingsStore.save() 20 | }, 21 | 22 | onClick(e) { 23 | e.stopPropagation() 24 | }, 25 | 26 | render() { 27 | return
28 |
29 |
30 | 33 |

Automatically collapse comment threads without new comments on page load.

34 |
35 |
36 | 39 |

Show "reply" links to Hacker News

40 |
41 |
42 | 45 |

Cache comments and content offline.

46 |
47 |
48 | 51 |

Show items flagged as dead.

52 |
53 |
54 | 57 |

Show comments flagged as deleted in threads.

58 |
59 |
60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 |
72 |
73 |
74 |
75 | } 76 | }) 77 | 78 | module.exports = Settings 79 | -------------------------------------------------------------------------------- /src/Spinner.js: -------------------------------------------------------------------------------- 1 | var React = require('react') 2 | 3 | // TODO Implement GIF-based fallback for IE9 and another non-animating browsers 4 | // See https://github.com/tobiasahlin/SpinKit for how-to 5 | var Spinner = React.createClass({ 6 | getDefaultProps() { 7 | return {size: 6, spacing: 2} 8 | }, 9 | 10 | render() { 11 | var bounceSize = this.props.size + 'px' 12 | var bounceStyle = {height: bounceSize, width: bounceSize, marginRight: this.props.spacing + 'px'} 13 | return
14 |
15 |
16 |
17 |
18 | } 19 | }) 20 | 21 | module.exports = Spinner 22 | -------------------------------------------------------------------------------- /src/Stories.js: -------------------------------------------------------------------------------- 1 | var React = require('react') 2 | 3 | var StoryStore = require('./stores/StoryStore') 4 | 5 | var PageNumberMixin = require('./mixins/PageNumberMixin') 6 | var Paginator = require('./Paginator') 7 | var Spinner = require('./Spinner') 8 | var StoryListItem = require('./StoryListItem') 9 | var SettingsStore = require('./stores/SettingsStore') 10 | 11 | var {ITEMS_PER_PAGE} = require('./utils/constants') 12 | var pageCalc = require('./utils/pageCalc') 13 | var setTitle = require('./utils/setTitle') 14 | 15 | var Stories = React.createClass({ 16 | mixins: [PageNumberMixin], 17 | 18 | propTypes: { 19 | // The number of stories which may be paginated through 20 | limit: React.PropTypes.number.isRequired, 21 | // The route name being used 22 | route: React.PropTypes.string.isRequired, 23 | // The type of stories to be displayed 24 | type: React.PropTypes.string.isRequired, 25 | 26 | // Page title associated with the stories being displayed 27 | title: React.PropTypes.string 28 | }, 29 | 30 | getInitialState() { 31 | return { 32 | ids: [], 33 | limit: this.props.limit, 34 | stories: [] 35 | } 36 | }, 37 | 38 | componentDidMount() { 39 | setTitle(this.props.title) 40 | this.store = new StoryStore(this.props.type) 41 | this.store.addListener('update', this.handleUpdate) 42 | this.store.start() 43 | this.setState(this.store.getState()) 44 | }, 45 | 46 | componentWillUnmount() { 47 | this.store.removeListener('update', this.handleUpdate) 48 | this.store.stop() 49 | this.store = null 50 | }, 51 | 52 | handleUpdate(update) { 53 | if (!this.isMounted()) { 54 | if (process.env.NODE_ENV !== 'production') { 55 | console.warn( 56 | `Skipping update as the ${this.props.type} Stories component is no longer mounted.` 57 | ) 58 | } 59 | return 60 | } 61 | update.limit = update.ids.length 62 | this.setState(update) 63 | }, 64 | 65 | render() { 66 | var page = pageCalc(this.getPageNumber(), ITEMS_PER_PAGE, this.state.limit) 67 | 68 | // Display a list of placeholder items while we're waiting for the initial 69 | // list of story ids to load from Firebase. 70 | if (this.state.stories.length === 0 && this.state.ids.length === 0 && this.getPageNumber() > 0) { 71 | var dummyItems = [] 72 | for (var i = page.startIndex; i < page.endIndex; i++) { 73 | dummyItems.push( 74 |
  • 75 | 76 |
  • 77 | ) 78 | } 79 | return
    80 |
      {dummyItems}
    81 | 82 |
    83 | } 84 | 85 | return
    86 |
      87 | {this.renderItems(page.startIndex, page.endIndex)} 88 |
    89 | 90 |
    91 | }, 92 | 93 | renderItems(startIndex, endIndex) { 94 | var rendered = [] 95 | for (var i = startIndex; i < endIndex; i++) { 96 | var item = this.state.stories[i] 97 | var id = this.state.ids[i] 98 | if (id) { 99 | rendered.push() 100 | } 101 | else { 102 | rendered.push() 103 | } 104 | } 105 | return rendered 106 | } 107 | }) 108 | 109 | module.exports = Stories 110 | -------------------------------------------------------------------------------- /src/StoryListItem.js: -------------------------------------------------------------------------------- 1 | var React = require('react') 2 | var ReactFireMixin = require('reactfire') 3 | 4 | var StoryCommentThreadStore = require('./stores/StoryCommentThreadStore') 5 | var HNService = require('./services/HNService') 6 | var HNServiceRest = require('./services/HNServiceRest') 7 | var SettingsStore = require('./stores/SettingsStore') 8 | var StoryStore = require('./stores/StoryStore') 9 | 10 | var ItemMixin = require('./mixins/ItemMixin') 11 | var ListItemMixin = require('./mixins/ListItemMixin') 12 | var Spinner = require('./Spinner') 13 | 14 | /** 15 | * Display story title and metadata as as a list item. 16 | * Cached story data may be given as a prop, but this component is also 17 | * responsible for listening to updates to the story and providing the latest 18 | * version for StoryStore's cache. 19 | */ 20 | var StoryListItem = React.createClass({ 21 | mixins: [ItemMixin, ListItemMixin, ReactFireMixin], 22 | 23 | propTypes: { 24 | // The StoryStore handling caching and updates to the stories being displayed 25 | store: React.PropTypes.instanceOf(StoryStore).isRequired, 26 | 27 | // The story's id in Hacker News 28 | id: React.PropTypes.number, 29 | // A version of the story from the cache, for initial display 30 | cachedItem: React.PropTypes.object, 31 | // The current index of the story in the list being displayed 32 | index: React.PropTypes.number 33 | }, 34 | 35 | getDefaultProps() { 36 | return { 37 | id: null, 38 | cachedItem: null, 39 | index: null 40 | } 41 | }, 42 | 43 | getInitialState() { 44 | return { 45 | item: this.props.cachedItem || {} 46 | } 47 | }, 48 | 49 | componentWillMount() { 50 | if (this.props.id != null) { 51 | this.initLiveItem(this.props) 52 | } 53 | else if (this.props.cachedItem != null) { 54 | // Display the comment state of the cached item we were given while we're 55 | // waiting for the live item to load. 56 | this.threadState = StoryCommentThreadStore.loadState(this.state.item.id) 57 | } 58 | }, 59 | 60 | componentWillUnmount() { 61 | if (this.props.id != null) { 62 | this.props.store.removeListener(this.props.id, this.updateThreadState) 63 | } 64 | }, 65 | 66 | /** 67 | * Catch the transition from not having an id prop to having one. 68 | * Scenario: we were waiting for the initial list of story ids to load. 69 | */ 70 | componentWillReceiveProps(nextProps) { 71 | if (this.props.id == null && nextProps.id != null) { 72 | this.initLiveItem(nextProps) 73 | } 74 | }, 75 | 76 | /** 77 | * If the live item has been loaded or updated, update the StoryStore cache 78 | * with its current index and latest data. 79 | */ 80 | componentWillUpdate(nextProps, nextState) { 81 | if (this.state.item !== nextState.item) { 82 | if (nextState.item != null) { 83 | this.props.store.itemUpdated(nextState.item, this.props.index) 84 | } 85 | else { 86 | if (process.env.NODE_ENV !== 'production') { 87 | console.warn(`Item ${this.props.id} went from ${JSON.stringify(this.state.item)} to ${nextProps.item}`) 88 | } 89 | } 90 | } 91 | }, 92 | 93 | /** 94 | * Initialise listening to updates for the item with the given id and 95 | * initialise its comment thread state. 96 | */ 97 | initLiveItem(props) { 98 | if (SettingsStore.offlineMode) { 99 | HNServiceRest.itemRef(props.id).then(function(res) { 100 | return res.json() 101 | }).then(function(snapshot) { 102 | this.replaceState({ item: snapshot }) 103 | }.bind(this)) 104 | } 105 | else { 106 | // If we were given a cached item to display initially, it will be replaced 107 | this.bindAsObject(HNService.itemRef(props.id), 'item') 108 | } 109 | 110 | this.threadState = StoryCommentThreadStore.loadState(props.id) 111 | this.props.store.addListener(props.id, this.updateThreadState) 112 | }, 113 | 114 | /** 115 | * Update thread state in response to a storage event indicating it has been 116 | * modified. 117 | */ 118 | updateThreadState() { 119 | this.threadState = StoryCommentThreadStore.loadState(this.props.id) 120 | this.forceUpdate() 121 | }, 122 | 123 | render() { 124 | // Display the loading spinner if we have nothing to show initially 125 | if (!this.state.item || !this.state.item.id) { 126 | return
  • 127 | 128 |
  • 129 | } 130 | 131 | return this.renderListItem(this.state.item, this.threadState) 132 | } 133 | }) 134 | 135 | module.exports = StoryListItem 136 | -------------------------------------------------------------------------------- /src/Updates.js: -------------------------------------------------------------------------------- 1 | var React = require('react') 2 | 3 | var SettingsStore = require('./stores/SettingsStore') 4 | var UpdatesStore = require('./stores/UpdatesStore') 5 | 6 | var DisplayListItem = require('./DisplayListItem') 7 | var DisplayComment = require('./DisplayComment') 8 | var Paginator = require('./Paginator') 9 | var Spinner = require('./Spinner') 10 | 11 | var PageNumberMixin = require('./mixins/PageNumberMixin') 12 | 13 | var {ITEMS_PER_PAGE} = require('./utils/constants') 14 | var pageCalc = require('./utils/pageCalc') 15 | var setTitle = require('./utils/setTitle') 16 | 17 | function filterDead(item) { 18 | return !item.dead 19 | } 20 | 21 | function filterUpdates(updates) { 22 | if (!SettingsStore.showDead) { 23 | return { 24 | comments: updates.comments.filter(filterDead), 25 | stories: updates.stories.filter(filterDead) 26 | } 27 | } 28 | return updates 29 | } 30 | 31 | var Updates = React.createClass({ 32 | mixins: [PageNumberMixin], 33 | 34 | getInitialState() { 35 | return filterUpdates(UpdatesStore.getUpdates()) 36 | }, 37 | 38 | componentWillMount() { 39 | this.setTitle(this.props.type) 40 | UpdatesStore.start() 41 | UpdatesStore.on('updates', this.handleUpdates) 42 | }, 43 | 44 | componentWillUnmount() { 45 | UpdatesStore.off('updates', this.handleUpdates) 46 | UpdatesStore.stop() 47 | }, 48 | 49 | componentWillReceiveProps(nextProps) { 50 | if (this.props.type !== nextProps.type) { 51 | this.setTitle(nextProps.type) 52 | } 53 | }, 54 | 55 | setTitle(type) { 56 | setTitle('New ' + (type === 'comments' ? 'Comments' : 'Links')) 57 | }, 58 | 59 | handleUpdates(updates) { 60 | if (!this.isMounted()) { 61 | if (process.env.NODE_ENV !== 'production') { 62 | console.warn('Skipping update of ' + this.props.type + ' as the Updates component is not mounted') 63 | } 64 | return 65 | } 66 | this.setState(filterUpdates(updates)) 67 | }, 68 | 69 | render() { 70 | var items = (this.props.type === 'comments' ? this.state.comments : this.state.stories) 71 | if (items.length === 0) { 72 | return
    73 | } 74 | 75 | var page = pageCalc(this.getPageNumber(), ITEMS_PER_PAGE, items.length) 76 | 77 | if (this.props.type === 'comments') { 78 | return
    79 | {items.slice(page.startIndex, page.endIndex).map(function(comment) { 80 | return 81 | })} 82 | 83 |
    84 | } 85 | else { 86 | return
    87 |
      88 | {items.slice(page.startIndex, page.endIndex).map(function(item) { 89 | return 90 | })} 91 |
    92 | 93 |
    94 | } 95 | } 96 | }) 97 | 98 | module.exports = Updates 99 | -------------------------------------------------------------------------------- /src/UserProfile.js: -------------------------------------------------------------------------------- 1 | var React = require('react') 2 | var ReactFireMixin = require('reactfire') 3 | var TimeAgo = require('react-timeago').default 4 | 5 | var HNService = require('./services/HNService') 6 | 7 | var Spinner = require('./Spinner') 8 | 9 | var setTitle = require('./utils/setTitle') 10 | 11 | // TODO User submissions 12 | 13 | // TODO User comments 14 | 15 | var UserProfile = React.createClass({ 16 | mixins: [ReactFireMixin], 17 | getInitialState() { 18 | return {user: {}} 19 | }, 20 | 21 | componentWillMount() { 22 | this.bindAsObject(HNService.userRef(this.props.params.id), 'user') 23 | }, 24 | 25 | componentWillUpdate(nextProps, nextState) { 26 | if (this.state.user.id !== nextState.user.id) { 27 | setTitle('Profile: ' + nextState.user.id) 28 | } 29 | }, 30 | 31 | componentWillReceiveProps(nextProps) { 32 | if (this.props.params.id !== nextProps.params.id) { 33 | this.unbind('user') 34 | this.bindAsObject(HNService.userRef(nextProps.params.id), 'user') 35 | } 36 | }, 37 | 38 | render() { 39 | var user = this.state.user 40 | if (!user.id) { 41 | return
    42 |

    {this.props.params.id}

    43 | 44 |
    45 | } 46 | var createdDate = new Date(user.created * 1000) 47 | return
    48 |

    {user.id}

    49 |
    50 |
    Created
    51 |
    ({createdDate.toDateString()})
    52 |
    Karma
    53 |
    {user.karma}
    54 |
    Delay
    55 |
    {user.delay}
    56 | {user.about &&
    About
    } 57 | {user.about &&
    } 58 |
    59 |
    60 | } 61 | }) 62 | 63 | module.exports = UserProfile 64 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | require('setimmediate') 2 | 3 | var React = require('react') 4 | var {render} = require('react-dom') 5 | var Router = require('react-router/lib/Router') 6 | var createHashHistory = require('history/lib/createHashHistory') 7 | var useScroll = require('react-router-scroll/lib/useScroll') 8 | var applyRouterMiddleware = require('react-router/lib/applyRouterMiddleware') 9 | var history = createHashHistory() 10 | 11 | var routes = require('./routes') 12 | 13 | render(, document.getElementById('app')) 14 | -------------------------------------------------------------------------------- /src/mixins/CommentMixin.js: -------------------------------------------------------------------------------- 1 | var React = require('react') 2 | var Link = require('react-router/lib/Link') 3 | var TimeAgo = require('react-timeago').default 4 | 5 | var ItemStore = require('../stores/ItemStore') 6 | var SettingsStore = require('../stores/SettingsStore') 7 | 8 | var Spinner = require('../Spinner') 9 | 10 | var pluralise = require('../utils/pluralise') 11 | 12 | var CommentMixin = { 13 | fetchAncestors(comment) { 14 | ItemStore.fetchCommentAncestors(comment, result => { 15 | if (process.env.NODE_ENV !== 'production') { 16 | console.info( 17 | 'fetchAncestors(' + comment.id + ') took ' + 18 | result.timeTaken + ' ms for ' + 19 | result.itemCount + ' item' + pluralise(result.itemCount) + ' with ' + 20 | result.cacheHits + ' cache hit' + pluralise(result.cacheHits) + ' (' + 21 | (result.cacheHits / result.itemCount * 100).toFixed(1) + '%)' 22 | ) 23 | } 24 | if (!this.isMounted()) { 25 | if (process.env.NODE_ENV !== 'production') { 26 | console.info("...but the comment isn't mounted") 27 | } 28 | // Too late - the comment or the user has moved elsewhere 29 | return 30 | } 31 | this.setState({ 32 | parent: result.parent, 33 | op: result.op 34 | }) 35 | }) 36 | }, 37 | 38 | renderCommentLoading(comment) { 39 | return
    40 | {(this.props.loadingSpinner || comment.delayed) && } 41 | {comment.delayed &&
    42 | Unable to load comment – this usually indicates the author has configured a delay. 43 | Trying again in 30 seconds. 44 |
    } 45 |
    46 | }, 47 | 48 | renderCommentDeleted(comment, options) { 49 | return
    50 |
    51 |
    52 | [deleted] | view on Hacker News 53 |
    54 |
    55 |
    56 | }, 57 | 58 | renderError(comment, options) { 59 | return
    60 |
    61 |
    62 | [error] | comment is {JSON.stringify(comment)} | view on Hacker News 63 |
    64 |
    65 |
    66 | }, 67 | 68 | renderCollapseControl(collapsed) { 69 | return 70 | [{collapsed ? '+' : '–'}] 71 | 72 | }, 73 | 74 | /** 75 | * @param options.collapsible {Boolean} if true, assumes this.toggleCollspse() 76 | * @param options.collapsed {Boolean} 77 | * @param options.link {Boolean} 78 | * @param options.parent {Boolean} if true, assumes this.state.parent 79 | * @param options.op {Boolean} if true, assumes this.state.op 80 | * @param options.childCounts {Object} with .children and .newComments 81 | */ 82 | renderCommentMeta(comment, options) { 83 | if (comment.dead && !SettingsStore.showDead) { 84 | return
    85 | {options.collapsible && this.renderCollapseControl(options.collapsed)} 86 | {options.collapsible && ' '} 87 | [dead] 88 | {options.childCounts && ' | (' + options.childCounts.children + ' child' + pluralise(options.childCounts.children, ',ren')} 89 | {options.childCounts && options.childCounts.newComments > 0 && ', '} 90 | {options.childCounts && options.childCounts.newComments > 0 && {options.childCounts.newComments} new} 91 | {options.childCounts && ')'} 92 |
    93 | } 94 | 95 | return
    96 | {options.collapsible && this.renderCollapseControl(options.collapsed)} 97 | {options.collapsible && ' '} 98 | {comment.by}{' '} 99 | 100 | {options.link && ' | '} 101 | {options.link && link} 102 | {options.parent && ' | '} 103 | {options.parent && parent} 104 | {options.op && ' | on: '} 105 | {options.op && {this.state.op.title}} 106 | {comment.dead && ' | [dead]'} 107 | {options.childCounts && ' | (' + options.childCounts.children + ' child' + pluralise(options.childCounts.children, ',ren')} 108 | {options.childCounts && options.childCounts.newComments > 0 && ', '} 109 | {options.childCounts && options.childCounts.newComments > 0 && {options.childCounts.newComments} new} 110 | {options.childCounts && ')'} 111 |
    112 | }, 113 | 114 | renderCommentText(comment, options) { 115 | return
    116 | {(!comment.dead || SettingsStore.showDead) ?
    : '[dead]'} 117 | {SettingsStore.replyLinks && options.replyLink && !comment.dead &&

    118 | reply 119 |

    } 120 |
    121 | } 122 | } 123 | 124 | module.exports = CommentMixin 125 | -------------------------------------------------------------------------------- /src/mixins/ItemMixin.js: -------------------------------------------------------------------------------- 1 | var React = require('react') 2 | var Link = require('react-router/lib/Link') 3 | var TimeAgo = require('react-timeago').default 4 | 5 | var SettingsStore = require('../stores/SettingsStore') 6 | var pluralise = require('../utils/pluralise') 7 | var urlParse = require('url-parse') 8 | 9 | var parseHost = function(url) { 10 | var hostname = (urlParse(url, true)).hostname 11 | var parts = hostname.split('.').slice(-3) 12 | if (parts[0] === 'www') { 13 | parts.shift() 14 | } 15 | return parts.join('.') 16 | } 17 | 18 | /** 19 | * Reusable logic for displaying an item. 20 | */ 21 | var ItemMixin = { 22 | /** 23 | * Render an item's metadata bar. 24 | */ 25 | renderItemMeta(item, extraContent) { 26 | var itemDate = new Date(item.time * 1000) 27 | 28 | if (item.type === 'job') { 29 | return
    30 | 31 |
    32 | } 33 | 34 | return
    35 | 36 | {item.score} point{pluralise(item.score)} 37 | {' '} 38 | 39 | by {item.by} 40 | {' '} 41 | 42 | {' | '} 43 | 44 | {item.descendants > 0 ? item.descendants + ' comment' + pluralise(item.descendants) : 'discuss'} 45 | 46 | {extraContent} 47 |
    48 | }, 49 | 50 | /** 51 | * Render an item's title bar. 52 | */ 53 | renderItemTitle(item) { 54 | var hasURL = !!item.url 55 | var title 56 | if (item.dead) { 57 | title = '[dead] ' + item.title 58 | } 59 | else { 60 | title = (hasURL ? {item.title} 61 | : {item.title}) 62 | } 63 | return
    64 | {title} 65 | {hasURL && ' '} 66 | {hasURL && ({parseHost(item.url)})} 67 |
    68 | } 69 | } 70 | 71 | module.exports = ItemMixin 72 | -------------------------------------------------------------------------------- /src/mixins/ListItemMixin.js: -------------------------------------------------------------------------------- 1 | var React = require('react') 2 | var Link = require('react-router/lib/Link') 3 | 4 | var SettingsStore = require('../stores/SettingsStore') 5 | var cx = require('../utils/buildClassName') 6 | 7 | /** 8 | * Reusable logic for displaying an item in a list. 9 | * Must be used in conjunction with ItemMixin for its rendering methods. 10 | */ 11 | var ListItemMixin = { 12 | getNewCommentCount(item, threadState) { 13 | if (threadState.lastVisit === null) { 14 | return 0 15 | } 16 | return item.descendants - threadState.commentCount 17 | }, 18 | 19 | renderListItem(item, threadState) { 20 | if (item.deleted) { return null } 21 | var newCommentCount = this.getNewCommentCount(item, threadState) 22 | return
  • 23 | {this.renderItemTitle(item)} 24 | {this.renderItemMeta(item, (newCommentCount > 0 && {' '} 25 | ( 26 | {newCommentCount} new 27 | ) 28 | ))} 29 |
  • 30 | } 31 | } 32 | 33 | module.exports = ListItemMixin 34 | -------------------------------------------------------------------------------- /src/mixins/PageNumberMixin.js: -------------------------------------------------------------------------------- 1 | var PageNumberMixin = { 2 | getPageNumber(page) { 3 | if (typeof page == 'undefined') { 4 | page = this.props.location.query.page 5 | } 6 | return (page && /^\d+$/.test(page) ? Math.max(1, Number(page)) : 1) 7 | } 8 | } 9 | 10 | module.exports = PageNumberMixin 11 | -------------------------------------------------------------------------------- /src/routes.js: -------------------------------------------------------------------------------- 1 | var IndexRoute = require('react-router/lib/IndexRoute') 2 | var React = require('react') 3 | var Route = require('react-router/lib/Route') 4 | var Item = require('./Item') 5 | // Polyfill require.ensure 6 | if (typeof require.ensure !== 'function') require.ensure = function(d, c) { c(require) } 7 | 8 | var App = require('./App') 9 | var Stories = require('./Stories') 10 | var Updates = require('./Updates') 11 | 12 | function stories(route, type, limit, title) { 13 | return React.createClass({ 14 | render() { 15 | return 16 | } 17 | }) 18 | } 19 | 20 | function updates(type) { 21 | return React.createClass({ 22 | render() { 23 | return 24 | } 25 | }) 26 | } 27 | 28 | var Ask = stories('ask', 'askstories', 200, 'Ask') 29 | var Comments = updates('comments') 30 | var Jobs = stories('jobs', 'jobstories', 200, 'Jobs') 31 | var New = stories('newest', 'newstories', 500, 'New Links') 32 | var Show = stories('show', 'showstories', 200, 'Show') 33 | var Top = stories('news', 'topstories', 500) 34 | 35 | module.exports = 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | { 49 | require.ensure([], require => { 50 | callback(null, require('./PermalinkedComment')) 51 | }, 'PermalinkedComment') 52 | }} 53 | /> 54 | 55 | { 58 | require.ensure([], require => { 59 | callback(null, require('./UserProfile')) 60 | }, 'UserProfile') 61 | }} 62 | /> 63 | { 66 | require.ensure([], require => { 67 | callback(null, require('./NotFound')) 68 | }, 'NotFound') 69 | }} 70 | /> 71 | 72 | -------------------------------------------------------------------------------- /src/services/HNService.js: -------------------------------------------------------------------------------- 1 | var firebase = require('firebase/app') 2 | require('firebase/database') 3 | 4 | var config = { 5 | databaseURL: 'https://hacker-news.firebaseio.com' 6 | } 7 | firebase.initializeApp(config) 8 | var version = '/v0' 9 | var api = firebase.database().ref(version) 10 | 11 | // https://firebase.google.com/support/guides/firebase-web 12 | 13 | function fetchItem(id, cb) { 14 | itemRef(id).once('value', function(snapshot) { 15 | cb(snapshot.val()) 16 | }) 17 | } 18 | 19 | function fetchItems(ids, cb) { 20 | var items = [] 21 | ids.forEach(function(id) { 22 | fetchItem(id, addItem) 23 | }) 24 | function addItem(item) { 25 | items.push(item) 26 | if (items.length >= ids.length) { 27 | cb(items) 28 | } 29 | } 30 | } 31 | 32 | function storiesRef(path) { 33 | return api.child(path) 34 | } 35 | 36 | function itemRef(id) { 37 | return api.child('item/' + id) 38 | } 39 | 40 | function userRef(id) { 41 | return api.child('user/' + id) 42 | } 43 | 44 | function updatesRef() { 45 | return api.child('updates/items') 46 | } 47 | 48 | module.exports = { 49 | fetchItem, 50 | fetchItems, 51 | storiesRef, 52 | itemRef, 53 | userRef, 54 | updatesRef 55 | } 56 | -------------------------------------------------------------------------------- /src/services/HNServiceRest.js: -------------------------------------------------------------------------------- 1 | /* global fetch */ 2 | require('isomorphic-fetch') 3 | /* 4 | A version of HNService which concumes the Firebase REST 5 | endpoint (https://www.firebase.com/docs/rest/api/). This 6 | is used when a user has enabled 'Offline Mode' in the 7 | Settings panel and ensures responses can be easily fetched 8 | and cached when paired with Service Worker. This cannot be 9 | trivially done using just Web Sockets with the default 10 | Firebase API and provides a sufficient fallback that works. 11 | */ 12 | var endPoint = 'https://hacker-news.firebaseio.com/v0' 13 | var options = { 14 | method: 'GET', 15 | headers: { 16 | 'Accept': 'application/json' 17 | } 18 | } 19 | 20 | function storiesRef(path) { 21 | return fetch(endPoint + '/' + path + '.json', options) 22 | } 23 | 24 | function itemRef(id) { 25 | return fetch(endPoint + '/item/' + id + '.json', options) 26 | } 27 | 28 | function itemRefJSON(id) { 29 | return itemRef(id).then(function(response) { 30 | return response.json() 31 | }) 32 | } 33 | 34 | function userRef(id) { 35 | return fetch(endPoint + '/user/' + id + '.json', options) 36 | } 37 | 38 | function updatesRef() { 39 | return fetch(endPoint + '/updates/items/' + '.json', options) 40 | } 41 | 42 | function fetchItem(id, cb) { 43 | itemRef(id).then(function(snapshot) { 44 | cb(snapshot) 45 | }) 46 | } 47 | 48 | function fetchItems(ids, cb) { 49 | var items = [] 50 | var promises = [] 51 | ids.forEach(function(id) { 52 | promises.push(itemRefJSON(id)) 53 | }) 54 | Promise.all(promises).then(function(values) { 55 | items = values 56 | if (items.length >= ids.length) { 57 | cb(items) 58 | } 59 | }) 60 | } 61 | 62 | module.exports = { 63 | fetchItem, 64 | fetchItems, 65 | storiesRef, 66 | itemRef, 67 | userRef, 68 | updatesRef 69 | } 70 | -------------------------------------------------------------------------------- /src/stores/CommentThreadStore.js: -------------------------------------------------------------------------------- 1 | var extend = require('../utils/extend') 2 | 3 | function CommentThreadStore(item, onCommentsChanged) { 4 | this.itemId = item.id 5 | this.onCommentsChanged = onCommentsChanged 6 | 7 | /** 8 | * Lookup from a comment id to its child comment ids. 9 | * @type {Object.>} 10 | */ 11 | this.children = {} 12 | this.children[item.id] = [] 13 | 14 | /** 15 | * Lookup for new comment ids. Will only contain true. 16 | * @type {Object.} 17 | */ 18 | this.isNew = {} 19 | 20 | /** 21 | * Lookup for collapsed state of comment ids. May contain true or false. 22 | * @type {Object.} 23 | */ 24 | this.isCollapsed = {} 25 | } 26 | 27 | extend(CommentThreadStore.prototype, { 28 | /** 29 | * Get counts of children and new comments under the given comment. 30 | * @return .children {Number} 31 | * @return .newComments {Number} 32 | */ 33 | getChildCounts(comment) { 34 | var childCount = 0 35 | var newCommentCount = 0 36 | var nodes = [comment.id] 37 | 38 | while (nodes.length) { 39 | var nextNodes = [] 40 | for (var i = 0, l = nodes.length; i < l; i++) { 41 | var nodeChildren = this.children[nodes[i]] 42 | if (nodeChildren.length) { 43 | nextNodes.push.apply(nextNodes, nodeChildren) 44 | } 45 | } 46 | for (i = 0, l = nextNodes.length; i < l; i++) { 47 | if (this.isNew[nextNodes[i]]) { 48 | newCommentCount++ 49 | } 50 | } 51 | childCount += nextNodes.length 52 | nodes = nextNodes 53 | } 54 | 55 | return { 56 | children: childCount, 57 | newComments: newCommentCount 58 | } 59 | }, 60 | 61 | /** 62 | * Register a comment's appearance in the thread. 63 | */ 64 | commentAdded(comment) { 65 | if (comment.deleted) { return } 66 | 67 | this.children[comment.id] = [] 68 | this.children[comment.parent].push(comment.id) 69 | }, 70 | 71 | /** 72 | * Register a comment's deletion from the thread. 73 | */ 74 | commentDeleted(comment) { 75 | // Comments which initially failed to load (null from Firebase API) can be 76 | // deleted by the time the API catches up. 77 | if (!comment) { return } 78 | 79 | var siblings = this.children[comment.parent] 80 | siblings.splice(siblings.indexOf(comment.id), 1) 81 | }, 82 | 83 | toggleCollapse(commentId) { 84 | this.isCollapsed[commentId] = !this.isCollapsed[commentId] 85 | this.onCommentsChanged({type: 'collapse'}) 86 | } 87 | }) 88 | 89 | module.exports = CommentThreadStore 90 | -------------------------------------------------------------------------------- /src/stores/ItemStore.js: -------------------------------------------------------------------------------- 1 | var HNService = require('../services/HNService') 2 | var HNServiceRest = require('../services/HNServiceRest') 3 | 4 | var StoryStore = require('./StoryStore') 5 | var UpdatesStore = require('./UpdatesStore') 6 | var SettingsStore = require('./SettingsStore') 7 | var commentParentLookup = {} 8 | var titleCache = {} 9 | 10 | function fetchCommentParent(comment, cb, result) { 11 | var commentId = comment.id 12 | var parentId = comment.parent 13 | 14 | while (commentParentLookup[parentId] || titleCache[parentId]) { 15 | // We just saved ourselves an item fetch 16 | result.itemCount++ 17 | result.cacheHits++ 18 | 19 | // The parent is a known non-comment 20 | if (titleCache[parentId]) { 21 | if (result.itemCount === 1) { result.parent = titleCache[parentId] } 22 | result.op = titleCache[parentId] 23 | cb(result) 24 | return 25 | } 26 | 27 | // The parent is a known comment 28 | if (commentParentLookup[parentId]) { 29 | if (result.itemCount === 1) { result.parent = {id: parentId, type: 'comment'} } 30 | // Set the parent comment's ids up for the next iteration 31 | commentId = parentId 32 | parentId = commentParentLookup[parentId] 33 | } 34 | } 35 | 36 | // The parent of the current comment isn't known, so we'll have to fetch it 37 | ItemStore.getItem(parentId, function(parent) { 38 | result.itemCount++ 39 | // Add the current comment's parent to the lookup for next time 40 | commentParentLookup[commentId] = parentId 41 | if (parent.type === 'comment') { 42 | commentParentLookup[parent.id] = parent.parent 43 | } 44 | processCommentParent(parent, cb, result) 45 | }, result) 46 | } 47 | 48 | function processCommentParent(item, cb, result) { 49 | if (result.itemCount === 1) { 50 | result.parent = item 51 | } 52 | if (item.type !== 'comment') { 53 | result.op = item 54 | titleCache[item.id] = { 55 | id: item.id, 56 | type: item.type, 57 | title: item.title 58 | } 59 | cb(result) 60 | } 61 | else { 62 | fetchCommentParent(item, cb, result) 63 | } 64 | } 65 | 66 | var ItemStore = { 67 | getItem(id, cb, result) { 68 | var cachedItem = this.getCachedItem(id) 69 | if (cachedItem) { 70 | if (result) { 71 | result.cacheHits++ 72 | } 73 | setImmediate(cb, cachedItem) 74 | } 75 | else { 76 | if (SettingsStore.offlineMode) { 77 | HNServiceRest.fetchItem(id, cb) 78 | } 79 | else { 80 | HNService.fetchItem(id, cb) 81 | } 82 | } 83 | }, 84 | 85 | getCachedItem(id) { 86 | return StoryStore.getItem(id) || UpdatesStore.getItem(id) || null 87 | }, 88 | 89 | getCachedStory(id) { 90 | return StoryStore.getItem(id) || UpdatesStore.getStory(id) || null 91 | }, 92 | 93 | fetchCommentAncestors(comment, cb) { 94 | var startTime = Date.now() 95 | var result = {itemCount: 0, cacheHits: 0} 96 | fetchCommentParent(comment, function() { 97 | result.timeTaken = Date.now() - startTime 98 | setImmediate(cb, result) 99 | }, result) 100 | } 101 | } 102 | 103 | module.exports = ItemStore 104 | -------------------------------------------------------------------------------- /src/stores/SettingsStore.js: -------------------------------------------------------------------------------- 1 | var extend = require('../utils/extend') 2 | var storage = require('../utils/storage') 3 | 4 | var STORAGE_KEY = 'settings' 5 | 6 | var SettingsStore = { 7 | autoCollapse: true, 8 | replyLinks: true, 9 | showDead: false, 10 | showDeleted: false, 11 | titleFontSize: 18, 12 | listSpacing: 16, 13 | offlineMode: false, 14 | 15 | load() { 16 | var json = storage.get(STORAGE_KEY) 17 | if (json) { 18 | extend(this, JSON.parse(json)) 19 | } 20 | }, 21 | 22 | save() { 23 | storage.set(STORAGE_KEY, JSON.stringify({ 24 | autoCollapse: this.autoCollapse, 25 | replyLinks: this.replyLinks, 26 | showDead: this.showDead, 27 | showDeleted: this.showDeleted, 28 | titleFontSize: this.titleFontSize, 29 | listSpacing: this.listSpacing, 30 | offlineMode: this.offlineMode 31 | })) 32 | } 33 | } 34 | 35 | module.exports = SettingsStore 36 | -------------------------------------------------------------------------------- /src/stores/StoryCommentThreadStore.js: -------------------------------------------------------------------------------- 1 | var CommentThreadStore = require('./CommentThreadStore') 2 | var SettingsStore = require('./SettingsStore') 3 | 4 | var debounce = require('../utils/cancellableDebounce') 5 | var extend = require('../utils/extend') 6 | var pluralise = require('../utils/pluralise') 7 | var storage = require('../utils/storage') 8 | 9 | /** 10 | * Load persisted comment thread state. 11 | * @return .lastVisit {Date} null if the item hasn't been visited before. 12 | * @return .commentCount {Number} 0 if the item hasn't been visited before. 13 | * @return .maxCommentId {Number} 0 if the item hasn't been visited before. 14 | */ 15 | function loadState(itemId) { 16 | var json = storage.get(itemId) 17 | if (json) { 18 | return JSON.parse(json) 19 | } 20 | return { 21 | lastVisit: null, 22 | commentCount: 0, 23 | maxCommentId: 0 24 | } 25 | } 26 | 27 | function StoryCommentThreadStore(item, onCommentsChanged, options) { 28 | CommentThreadStore.call(this, item, onCommentsChanged) 29 | this.startedLoading = Date.now() 30 | 31 | /** Lookup from a comment id to its parent comment id. */ 32 | this.parents = {} 33 | /** The number of comments which have loaded. */ 34 | this.commentCount = 0 35 | /** The number of new comments which have loaded. */ 36 | this.newCommentCount = 0 37 | /** The max comment id seen by the store. */ 38 | this.maxCommentId = 0 39 | /** Has the comment thread finished loading? */ 40 | this.loading = true 41 | /** The number of comments we're expecting to load. */ 42 | this.expectedComments = item.kids ? item.kids.length : 0 43 | /** 44 | * The number of descendants the story has according to the API. 45 | * This count includes deleted comments, which aren't accessible via the API, 46 | * so a thread with deleted comments (example story id: 9273709) will never 47 | * load this number of comments 48 | * However, we still need to persist the last known descendant count in order 49 | * to determine how many new comments there are when displaying the story on a 50 | * list page. 51 | */ 52 | this.itemDescendantCount = item.descendants 53 | 54 | var initialState = loadState(item.id) 55 | /** Time of last visit to the story. */ 56 | this.lastVisit = initialState.lastVisit 57 | /** Max comment id on the last visit - determines which comments are new. */ 58 | this.prevMaxCommentId = initialState.maxCommentId 59 | /** Is this the user's first time viewing the story? */ 60 | this.isFirstVisit = (initialState.lastVisit === null) 61 | 62 | // Trigger an immediate check for thread load completion if the item was not 63 | // retrieved from the cache, so is the latest version. This completes page 64 | // loading immediately for items which have no comments yet. 65 | if (!options.cached) { 66 | this.checkLoadCompletion() 67 | } 68 | } 69 | 70 | StoryCommentThreadStore.loadState = loadState 71 | 72 | StoryCommentThreadStore.prototype = extend(Object.create(CommentThreadStore.prototype), { 73 | constructor: StoryCommentThreadStore, 74 | 75 | /** 76 | * Callback to the item component with updated comment counts, debounced as 77 | * comments will be loading frequently on initial load. 78 | */ 79 | numberOfCommentsChanged: debounce(function() { 80 | this.onCommentsChanged({type: 'number'}) 81 | }, 123), 82 | 83 | /** 84 | * If we don't have a last visit time stored for an item, it must have been 85 | * visited for the first time. Once it finishes loading, establish the last 86 | * visit time and max comment id which will be used to track and display new 87 | * comments. 88 | */ 89 | firstLoadComplete() { 90 | this.lastVisit = Date.now() 91 | this.prevMaxCommentId = this.maxCommentId 92 | this.isFirstVisit = false 93 | this.onCommentsChanged({type: 'first_load_complete'}) 94 | }, 95 | 96 | /** 97 | * Check whether the number of comments has reached the expected number yet. 98 | */ 99 | checkLoadCompletion() { 100 | if (this.loading && this.commentCount >= this.expectedComments) { 101 | if (process.env.NODE_ENV !== 'production') { 102 | console.info( 103 | 'Initial load of ' + 104 | this.commentCount + ' comment' + pluralise(this.commentCount) + 105 | ' for ' + this.itemId + ' took ' + 106 | ((Date.now() - this.startedLoading) / 1000).toFixed(2) + 's' 107 | ) 108 | } 109 | this.loading = false 110 | if (this.isFirstVisit) { 111 | this.firstLoadComplete() 112 | } 113 | else if (SettingsStore.autoCollapse && this.newCommentCount > 0) { 114 | this.collapseThreadsWithoutNewComments() 115 | } 116 | this._storeState() 117 | } 118 | }, 119 | 120 | /** 121 | * Persist comment thread state. 122 | */ 123 | _storeState() { 124 | storage.set(this.itemId, JSON.stringify({ 125 | lastVisit: Date.now(), 126 | commentCount: this.itemDescendantCount, 127 | maxCommentId: this.maxCommentId 128 | })) 129 | }, 130 | 131 | /** 132 | * The item this comment thread belongs to got updated. 133 | */ 134 | itemUpdated(item) { 135 | this.itemDescendantCount = item.descendants 136 | }, 137 | 138 | /** 139 | * A comment got loaded initially or added later. 140 | */ 141 | commentAdded(comment) { 142 | // Deleted comments don't count towards the comment count 143 | if (comment.deleted) { 144 | // Adjust the number of comments expected during the initial page load. 145 | if (this.loading) { 146 | this.expectedComments-- 147 | this.checkLoadCompletion() 148 | } 149 | return 150 | } 151 | 152 | CommentThreadStore.prototype.commentAdded.call(this, comment) 153 | 154 | // Dead comments don't contribute to the comment count if showDead is off 155 | if (comment.dead && !SettingsStore.showDead) { 156 | this.expectedComments-- 157 | } 158 | else { 159 | this.commentCount++ 160 | } 161 | // Add the number of kids the comment has to the expected total for the 162 | // initial load. 163 | if (this.loading && comment.kids) { 164 | this.expectedComments += comment.kids.length 165 | } 166 | // Register the comment as new if it's new, unless it's dead and showDead is off 167 | if (this.prevMaxCommentId > 0 && 168 | comment.id > this.prevMaxCommentId && 169 | (!comment.dead || SettingsStore.showDead)) { 170 | this.newCommentCount++ 171 | this.isNew[comment.id] = true 172 | } 173 | // Keep track of the biggest comment id seen 174 | if (comment.id > this.maxCommentId) { 175 | this.maxCommentId = comment.id 176 | } 177 | // We don't want the story to be part of the comment parent hierarchy 178 | if (comment.parent !== this.itemId) { 179 | this.parents[comment.id] = comment.parent 180 | } 181 | 182 | this.numberOfCommentsChanged() 183 | if (this.loading) { 184 | this.checkLoadCompletion() 185 | } 186 | }, 187 | 188 | /** 189 | * A comment which hasn't loaded yet is being delayed. 190 | */ 191 | commentDelayed(commentId) { 192 | // Don't wait for delayed comments 193 | this.expectedComments-- 194 | }, 195 | 196 | /** 197 | * A comment which wasn't previously deleted became deleted. 198 | */ 199 | commentDeleted(comment) { 200 | CommentThreadStore.prototype.commentDeleted.call(this, comment) 201 | this.commentCount-- 202 | if (this.isNew[comment.id]) { 203 | this.newCommentCount-- 204 | delete this.isNew[comment.id] 205 | } 206 | delete this.parents[comment.id] 207 | // Trigger debounced callbacks 208 | this.numberOfCommentsChanged() 209 | }, 210 | 211 | /** 212 | * A comment which wasn't previously dead became dead. 213 | */ 214 | commentDied(comment) { 215 | if (!SettingsStore.showDead) { 216 | this.commentCount-- 217 | if (this.isNew[comment.id]) { 218 | this.newCommentCount-- 219 | delete this.isNew[comment.id] 220 | } 221 | } 222 | }, 223 | 224 | /** 225 | * Change the expected number of comments if an update was received during 226 | * initial loding and trigger a re-check of loading completion. 227 | */ 228 | adjustExpectedComments(change) { 229 | this.expectedComments += change 230 | this.checkLoadCompletion() 231 | }, 232 | 233 | collapseThreadsWithoutNewComments() { 234 | // Create an id lookup for comments which have a new comment as one of their 235 | // descendants. New comments themselves are not added to the lookup. 236 | var newCommentIds = Object.keys(this.isNew) 237 | var hasNewComments = {} 238 | for (var i = 0, l = newCommentIds.length; i < l; i++) { 239 | var parent = this.parents[newCommentIds[i]] 240 | while (parent) { 241 | // Stop when we hit one we've seen before 242 | if (hasNewComments[parent]) { 243 | break 244 | } 245 | hasNewComments[parent] = true 246 | parent = this.parents[parent] 247 | } 248 | } 249 | 250 | // Walk the tree of comments one level at a time, only walking children to 251 | // comments we know have new comment descendants, to find subtrees which 252 | // don't have new comments. 253 | // Other comments are marked for collapsing unless they are themselves a 254 | // new comment (in which case all their replies must be new too). 255 | var shouldCollapse = {} 256 | var commentIds = this.children[this.itemId] 257 | while (commentIds.length) { 258 | var nextCommentIds = [] 259 | for (i = 0, l = commentIds.length; i < l; i++) { 260 | var commentId = commentIds[i] 261 | if (!hasNewComments[commentId]) { 262 | if (!this.isNew[commentId]) { 263 | shouldCollapse[commentId] = true 264 | } 265 | } 266 | else { 267 | var childCommentIds = this.children[commentId] 268 | if (childCommentIds.length) { 269 | nextCommentIds.push.apply(nextCommentIds, childCommentIds) 270 | } 271 | } 272 | } 273 | commentIds = nextCommentIds 274 | } 275 | 276 | this.isCollapsed = shouldCollapse 277 | this.onCommentsChanged({type: 'collapse'}) 278 | }, 279 | 280 | /** 281 | * Merk the thread as read. 282 | */ 283 | markAsRead() { 284 | this.lastVisit = Date.now() 285 | this.newCommentCount = 0 286 | this.prevMaxCommentId = this.maxCommentId 287 | this.isNew = {} 288 | this._storeState() 289 | }, 290 | 291 | /** 292 | * Persist comment thread state and perform any necessary internal cleanup. 293 | */ 294 | dispose() { 295 | // Cancel debounced callbacks in case any are pending 296 | this.numberOfCommentsChanged.cancel() 297 | this._storeState() 298 | } 299 | }) 300 | 301 | module.exports = StoryCommentThreadStore 302 | -------------------------------------------------------------------------------- /src/stores/StoryStore.js: -------------------------------------------------------------------------------- 1 | var {EventEmitter} = require('events') 2 | 3 | var HNService = require('../services/HNService') 4 | var HNServiceRest = require('../services/HNServiceRest') 5 | var SettingsStore = require('./SettingsStore') 6 | 7 | var extend = require('../utils/extend') 8 | 9 | /** 10 | * Firebase reference used to stream updates - only one StoryStore instance can 11 | * be active at a time. 12 | */ 13 | var firebaseRef = null 14 | 15 | // Cache objects shared among StoryStore instances, also accessible via static 16 | // functions on the StoryStore constructor. 17 | 18 | /** 19 | * Story ids by type, in rank order. Persisted to sessionStorage. 20 | * @type Object.> 21 | */ 22 | var idCache = {} 23 | 24 | /** 25 | * Item cache. Persisted to sessionStorage. 26 | * @type Object. 27 | */ 28 | var itemCache = {} 29 | 30 | /** 31 | * Story items in rank order for display, by type. 32 | * @type Object.> 33 | */ 34 | var storyLists = {} 35 | 36 | /** 37 | * Populate the story list for the given story type from the cache. 38 | */ 39 | function populateStoryList(type) { 40 | var ids = idCache[type] 41 | var storyList = storyLists[type] 42 | for (var i = 0, l = ids.length; i < l; i++) { 43 | storyList[i] = itemCache[ids[i]] || null 44 | } 45 | } 46 | 47 | function parseJSON(json, defaultValue) { 48 | return (json ? JSON.parse(json) : defaultValue) 49 | } 50 | 51 | class StoryStore extends EventEmitter { 52 | constructor(type) { 53 | super() 54 | this.type = type 55 | 56 | // Ensure cache objects for this type are initialised 57 | if (!(type in idCache)) { 58 | idCache[type] = [] 59 | } 60 | if (!(type in storyLists)) { 61 | storyLists[type] = [] 62 | populateStoryList(type) 63 | } 64 | 65 | // Pre-bind event handlers per instance 66 | this.onStorage = this.onStorage.bind(this) 67 | this.onStoriesUpdated = this.onStoriesUpdated.bind(this) 68 | } 69 | 70 | getState() { 71 | return { 72 | ids: idCache[this.type], 73 | stories: storyLists[this.type] 74 | } 75 | } 76 | 77 | itemUpdated(item, index) { 78 | storyLists[this.type][index] = item 79 | itemCache[item.id] = item 80 | } 81 | 82 | /** 83 | * Emit an item id event if a storage key corresponding to an item in the 84 | * cache has changed. 85 | */ 86 | onStorage(e) { 87 | if (itemCache[e.key]) { 88 | this.emit(e.key) 89 | } 90 | } 91 | 92 | /** 93 | * Handle story id snapshots from Firebase. 94 | */ 95 | onStoriesUpdated(snapshot) { 96 | if (SettingsStore.offlineMode) { 97 | idCache[this.type] = snapshot 98 | } 99 | else { 100 | idCache[this.type] = snapshot.val() 101 | } 102 | populateStoryList(this.type) 103 | this.emit('update', this.getState()) 104 | } 105 | 106 | start() { 107 | if (typeof window === 'undefined') return 108 | if (SettingsStore.offlineMode) { 109 | HNServiceRest.storiesRef(this.type).then(function(res) { 110 | return res.json() 111 | }).then(function(snapshot) { 112 | this.onStoriesUpdated(snapshot) 113 | }.bind(this)) 114 | } 115 | else { 116 | firebaseRef = HNService.storiesRef(this.type) 117 | firebaseRef.on('value', this.onStoriesUpdated) 118 | } 119 | window.addEventListener('storage', this.onStorage) 120 | } 121 | 122 | stop() { 123 | if (firebaseRef !== null) { 124 | if (!SettingsStore.offlineMode) { 125 | firebaseRef.off() 126 | } 127 | firebaseRef = null 128 | } 129 | if (typeof window === 'undefined') return 130 | window.removeEventListener('storage', this.onStorage) 131 | } 132 | } 133 | 134 | // Static, cache-related functions 135 | extend(StoryStore, { 136 | /** 137 | * Get an item from the cache. 138 | */ 139 | getItem(id) { 140 | return itemCache[id] || null 141 | }, 142 | 143 | /** 144 | * Deserialise caches from sessionStorage. 145 | */ 146 | loadSession() { 147 | if (typeof window === 'undefined') return 148 | if (SettingsStore.offlineMode) { 149 | idCache = parseJSON(window.localStorage.idCache, {}) 150 | itemCache = parseJSON(window.localStorage.itemCache, {}) 151 | } 152 | else { 153 | idCache = parseJSON(window.sessionStorage.idCache, {}) 154 | itemCache = parseJSON(window.sessionStorage.itemCache, {}) 155 | } 156 | }, 157 | 158 | /** 159 | * Serialise caches to sessionStorage as JSON. 160 | */ 161 | saveSession() { 162 | if (typeof window === 'undefined') return 163 | if (SettingsStore.offlineMode) { 164 | window.localStorage.setItem('idCache', JSON.stringify(idCache)) 165 | window.localStorage.setItem('itemCache', JSON.stringify(itemCache)) 166 | } 167 | else { 168 | window.sessionStorage.idCache = JSON.stringify(idCache) 169 | window.sessionStorage.itemCache = JSON.stringify(itemCache) 170 | } 171 | } 172 | }) 173 | 174 | module.exports = StoryStore 175 | -------------------------------------------------------------------------------- /src/stores/UpdatesStore.js: -------------------------------------------------------------------------------- 1 | var EventEmitter = require('events').EventEmitter 2 | 3 | var HNService = require('../services/HNService') 4 | var HNServiceRest = require('../services/HNServiceRest') 5 | var SettingsStore = require('./SettingsStore') 6 | 7 | var {UPDATES_CACHE_SIZE} = require('../utils/constants') 8 | var extend = require('../utils/extend') 9 | 10 | /** 11 | * Firebase reference used to stream updates. 12 | */ 13 | var updatesRef = null 14 | 15 | /** 16 | * Contains item id -> item cache objects. Persisted to sessionStorage. 17 | * @prop .comments {Object.} comments cache. 18 | * @prop .stories {Object.} story cache. 19 | */ 20 | var updatesCache = null 21 | 22 | /** 23 | * Lists of items in reverse chronological order for display. 24 | * @prop .comments {Array.} comment updates. 25 | * @prop .stories {Array.} story updates. 26 | */ 27 | var updates = {} 28 | 29 | function sortByTimeDesc(a, b) { 30 | return b.time - a.time 31 | } 32 | 33 | function cacheObjToSortedArray(obj) { 34 | var arr = Object.keys(obj).map(function(id) { return obj[id] }) 35 | arr.sort(sortByTimeDesc) 36 | return arr 37 | } 38 | 39 | /** 40 | * Populate lists of updates for display from the cache. 41 | */ 42 | function populateUpdates() { 43 | updates.comments = processCacheObj(updatesCache.comments) 44 | updates.stories = processCacheObj(updatesCache.stories) 45 | } 46 | 47 | /** 48 | * Create an array of items from a cache object, sorted in reverse chronological 49 | * order. Evict the oldest items from the cache if it's grown above 50 | * UPDATES_CACHE_SIZE. 51 | */ 52 | function processCacheObj(cacheObj) { 53 | var arr = cacheObjToSortedArray(cacheObj) 54 | arr.splice(UPDATES_CACHE_SIZE, Math.max(0, arr.length - UPDATES_CACHE_SIZE)) 55 | .forEach(function(item) { 56 | delete cacheObj[item.id] 57 | }) 58 | return arr 59 | } 60 | 61 | /** 62 | * Lookup to filter out any items which appear in the updates feed which can't 63 | * be displayed by the Updates component. 64 | */ 65 | var updateItemTypes = { 66 | comment: true, 67 | job: true, 68 | poll: true, 69 | story: true 70 | } 71 | 72 | /** 73 | * Process incoming items from the update stream. 74 | */ 75 | function handleUpdateItems(items) { 76 | for (var i = 0, l = items.length; i < l; i++) { 77 | var item = items[i] 78 | // Silently ignore deleted items (because irony) 79 | if (item.deleted) { continue } 80 | 81 | if (typeof updateItemTypes[item.type] == 'undefined') { 82 | if (process.env.NODE_ENV !== 'production') { 83 | console.warn( 84 | "An item which can't be displayed by the Updates component was " + 85 | 'received in the updates stream: ' + JSON.stringify(item) 86 | ) 87 | } 88 | continue 89 | } 90 | 91 | if (item.type === 'comment') { 92 | updatesCache.comments[item.id] = item 93 | } 94 | else { 95 | updatesCache.stories[item.id] = item 96 | } 97 | } 98 | populateUpdates() 99 | UpdatesStore.emit('updates', updates) 100 | } 101 | 102 | var UpdatesStore = extend(new EventEmitter(), { 103 | loadSession() { 104 | if (typeof window === 'undefined') return 105 | var json = window.sessionStorage.updates 106 | updatesCache = (json ? JSON.parse(json) : {comments: {}, stories: {}}) 107 | populateUpdates() 108 | }, 109 | 110 | saveSession() { 111 | if (typeof window === 'undefined') return 112 | window.sessionStorage.updates = JSON.stringify(updatesCache) 113 | }, 114 | 115 | start() { 116 | if (updatesRef === null) { 117 | if (SettingsStore.offlineMode) { 118 | HNServiceRest.updatesRef().then(function(res) { 119 | return res.json() 120 | }).then(function(snapshot) { 121 | HNServiceRest.fetchItems(snapshot, handleUpdateItems) 122 | }) 123 | } 124 | else { 125 | updatesRef = HNService.updatesRef() 126 | updatesRef.on('value', function(snapshot) { 127 | HNService.fetchItems(snapshot.val(), handleUpdateItems) 128 | }) 129 | } 130 | } 131 | }, 132 | 133 | stop() { 134 | if (!SettingsStore.offlineMode) { 135 | updatesRef.off() 136 | updatesRef = null 137 | } 138 | }, 139 | 140 | getUpdates() { 141 | return updates 142 | }, 143 | 144 | getItem(id) { 145 | return (updatesCache.comments[id] || updatesCache.stories[id] || null) 146 | }, 147 | 148 | getComment(id) { 149 | return (updatesCache.comments[id] || null) 150 | }, 151 | 152 | getStory(id) { 153 | return (updatesCache.stories[id] || null) 154 | } 155 | }) 156 | UpdatesStore.off = UpdatesStore.removeListener 157 | 158 | module.exports = UpdatesStore 159 | -------------------------------------------------------------------------------- /src/utils/buildClassName.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Creates a className string including some class names conditionally. 3 | * @param {string=} staticClassName class name(s) which should always be 4 | * included. 5 | * @param {Object.} conditionalClassNames an object mapping class 6 | * names to a value which indicates if the class name should be included - 7 | * class names will be included if their corresponding value is truthy. 8 | * @return {string} 9 | */ 10 | function buildClassName(staticClassName, conditionalClassNames) { 11 | var classNames = [] 12 | if (typeof conditionalClassNames == 'undefined') { 13 | conditionalClassNames = staticClassName 14 | } 15 | else { 16 | classNames.push(staticClassName) 17 | } 18 | var classNameKeys = Object.keys(conditionalClassNames) 19 | for (var i = 0, l = classNameKeys.length; i < l; i++) { 20 | if (conditionalClassNames[classNameKeys[i]]) { 21 | classNames.push(classNameKeys[i]) 22 | } 23 | } 24 | return classNames.join(' ') 25 | } 26 | 27 | module.exports = buildClassName 28 | -------------------------------------------------------------------------------- /src/utils/cancellableDebounce.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Based on the implementation of _.debounce() from Underscore.js 1.7.0 3 | * http://underscorejs.org 4 | * (c) 2009-2014 Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors 5 | * Distributed under the MIT license. 6 | * 7 | * Returns a function, that, as long as it continues to be invoked, will not 8 | * be triggered. The function will be called after it stops being called for 9 | * N milliseconds. If `immediate` is passed, trigger the function on the 10 | * leading edge, instead of the trailing. 11 | * 12 | * The returned function has a .cancel() function which can be used to prevent 13 | * the debounced functiom being called. 14 | */ 15 | function cancellableDebounce(func, wait, immediate) { 16 | var timeout, args, context, timestamp, result 17 | 18 | var later = function() { 19 | var last = Date.now() - timestamp 20 | if (last < wait && last > 0) { 21 | timeout = setTimeout(later, wait - last) 22 | } 23 | else { 24 | timeout = null 25 | if (!immediate) { 26 | result = func.apply(context, args) 27 | if (!timeout) { 28 | context = args = null 29 | } 30 | } 31 | } 32 | } 33 | 34 | var debounced = function() { 35 | context = this 36 | args = arguments 37 | timestamp = Date.now() 38 | var callNow = immediate && !timeout 39 | if (!timeout) { 40 | timeout = setTimeout(later, wait) 41 | } 42 | if (callNow) { 43 | result = func.apply(context, args) 44 | context = args = null 45 | } 46 | return result 47 | } 48 | 49 | debounced.cancel = function() { 50 | if (timeout) { 51 | clearTimeout(timeout) 52 | } 53 | } 54 | 55 | return debounced 56 | } 57 | 58 | module.exports = cancellableDebounce 59 | -------------------------------------------------------------------------------- /src/utils/constants.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ITEMS_PER_PAGE: 30, 3 | SITE_TITLE: 'React HN', 4 | UPDATES_CACHE_SIZE: 500 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/extend.js: -------------------------------------------------------------------------------- 1 | function extend(dest, src1, src2) { 2 | var props = Object.keys(src1) 3 | for (var i = 0, l = props.length; i < l; i++) { 4 | dest[props[i]] = src1[props[i]] 5 | } 6 | if (src2) { 7 | props = Object.keys(src2) 8 | for (i = 0, l = props.length; i < l; i++) { 9 | dest[props[i]] = src2[props[i]] 10 | } 11 | } 12 | return dest 13 | } 14 | 15 | module.exports = extend 16 | -------------------------------------------------------------------------------- /src/utils/pageCalc.js: -------------------------------------------------------------------------------- 1 | function pageCalc(pageNum, pageSize, numItems) { 2 | var startIndex = (pageNum - 1) * pageSize 3 | var endIndex = Math.min(numItems, startIndex + pageSize) 4 | var hasNext = endIndex < numItems - 1 5 | return {pageNum, startIndex, endIndex, hasNext} 6 | } 7 | 8 | module.exports = pageCalc 9 | -------------------------------------------------------------------------------- /src/utils/pluralise.js: -------------------------------------------------------------------------------- 1 | function pluralise(howMany, suffixes) { 2 | return (suffixes || ',s').split(',')[(howMany === 1 ? 0 : 1)] 3 | } 4 | 5 | module.exports = pluralise 6 | -------------------------------------------------------------------------------- /src/utils/setTitle.js: -------------------------------------------------------------------------------- 1 | var {SITE_TITLE} = require('./constants') 2 | 3 | function setTitle(title) { 4 | if (typeof document === 'undefined') return 5 | document.title = (title ? title + ' | ' + SITE_TITLE : SITE_TITLE) 6 | } 7 | 8 | module.exports = setTitle 9 | -------------------------------------------------------------------------------- /src/utils/storage.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | get(key, defaultValue) { 3 | if (typeof window === 'undefined') { 4 | return defaultValue 5 | } 6 | else { 7 | var value = window.localStorage[key] 8 | return (typeof value != 'undefined' ? value : defaultValue) 9 | } 10 | }, 11 | set(key, value) { 12 | if (typeof window !== 'undefined') { 13 | window.localStorage[key] = value 14 | } 15 | } 16 | } 17 | 18 | -------------------------------------------------------------------------------- /src/views/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | React HN 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | <%= htmlWebpackPlugin.options.markup %> 37 | 67 | 68 | 73 | 74 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /sw-precache-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "importScripts": [ 3 | "sw-toolbox.js", 4 | "runtime-caching.js" 5 | ], 6 | "stripPrefix": "dist/", 7 | "verbose": true 8 | } --------------------------------------------------------------------------------