├── .gitignore ├── LICENSE.md ├── README.md ├── TODO.md ├── nwb.config.js ├── package.json ├── public ├── 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 └── manifest.json ├── screenshot.png └── 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.html ├── index.js ├── mixins ├── CommentMixin.js ├── ItemMixin.js ├── ListItemMixin.js └── PageNumberMixin.js ├── routes.js ├── services └── HNService.js ├── stores ├── CommentThreadStore.js ├── ItemStore.js ├── SettingsStore.js ├── StoryCommentThreadStore.js ├── StoryStore.js └── UpdatesStore.js ├── style.css └── utils ├── buildClassName.js ├── cancellableDebounce.js ├── constants.js ├── extend.js ├── pageCalc.js ├── pluralise.js ├── setTitle.js └── storage.js /.gitignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /node_modules 3 | -------------------------------------------------------------------------------- /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 | # [react-hn](https://insin.github.io/react-hn) 2 | 3 | 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 | [![react-hn screenshot](https://github.com/insin/react-hn/raw/master/screenshot.png "New comment highlighting in react-hn")](https://insin.github.io/react-hn) 9 | 10 | Live version: https://insin.github.io/react-hn 11 | 12 | ## Features 13 | 14 | * Supports display of all item types: 15 | [stories](https://insin.github.io/react-hn/#/story/8863), 16 | [jobs](https://insin.github.io/react-hn/#/job/8426937), 17 | [polls](https://insin.github.io/react-hn/#/poll/126809) and 18 | [comments](https://insin.github.io/react-hn/#/comment/8054455) 19 | * Basic [user profiles](https://insin.github.io/react-hn/#/user/patio11) 20 | * Collapsible comment threads, with child counts 21 | * "Realtime" updates (free via Firebase!) 22 | * Last visit details for stories are cached in `localStorage` 23 | * New comments are highlighted: 24 | * Comments since your last visit to an item 25 | * New comments which load while you're reading an item 26 | * New comments in collapsed threads 27 | * Automatic or manual collapsing of comment threads which don't contain any new 28 | comments 29 | * Manual highlighting of the X most recent comments to catch up on threads you were reading elsewhere 30 | * Stories with new comments are marked on list pages 31 | * Stories can be marked as read to remove highighting from new comments 32 | * "comments" sections driven by the Changed Items API 33 | * Story listing pages are cached in `sessionStorage` for quick back button usage 34 | and pagination in the same session 35 | * Configurable settings: 36 | * auto collapse - automatically collapse comment threads without new comments 37 | on page load 38 | * show reply links - show "reply" links to Hacker News 39 | * show dead - show items flagged as dead 40 | * show deleted - show comments flagged as deleted in threads 41 | * Delayed comment detection - so tense! Who will it be? What will they say? 42 | 43 | [Feature requests are welcome!](https://github.com/insin/react-hn/issues/new) 44 | 45 | ## Building 46 | 47 | Install dependencies: 48 | 49 | ``` 50 | npm install 51 | ``` 52 | 53 | ### npm scripts 54 | 55 | * `npm start` - start development server 56 | * `npm run build` - build into the `dist/` directory 57 | * `npm run lint` - lint `src/` 58 | * `npm run lint:fix` - lint `src/` and auto-fix issues where possible 59 | 60 | ## MIT Licensed 61 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /nwb.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | type: 'react-app', 3 | webpack: { 4 | define: { 5 | __VERSION__: JSON.stringify(require('./package.json').version) 6 | }, 7 | // Path-independent build which doesn't have to be served at / 8 | publicPath: '' 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-hn", 3 | "version": "1.7.1", 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": "nwb build", 13 | "lint": "eslint src", 14 | "lint:fix": "npm run lint -- --fix", 15 | "start": "nwb serve" 16 | }, 17 | "dependencies": { 18 | "events": "1.1.1", 19 | "firebase": "3.4.1", 20 | "history": "^2.1.2", 21 | "object-assign": "^4.1.0", 22 | "react": "15.3.2", 23 | "react-dom": "15.3.2", 24 | "react-router": "2.8.1", 25 | "react-router-scroll": "^0.3.2", 26 | "react-timeago": "3.1.3", 27 | "reactfire": "1.0.0", 28 | "scroll-behavior": "0.8.1", 29 | "setimmediate": "1.0.5", 30 | "url-parse": "^1.1.1" 31 | }, 32 | "devDependencies": { 33 | "eslint-config-jonnybuchanan": "6.0.0", 34 | "nwb": "0.22.0" 35 | } 36 | } -------------------------------------------------------------------------------- /public/img/android-chrome-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/react-hn/7c5a7802109b1d2bae5c8493dd1fecf5bfe823a9/public/img/android-chrome-144x144.png -------------------------------------------------------------------------------- /public/img/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/react-hn/7c5a7802109b1d2bae5c8493dd1fecf5bfe823a9/public/img/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/img/android-chrome-36x36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/react-hn/7c5a7802109b1d2bae5c8493dd1fecf5bfe823a9/public/img/android-chrome-36x36.png -------------------------------------------------------------------------------- /public/img/android-chrome-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/react-hn/7c5a7802109b1d2bae5c8493dd1fecf5bfe823a9/public/img/android-chrome-48x48.png -------------------------------------------------------------------------------- /public/img/android-chrome-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/react-hn/7c5a7802109b1d2bae5c8493dd1fecf5bfe823a9/public/img/android-chrome-72x72.png -------------------------------------------------------------------------------- /public/img/android-chrome-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/react-hn/7c5a7802109b1d2bae5c8493dd1fecf5bfe823a9/public/img/android-chrome-96x96.png -------------------------------------------------------------------------------- /public/img/apple-touch-icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/react-hn/7c5a7802109b1d2bae5c8493dd1fecf5bfe823a9/public/img/apple-touch-icon-114x114.png -------------------------------------------------------------------------------- /public/img/apple-touch-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/react-hn/7c5a7802109b1d2bae5c8493dd1fecf5bfe823a9/public/img/apple-touch-icon-120x120.png -------------------------------------------------------------------------------- /public/img/apple-touch-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/react-hn/7c5a7802109b1d2bae5c8493dd1fecf5bfe823a9/public/img/apple-touch-icon-144x144.png -------------------------------------------------------------------------------- /public/img/apple-touch-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/react-hn/7c5a7802109b1d2bae5c8493dd1fecf5bfe823a9/public/img/apple-touch-icon-152x152.png -------------------------------------------------------------------------------- /public/img/apple-touch-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/react-hn/7c5a7802109b1d2bae5c8493dd1fecf5bfe823a9/public/img/apple-touch-icon-180x180.png -------------------------------------------------------------------------------- /public/img/apple-touch-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/react-hn/7c5a7802109b1d2bae5c8493dd1fecf5bfe823a9/public/img/apple-touch-icon-57x57.png -------------------------------------------------------------------------------- /public/img/apple-touch-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/react-hn/7c5a7802109b1d2bae5c8493dd1fecf5bfe823a9/public/img/apple-touch-icon-60x60.png -------------------------------------------------------------------------------- /public/img/apple-touch-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/react-hn/7c5a7802109b1d2bae5c8493dd1fecf5bfe823a9/public/img/apple-touch-icon-72x72.png -------------------------------------------------------------------------------- /public/img/apple-touch-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/react-hn/7c5a7802109b1d2bae5c8493dd1fecf5bfe823a9/public/img/apple-touch-icon-76x76.png -------------------------------------------------------------------------------- /public/img/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/react-hn/7c5a7802109b1d2bae5c8493dd1fecf5bfe823a9/public/img/apple-touch-icon-precomposed.png -------------------------------------------------------------------------------- /public/img/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/react-hn/7c5a7802109b1d2bae5c8493dd1fecf5bfe823a9/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/insin/react-hn/7c5a7802109b1d2bae5c8493dd1fecf5bfe823a9/public/img/favicon-16x16.png -------------------------------------------------------------------------------- /public/img/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/react-hn/7c5a7802109b1d2bae5c8493dd1fecf5bfe823a9/public/img/favicon-32x32.png -------------------------------------------------------------------------------- /public/img/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/react-hn/7c5a7802109b1d2bae5c8493dd1fecf5bfe823a9/public/img/favicon-96x96.png -------------------------------------------------------------------------------- /public/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/react-hn/7c5a7802109b1d2bae5c8493dd1fecf5bfe823a9/public/img/favicon.ico -------------------------------------------------------------------------------- /public/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/react-hn/7c5a7802109b1d2bae5c8493dd1fecf5bfe823a9/public/img/logo.png -------------------------------------------------------------------------------- /public/img/mstile-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/react-hn/7c5a7802109b1d2bae5c8493dd1fecf5bfe823a9/public/img/mstile-144x144.png -------------------------------------------------------------------------------- /public/img/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/react-hn/7c5a7802109b1d2bae5c8493dd1fecf5bfe823a9/public/img/mstile-150x150.png -------------------------------------------------------------------------------- /public/img/mstile-310x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/react-hn/7c5a7802109b1d2bae5c8493dd1fecf5bfe823a9/public/img/mstile-310x150.png -------------------------------------------------------------------------------- /public/img/mstile-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/react-hn/7c5a7802109b1d2bae5c8493dd1fecf5bfe823a9/public/img/mstile-310x310.png -------------------------------------------------------------------------------- /public/img/mstile-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/react-hn/7c5a7802109b1d2bae5c8493dd1fecf5bfe823a9/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/insin/react-hn/7c5a7802109b1d2bae5c8493dd1fecf5bfe823a9/public/img/splashscreen-icon-384x384.png -------------------------------------------------------------------------------- /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 | "background_color": "#4CC1FC", 26 | "display": "standalone", 27 | "theme_color": "#222222" 28 | } 29 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insin/react-hn/7c5a7802109b1d2bae5c8493dd1fecf5bfe823a9/screenshot.png -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | /* global __VERSION__ */ 2 | var React = require('react') 3 | var Link = require('react-router/lib/Link') 4 | 5 | var Settings = require('./Settings').default 6 | 7 | var StoryStore = require('./stores/StoryStore').default 8 | var UpdatesStore = require('./stores/UpdatesStore').default 9 | var SettingsStore = require('./stores/SettingsStore').default 10 | 11 | var App = React.createClass({ 12 | getInitialState() { 13 | return { 14 | showSettings: false 15 | } 16 | }, 17 | 18 | componentWillMount() { 19 | SettingsStore.load() 20 | StoryStore.loadSession() 21 | UpdatesStore.loadSession() 22 | window.addEventListener('beforeunload', this.handleBeforeUnload) 23 | }, 24 | 25 | componentWillUnmount() { 26 | window.removeEventListener('beforeunload', this.handleBeforeUnload) 27 | }, 28 | 29 | /** 30 | * Give stores a chance to persist data to sessionStorage in case this is a 31 | * refresh or an external link in the same tab. 32 | */ 33 | handleBeforeUnload() { 34 | StoryStore.saveSession() 35 | UpdatesStore.saveSession() 36 | }, 37 | 38 | toggleSettings(e) { 39 | e.preventDefault() 40 | this.setState({showSettings: !this.state.showSettings}) 41 | }, 42 | 43 | render() { 44 | return
45 |
46 |
47 | {' '} 48 | React HN{' '} 49 | new{' | '} 50 | comments {' | '} 51 | show{' | '} 52 | ask{' | '} 53 | jobs{' | '} 54 | read 55 | 56 | {this.state.showSettings ? 'hide settings' : 'settings'} 57 | 58 | {this.state.showSettings && } 59 |
60 |
61 | {this.props.children} 62 |
63 |
64 | {`react-hn v${__VERSION__} | `} 65 | insin/react-hn 66 |
67 |
68 |
69 | } 70 | }) 71 | 72 | export default App 73 | -------------------------------------------------------------------------------- /src/Comment.js: -------------------------------------------------------------------------------- 1 | var React = require('react') 2 | var ReactFireMixin = require('reactfire') 3 | 4 | var CommentThreadStore = require('./stores/CommentThreadStore').default 5 | var HNService = require('./services/HNService').default 6 | var SettingsStore = require('./stores/SettingsStore').default 7 | 8 | var CommentMixin = require('./mixins/CommentMixin').default 9 | 10 | var cx = require('./utils/buildClassName').default 11 | 12 | /** 13 | * A comment in a thread. 14 | */ 15 | var Comment = React.createClass({ 16 | mixins: [CommentMixin, ReactFireMixin], 17 | 18 | propTypes: { 19 | id: React.PropTypes.number.isRequired, 20 | level: React.PropTypes.number.isRequired, 21 | loadingSpinner: React.PropTypes.bool, 22 | threadStore: React.PropTypes.instanceOf(CommentThreadStore).isRequired 23 | }, 24 | 25 | getDefaultProps() { 26 | return { 27 | loadingSpinner: false 28 | } 29 | }, 30 | 31 | getInitialState() { 32 | return { 33 | comment: {} 34 | } 35 | }, 36 | 37 | componentWillMount() { 38 | this.bindFirebaseRef() 39 | }, 40 | 41 | componentWillUnmount() { 42 | this.clearDelayTimeout() 43 | }, 44 | 45 | componentDidUpdate(prevProps, prevState) { 46 | // Huge, fast-growing threads like https://news.ycombinator.com/item?id=9784470 47 | // seem to break the API - some comments are coming back from Firebase as null. 48 | if (!this.state.comment) { 49 | this.props.threadStore.adjustExpectedComments(-1) 50 | return 51 | } 52 | 53 | // On !prevState.comment: a comment which was initially null - see 54 | // above - may eventually load when the API catches up. 55 | if (!prevState.comment || !prevState.comment.id) { 56 | // Register a newly-loaded comment with the thread store 57 | if (this.state.comment.id) { 58 | // If the comment was delayed, cancel any pending timeout 59 | if (prevState.comment && prevState.comment.delayed) { 60 | this.clearDelayTimeout() 61 | } 62 | this.props.threadStore.commentAdded(this.state.comment) 63 | } 64 | if (prevState.comment && !prevState.comment.delayed && this.state.comment.delayed) { 65 | this.props.threadStore.commentDelayed(this.props.id) 66 | } 67 | } 68 | // The comment was already loaded, look for changes to it 69 | else { 70 | if (!prevState.comment.deleted && this.state.comment.deleted) { 71 | this.props.threadStore.commentDeleted(this.state.comment) 72 | } 73 | if (!prevState.comment.dead && this.state.comment.dead) { 74 | this.props.threadStore.commentDied(this.state.comment) 75 | } 76 | // If the comment has been updated and the initial set of comments is 77 | // still loading, the number of expected comments might need to be 78 | // adjusted. 79 | else if (prevState.comment !== this.state.comment && 80 | this.props.threadStore.loading) { 81 | var kids = (this.state.comment.kids ? this.state.comment.kids.length : 0) 82 | var prevKids = (prevState.comment.kids ? prevState.comment.kids.length : 0) 83 | this.props.threadStore.adjustExpectedComments(kids - prevKids) 84 | } 85 | } 86 | }, 87 | 88 | bindFirebaseRef() { 89 | this.bindAsObject(HNService.itemRef(this.props.id), 'comment', this.handleFirebaseRefCancelled) 90 | if (this.timeout) { 91 | this.timeout = null 92 | } 93 | }, 94 | 95 | /** 96 | * This is usually caused by a permissions error loading the comment due to 97 | * its author using the delay setting (note: this is conjecture), which is 98 | * measured in minutes - try again in 30 seconds. 99 | */ 100 | handleFirebaseRefCancelled(e) { 101 | if (process.env.NODE_ENV !== 'production') { 102 | console.error('Firebase ref for comment ' + this.props.id + ' was cancelled: ' + e.message) 103 | } 104 | this.unbind('comment') 105 | this.timeout = setTimeout(this.bindFirebaseRef, 30000) 106 | if (this.state.comment && !this.state.comment.delayed) { 107 | this.state.comment.delayed = true 108 | this.forceUpdate() 109 | } 110 | }, 111 | 112 | clearDelayTimeout() { 113 | if (this.timeout) { 114 | clearTimeout(this.timeout) 115 | this.timeout = null 116 | } 117 | }, 118 | 119 | toggleCollapse(e) { 120 | e.preventDefault() 121 | this.props.threadStore.toggleCollapse(this.state.comment.id) 122 | }, 123 | 124 | render() { 125 | var comment = this.state.comment 126 | var props = this.props 127 | if (!comment) { 128 | return this.renderError(comment, { 129 | id: this.props.id, 130 | className: 'Comment Comment--error Comment--level' + props.level 131 | }) 132 | } 133 | // Render a placeholder while we're waiting for the comment to load 134 | if (!comment.id) { return this.renderCommentLoading(comment) } 135 | // Don't show dead coments or their children, when configured 136 | if (comment.dead && !SettingsStore.showDead) { return null } 137 | // Render a link to HN for deleted comments if they're being displayed 138 | if (comment.deleted) { 139 | if (!SettingsStore.showDeleted) { return null } 140 | return this.renderCommentDeleted(comment, { 141 | className: 'Comment Comment--deleted Comment--level' + props.level 142 | }) 143 | } 144 | 145 | var isNew = props.threadStore.isNew[comment.id] 146 | var collapsed = !!props.threadStore.isCollapsed[comment.id] 147 | var childCounts = (collapsed && props.threadStore.getChildCounts(comment)) 148 | if (collapsed && isNew) { childCounts.newComments = 0 } 149 | var className = cx('Comment Comment--level' + props.level, { 150 | 'Comment--collapsed': collapsed, 151 | 'Comment--dead': comment.dead, 152 | 'Comment--new': isNew 153 | }) 154 | 155 | return
156 |
157 | {this.renderCommentMeta(comment, { 158 | collapsible: true, 159 | collapsed: collapsed, 160 | link: true, 161 | childCounts: childCounts 162 | })} 163 | {this.renderCommentText(comment, {replyLink: true})} 164 |
165 | {comment.kids &&
166 | {comment.kids.map(function(id) { 167 | return 172 | })} 173 |
} 174 |
175 | } 176 | }) 177 | 178 | export default Comment 179 | -------------------------------------------------------------------------------- /src/DisplayComment.js: -------------------------------------------------------------------------------- 1 | var React = require('react') 2 | 3 | var SettingsStore = require('./stores/SettingsStore').default 4 | 5 | var CommentMixin = require('./mixins/CommentMixin').default 6 | 7 | var cx = require('./utils/buildClassName').default 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 | export default DisplayComment 53 | -------------------------------------------------------------------------------- /src/DisplayListItem.js: -------------------------------------------------------------------------------- 1 | var React = require('react') 2 | 3 | var StoryCommentThreadStore = require('./stores/StoryCommentThreadStore').default 4 | 5 | var ItemMixin = require('./mixins/ItemMixin').default 6 | var ListItemMixin = require('./mixins/ListItemMixin').default 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 | export default 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').default 6 | var StoryCommentThreadStore = require('./stores/StoryCommentThreadStore').default 7 | var ItemStore = require('./stores/ItemStore').default 8 | 9 | var Comment = require('./Comment').default 10 | var PollOption = require('./PollOption').default 11 | var Spinner = require('./Spinner').default 12 | var ItemMixin = require('./mixins/ItemMixin').default 13 | 14 | var cx = require('./utils/buildClassName').default 15 | var setTitle = require('./utils/setTitle').default 16 | var pluralise = require('./utils/pluralise').default 17 | 18 | function timeUnitsAgo(value, unit, suffix) { 19 | if (value === 1) { 20 | return unit 21 | } 22 | return `${value} ${unit}s` 23 | } 24 | 25 | var Item = React.createClass({ 26 | mixins: [ItemMixin, ReactFireMixin], 27 | 28 | getInitialState() { 29 | return { 30 | item: ItemStore.getCachedStory(Number(this.props.params.id)) || {} 31 | } 32 | }, 33 | 34 | componentWillMount() { 35 | this.bindAsObject(HNService.itemRef(this.props.params.id), 'item') 36 | 37 | if (this.state.item.id) { 38 | this.threadStore = new StoryCommentThreadStore(this.state.item, this.handleCommentsChanged, {cached: true}) 39 | setTitle(this.state.item.title) 40 | } 41 | window.addEventListener('beforeunload', this.handleBeforeUnload) 42 | }, 43 | 44 | componentWillUnmount() { 45 | if (this.threadStore) { 46 | this.threadStore.dispose() 47 | } 48 | window.removeEventListener('beforeunload', this.handleBeforeUnload) 49 | }, 50 | 51 | componentWillReceiveProps(nextProps) { 52 | if (this.props.params.id !== nextProps.params.id) { 53 | // Tear it down... 54 | this.threadStore.dispose() 55 | this.threadStore = null 56 | this.unbind('item') 57 | // ...and set it up again 58 | var item = ItemStore.getCachedStory(Number(nextProps.params.id)) 59 | if (item) { 60 | this.threadStore = new StoryCommentThreadStore(item, this.handleCommentsChanged, {cached: true}) 61 | setTitle(item.title) 62 | } 63 | 64 | this.bindAsObject(HNService.itemRef(nextProps.params.id), 'item') 65 | this.setState({item: item || {}}) 66 | } 67 | }, 68 | 69 | componentWillUpdate(nextProps, nextState) { 70 | // Update the title when the item has loaded. 71 | if (!this.state.item.id && nextState.item.id) { 72 | setTitle(nextState.item.title) 73 | } 74 | }, 75 | 76 | componentDidUpdate(prevProps, prevState) { 77 | // If the state item id changed, an initial or new item must have loaded 78 | if (prevState.item.id !== this.state.item.id) { 79 | if (!this.threadStore || this.threadStore.itemId !== this.state.item.id) { 80 | this.threadStore = new StoryCommentThreadStore(this.state.item, this.handleCommentsChanged, {cached: false}) 81 | setTitle(this.state.item.title) 82 | this.forceUpdate() 83 | } 84 | } 85 | else if (prevState.item !== this.state.item) { 86 | // If the item has been updated from Firebase and the initial set 87 | // of comments is still loading, the number of expected comments might 88 | // need to be adjusted. 89 | // This triggers a check for thread load completion, completing it 90 | // immediately if a cached item had 0 kids and the latest version from 91 | // Firebase also has 0 kids. 92 | if (this.threadStore.loading) { 93 | var kids = (this.state.item.kids ? this.state.item.kids.length : 0) 94 | var prevKids = (prevState.item.kids ? prevState.item.kids.length : 0) 95 | var kidDiff = kids - prevKids 96 | if (kidDiff !== 0) { 97 | this.threadStore.adjustExpectedComments(kidDiff) 98 | } 99 | } 100 | this.threadStore.itemUpdated(this.state.item) 101 | } 102 | }, 103 | 104 | /** 105 | * Ensure the last visit time and comment details get stored for this item if 106 | * the user refreshes or otherwise navigates off the page. 107 | */ 108 | handleBeforeUnload() { 109 | if (this.threadStore) { 110 | this.threadStore.dispose() 111 | } 112 | }, 113 | 114 | handleCommentsChanged(payload) { 115 | this.forceUpdate() 116 | }, 117 | 118 | autoCollapse(e) { 119 | e.preventDefault() 120 | this.threadStore.collapseThreadsWithoutNewComments() 121 | }, 122 | 123 | markAsRead(e) { 124 | e.preventDefault() 125 | this.threadStore.markAsRead() 126 | this.forceUpdate() 127 | }, 128 | 129 | getButtonLabel() { 130 | var showCommentsAfter = this.state.showNewCommentsAfter || this.threadStore.commentCount - 1 131 | var howMany = this.threadStore.commentCount - showCommentsAfter 132 | var timeComment = this.threadStore.getCommentByTimeIndex(showCommentsAfter + 1) 133 | var text = `highlight ${howMany} comment${pluralise(howMany)} from ` 134 | return 135 | {text} 136 | {timeComment && } 137 | 138 | }, 139 | 140 | highlightRecentComments() { 141 | var showCommentsAfter = this.state.showNewCommentsAfter || this.threadStore.commentCount - 1 142 | this.threadStore.highlightNewCommentsSince(showCommentsAfter) 143 | }, 144 | 145 | render() { 146 | var state = this.state 147 | var item = state.item 148 | var threadStore = this.threadStore 149 | if (!item.id || !threadStore) { return
} 150 | return
151 |
152 | {this.renderItemTitle(item)} 153 | {this.renderItemMeta(item, (threadStore.lastVisit !== null && threadStore.newCommentCount > 0 && {' '} 154 | ({threadStore.newCommentCount} new in the last {') | '} 155 | 156 | auto collapse 157 | {' | '} 158 | 159 | mark as read 160 | 161 | ))} 162 |
1 ? 1.0 : 0.0, 165 | transition: 'opacity .33s ease-out', 166 | }}> 167 | { 174 | var showNewCommentsAfter = Number(e.target.value) 175 | this.setState({showNewCommentsAfter}) 176 | }} 177 | /> 178 | 181 |
182 | {item.text &&
183 |
184 |
} 185 | {item.type === 'poll' &&
186 | {item.parts.map(function(id) { 187 | return 188 | })} 189 |
} 190 |
191 | {item.kids &&
192 | {item.kids.map(function(id, index) { 193 | return 197 | })} 198 |
} 199 |
200 | } 201 | }) 202 | 203 | export default Item 204 | -------------------------------------------------------------------------------- /src/NotFound.js: -------------------------------------------------------------------------------- 1 | var React = require('react') 2 | 3 | var NotFound = React.createClass({ 4 | render() { 5 | return

Not found

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

Show items flagged as dead.

46 |
47 |
48 | 51 |

Show comments flagged as deleted in threads.

52 |
53 |
54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 |
66 |
67 |
68 |
69 | } 70 | }) 71 | 72 | export default Settings 73 | -------------------------------------------------------------------------------- /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 | export default Spinner 22 | -------------------------------------------------------------------------------- /src/Stories.js: -------------------------------------------------------------------------------- 1 | var React = require('react') 2 | 3 | var StoryStore = require('./stores/StoryStore').default 4 | 5 | var PageNumberMixin = require('./mixins/PageNumberMixin').default 6 | var Paginator = require('./Paginator').default 7 | var Spinner = require('./Spinner').default 8 | var StoryListItem = require('./StoryListItem').default 9 | var SettingsStore = require('./stores/SettingsStore').default 10 | 11 | var {ITEMS_PER_PAGE} = require('./utils/constants').default 12 | var pageCalc = require('./utils/pageCalc').default 13 | var setTitle = require('./utils/setTitle').default 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: null, 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 | // Special case for the Read Stories page, as its ids are read from 69 | // localStorage and there might not be any yet. 70 | if (this.props.type === 'read') { 71 | if (this.state.ids == null) { 72 | return
73 | } 74 | if (this.state.ids.length === 0) { 75 | return
76 |

There are no previously read stories to display.

77 |
78 | } 79 | } 80 | 81 | // Display a list of placeholder items while we're waiting for the initial 82 | // list of story ids to load from Firebase. 83 | if (this.state.ids == null) { 84 | var dummyItems = [] 85 | for (var i = page.startIndex; i < page.endIndex; i++) { 86 | dummyItems.push( 87 |
  • 88 | 89 |
  • 90 | ) 91 | } 92 | return
    93 |
      {dummyItems}
    94 | 95 |
    96 | } 97 | 98 | return
    99 |
      100 | {this.renderItems(page.startIndex, page.endIndex)} 101 |
    102 | 103 |
    104 | }, 105 | 106 | renderItems(startIndex, endIndex) { 107 | var rendered = [] 108 | for (var i = startIndex; i < endIndex; i++) { 109 | var item = this.state.stories[i] 110 | var id = this.state.ids[i] 111 | if (id) { 112 | rendered.push() 113 | } 114 | else { 115 | rendered.push() 116 | } 117 | } 118 | return rendered 119 | } 120 | }) 121 | 122 | export default Stories 123 | -------------------------------------------------------------------------------- /src/StoryListItem.js: -------------------------------------------------------------------------------- 1 | var React = require('react') 2 | var ReactFireMixin = require('reactfire') 3 | 4 | var StoryCommentThreadStore = require('./stores/StoryCommentThreadStore').default 5 | var HNService = require('./services/HNService').default 6 | var SettingsStore = require('./stores/SettingsStore').default 7 | var StoryStore = require('./stores/StoryStore').default 8 | 9 | var ItemMixin = require('./mixins/ItemMixin').default 10 | var ListItemMixin = require('./mixins/ListItemMixin').default 11 | var Spinner = require('./Spinner').default 12 | 13 | /** 14 | * Display story title and metadata as as a list item. 15 | * Cached story data may be given as a prop, but this component is also 16 | * responsible for listening to updates to the story and providing the latest 17 | * version for StoryStore's cache. 18 | */ 19 | var StoryListItem = React.createClass({ 20 | mixins: [ItemMixin, ListItemMixin, ReactFireMixin], 21 | 22 | propTypes: { 23 | // The StoryStore handling caching and updates to the stories being displayed 24 | store: React.PropTypes.instanceOf(StoryStore).isRequired, 25 | 26 | // The story's id in Hacker News 27 | id: React.PropTypes.number, 28 | // A version of the story from the cache, for initial display 29 | cachedItem: React.PropTypes.object, 30 | // The current index of the story in the list being displayed 31 | index: React.PropTypes.number 32 | }, 33 | 34 | getDefaultProps() { 35 | return { 36 | id: null, 37 | cachedItem: null, 38 | index: null 39 | } 40 | }, 41 | 42 | getInitialState() { 43 | return { 44 | item: this.props.cachedItem || {} 45 | } 46 | }, 47 | 48 | componentWillMount() { 49 | if (this.props.id != null) { 50 | this.initLiveItem(this.props) 51 | } 52 | else if (this.props.cachedItem != null) { 53 | // Display the comment state of the cached item we were given while we're 54 | // waiting for the live item to load. 55 | this.threadState = StoryCommentThreadStore.loadState(this.state.item.id) 56 | } 57 | }, 58 | 59 | componentWillUnmount() { 60 | if (this.props.id != null) { 61 | this.props.store.removeListener(this.props.id, this.updateThreadState) 62 | } 63 | }, 64 | 65 | /** 66 | * Catch the transition from not having an id prop to having one. 67 | * Scenario: we were waiting for the initial list of story ids to load. 68 | */ 69 | componentWillReceiveProps(nextProps) { 70 | if (this.props.id == null && nextProps.id != null) { 71 | this.initLiveItem(nextProps) 72 | } 73 | }, 74 | 75 | /** 76 | * If the live item has been loaded or updated, update the StoryStore cache 77 | * with its current index and latest data. 78 | */ 79 | componentWillUpdate(nextProps, nextState) { 80 | if (this.state.item !== nextState.item) { 81 | if (nextState.item != null) { 82 | this.props.store.itemUpdated(nextState.item, this.props.index) 83 | } 84 | else { 85 | if (process.env.NODE_ENV !== 'production') { 86 | console.warn(`Item ${this.props.id} went from ${JSON.stringify(this.state.item)} to ${nextProps.item}`) 87 | } 88 | } 89 | } 90 | }, 91 | 92 | /** 93 | * Initialise listening to updates for the item with the given id and 94 | * initialise its comment thread state. 95 | */ 96 | initLiveItem(props) { 97 | // If we were given a cached item to display initially, it will be replaced 98 | this.bindAsObject(HNService.itemRef(props.id), 'item') 99 | 100 | this.threadState = StoryCommentThreadStore.loadState(props.id) 101 | this.props.store.addListener(props.id, this.updateThreadState) 102 | }, 103 | 104 | /** 105 | * Update thread state in response to a storage event indicating it has been 106 | * modified. 107 | */ 108 | updateThreadState() { 109 | this.threadState = StoryCommentThreadStore.loadState(this.props.id) 110 | this.forceUpdate() 111 | }, 112 | 113 | render() { 114 | // Display the loading spinner if we have nothing to show initially 115 | if (!this.state.item || !this.state.item.id) { 116 | return
  • 117 | 118 |
  • 119 | } 120 | 121 | return this.renderListItem(this.state.item, this.threadState) 122 | } 123 | }) 124 | 125 | export default StoryListItem 126 | -------------------------------------------------------------------------------- /src/Updates.js: -------------------------------------------------------------------------------- 1 | var React = require('react') 2 | 3 | var SettingsStore = require('./stores/SettingsStore').default 4 | var UpdatesStore = require('./stores/UpdatesStore').default 5 | 6 | var DisplayListItem = require('./DisplayListItem').default 7 | var DisplayComment = require('./DisplayComment').default 8 | var Paginator = require('./Paginator').default 9 | var Spinner = require('./Spinner').default 10 | 11 | var PageNumberMixin = require('./mixins/PageNumberMixin').default 12 | 13 | var {ITEMS_PER_PAGE} = require('./utils/constants').default 14 | var pageCalc = require('./utils/pageCalc').default 15 | var setTitle = require('./utils/setTitle').default 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 | export default 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').default 6 | 7 | var Spinner = require('./Spinner').default 8 | 9 | var setTitle = require('./utils/setTitle').default 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 | export default UserProfile 64 | -------------------------------------------------------------------------------- /src/index.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 | 41 | 42 | 43 |
    44 | 45 | 46 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import {hashHistory} from 'react-router' 2 | 3 | require('./style.css') 4 | 5 | require('setimmediate') 6 | 7 | var React = require('react') 8 | var {render} = require('react-dom') 9 | var Router = require('react-router/lib/Router') 10 | var useScroll = require('react-router-scroll/lib/useScroll') 11 | var applyRouterMiddleware = require('react-router/lib/applyRouterMiddleware') 12 | 13 | var routes = require('./routes').default 14 | 15 | render( 16 | , 21 | document.getElementById('app') 22 | ) 23 | -------------------------------------------------------------------------------- /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').default 6 | var SettingsStore = require('../stores/SettingsStore').default 7 | 8 | var Spinner = require('../Spinner').default 9 | 10 | var pluralise = require('../utils/pluralise').default 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 | export default 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').default 6 | var pluralise = require('../utils/pluralise').default 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 | export default 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').default 5 | var cx = require('../utils/buildClassName').default 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 | export default 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 | export default 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').default 5 | // Polyfill require.ensure 6 | if (typeof require.ensure !== 'function') require.ensure = function(d, c) { c(require) } 7 | 8 | var App = require('./App').default 9 | var Stories = require('./Stories').default 10 | var Updates = require('./Updates').default 11 | var PermalinkedComment = require('./PermalinkedComment').default 12 | var UserProfile = require('./UserProfile').default 13 | var NotFound = require('./NotFound').default 14 | 15 | function stories(route, type, limit, title) { 16 | return React.createClass({ 17 | render() { 18 | return 19 | } 20 | }) 21 | } 22 | 23 | function updates(type) { 24 | return React.createClass({ 25 | render() { 26 | return 27 | } 28 | }) 29 | } 30 | 31 | var Ask = stories('ask', 'askstories', 200, 'Ask') 32 | var Comments = updates('comments') 33 | var Jobs = stories('jobs', 'jobstories', 200, 'Jobs') 34 | var New = stories('newest', 'newstories', 500, 'New Links') 35 | var Show = stories('show', 'showstories', 200, 'Show') 36 | var Top = stories('news', 'topstories', 500) 37 | var Read = stories('read', 'read', 0, 'Read Stories') 38 | 39 | export default 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /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 | export default { 49 | fetchItem, 50 | fetchItems, 51 | storiesRef, 52 | itemRef, 53 | userRef, 54 | updatesRef 55 | } 56 | -------------------------------------------------------------------------------- /src/stores/CommentThreadStore.js: -------------------------------------------------------------------------------- 1 | var extend = require('../utils/extend').default 2 | 3 | function CommentThreadStore(item, onCommentsChanged) { 4 | this.itemId = item.id 5 | this.onCommentsChanged = onCommentsChanged 6 | 7 | /** 8 | * Lookup from a comment id to the comment. 9 | * @type {Object.} 10 | */ 11 | this.comments = {} 12 | 13 | /** 14 | * Lookup from a comment id to its child comment ids. 15 | * @type {Object.>} 16 | */ 17 | this.children = {} 18 | this.children[item.id] = [] 19 | 20 | /** 21 | * Lookup for new comment ids. Will only contain true. 22 | * @type {Object.} 23 | */ 24 | this.isNew = {} 25 | 26 | /** 27 | * Lookup for collapsed state of comment ids. May contain true or false. 28 | * @type {Object.} 29 | */ 30 | this.isCollapsed = {} 31 | 32 | /** 33 | * Lookup for dead comment ids 34 | * @type {Object.} 35 | */ 36 | this.deadComments = {} 37 | } 38 | 39 | extend(CommentThreadStore.prototype, { 40 | /** 41 | * Get counts of children and new comments under the given comment. 42 | * @return .children {Number} 43 | * @return .newComments {Number} 44 | */ 45 | getChildCounts(comment) { 46 | var childCount = 0 47 | var newCommentCount = 0 48 | var nodes = [comment.id] 49 | 50 | while (nodes.length) { 51 | var nextNodes = [] 52 | for (var i = 0, l = nodes.length; i < l; i++) { 53 | var nodeChildren = this.children[nodes[i]] 54 | if (nodeChildren.length) { 55 | nextNodes.push.apply(nextNodes, nodeChildren) 56 | } 57 | } 58 | for (i = 0, l = nextNodes.length; i < l; i++) { 59 | if (this.isNew[nextNodes[i]]) { 60 | newCommentCount++ 61 | } 62 | } 63 | childCount += nextNodes.length 64 | nodes = nextNodes 65 | } 66 | 67 | return { 68 | children: childCount, 69 | newComments: newCommentCount 70 | } 71 | }, 72 | 73 | /** 74 | * Register a comment's appearance in the thread. 75 | */ 76 | commentAdded(comment) { 77 | if (comment.deleted) { return } 78 | 79 | this.comments[comment.id] = comment 80 | this.children[comment.id] = [] 81 | this.children[comment.parent].push(comment.id) 82 | if (comment.dead) { 83 | this.deadComments[comment.id] = true 84 | } 85 | }, 86 | 87 | /** 88 | * Register a comment's deletion from the thread. 89 | */ 90 | commentDeleted(comment) { 91 | // Comments which initially failed to load (null from Firebase API) can be 92 | // deleted by the time the API catches up. 93 | if (!comment) { return } 94 | 95 | delete this.comments[comment.id] 96 | var siblings = this.children[comment.parent] 97 | siblings.splice(siblings.indexOf(comment.id), 1) 98 | if (comment.dead) { 99 | delete this.deadComments[comment.id] 100 | } 101 | }, 102 | 103 | toggleCollapse(commentId) { 104 | this.isCollapsed[commentId] = !this.isCollapsed[commentId] 105 | this.onCommentsChanged({type: 'collapse'}) 106 | } 107 | }) 108 | 109 | export default CommentThreadStore 110 | -------------------------------------------------------------------------------- /src/stores/ItemStore.js: -------------------------------------------------------------------------------- 1 | var HNService = require('../services/HNService').default 2 | 3 | var StoryStore = require('./StoryStore').default 4 | var UpdatesStore = require('./UpdatesStore').default 5 | var commentParentLookup = {} 6 | var titleCache = {} 7 | 8 | function fetchCommentParent(comment, cb, result) { 9 | var commentId = comment.id 10 | var parentId = comment.parent 11 | 12 | while (commentParentLookup[parentId] || titleCache[parentId]) { 13 | // We just saved ourselves an item fetch 14 | result.itemCount++ 15 | result.cacheHits++ 16 | 17 | // The parent is a known non-comment 18 | if (titleCache[parentId]) { 19 | if (result.itemCount === 1) { result.parent = titleCache[parentId] } 20 | result.op = titleCache[parentId] 21 | cb(result) 22 | return 23 | } 24 | 25 | // The parent is a known comment 26 | if (commentParentLookup[parentId]) { 27 | if (result.itemCount === 1) { result.parent = {id: parentId, type: 'comment'} } 28 | // Set the parent comment's ids up for the next iteration 29 | commentId = parentId 30 | parentId = commentParentLookup[parentId] 31 | } 32 | } 33 | 34 | // The parent of the current comment isn't known, so we'll have to fetch it 35 | ItemStore.getItem(parentId, function(parent) { 36 | result.itemCount++ 37 | // Add the current comment's parent to the lookup for next time 38 | commentParentLookup[commentId] = parentId 39 | if (parent.type === 'comment') { 40 | commentParentLookup[parent.id] = parent.parent 41 | } 42 | processCommentParent(parent, cb, result) 43 | }, result) 44 | } 45 | 46 | function processCommentParent(item, cb, result) { 47 | if (result.itemCount === 1) { 48 | result.parent = item 49 | } 50 | if (item.type !== 'comment') { 51 | result.op = item 52 | titleCache[item.id] = { 53 | id: item.id, 54 | type: item.type, 55 | title: item.title 56 | } 57 | cb(result) 58 | } 59 | else { 60 | fetchCommentParent(item, cb, result) 61 | } 62 | } 63 | 64 | var ItemStore = { 65 | getItem(id, cb, result) { 66 | var cachedItem = this.getCachedItem(id) 67 | if (cachedItem) { 68 | if (result) { 69 | result.cacheHits++ 70 | } 71 | setImmediate(cb, cachedItem) 72 | } 73 | else { 74 | HNService.fetchItem(id, cb) 75 | } 76 | }, 77 | 78 | getCachedItem(id) { 79 | return StoryStore.getItem(id) || UpdatesStore.getItem(id) || null 80 | }, 81 | 82 | getCachedStory(id) { 83 | return StoryStore.getItem(id) || UpdatesStore.getStory(id) || null 84 | }, 85 | 86 | fetchCommentAncestors(comment, cb) { 87 | var startTime = Date.now() 88 | var result = {itemCount: 0, cacheHits: 0} 89 | fetchCommentParent(comment, function() { 90 | result.timeTaken = Date.now() - startTime 91 | setImmediate(cb, result) 92 | }, result) 93 | } 94 | } 95 | 96 | export default ItemStore 97 | -------------------------------------------------------------------------------- /src/stores/SettingsStore.js: -------------------------------------------------------------------------------- 1 | var extend = require('../utils/extend').default 2 | var storage = require('../utils/storage').default 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 | 14 | load() { 15 | var json = storage.get(STORAGE_KEY) 16 | if (json) { 17 | extend(this, JSON.parse(json)) 18 | } 19 | }, 20 | 21 | save() { 22 | storage.set(STORAGE_KEY, JSON.stringify({ 23 | autoCollapse: this.autoCollapse, 24 | replyLinks: this.replyLinks, 25 | showDead: this.showDead, 26 | showDeleted: this.showDeleted, 27 | titleFontSize: this.titleFontSize, 28 | listSpacing: this.listSpacing 29 | })) 30 | } 31 | } 32 | 33 | export default SettingsStore 34 | -------------------------------------------------------------------------------- /src/stores/StoryCommentThreadStore.js: -------------------------------------------------------------------------------- 1 | var CommentThreadStore = require('./CommentThreadStore').default 2 | var SettingsStore = require('./SettingsStore').default 3 | 4 | var debounce = require('../utils/cancellableDebounce').default 5 | var extend = require('../utils/extend').default 6 | var pluralise = require('../utils/pluralise').default 7 | var storage = require('../utils/storage').default 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 | getCommentByTimeIndex(timeIndex) { 281 | var sortedCommentIds = Object.keys(this.comments).map(id => Number(id)) 282 | if (!SettingsStore.showDead) { 283 | sortedCommentIds = sortedCommentIds.filter(id => !this.deadComments[id]) 284 | } 285 | sortedCommentIds.sort() 286 | var commentId = sortedCommentIds[timeIndex - 1] 287 | return this.comments[commentId] 288 | }, 289 | 290 | highlightNewCommentsSince(showCommentsAfter) { 291 | var referenceComment = this.getCommentByTimeIndex(showCommentsAfter) 292 | 293 | // Walk the tree of comments and create a new isNew lookup for comments 294 | // newer than the reference comment we're using for highlighting. 295 | var isNew = {} 296 | var commentIds = this.children[this.itemId] 297 | while (commentIds.length) { 298 | var nextCommentIds = [] 299 | for (var i = 0, l = commentIds.length; i < l; i++) { 300 | var commentId = commentIds[i] 301 | if (commentId > referenceComment.id) { 302 | isNew[commentId] = true 303 | } 304 | var childCommentIds = this.children[commentId] 305 | if (childCommentIds.length) { 306 | nextCommentIds.push.apply(nextCommentIds, childCommentIds) 307 | } 308 | } 309 | commentIds = nextCommentIds 310 | } 311 | 312 | this.isNew = isNew 313 | this.collapseThreadsWithoutNewComments() 314 | }, 315 | 316 | /** 317 | * Merk the thread as read. 318 | */ 319 | markAsRead() { 320 | this.lastVisit = Date.now() 321 | this.newCommentCount = 0 322 | this.prevMaxCommentId = this.maxCommentId 323 | this.isNew = {} 324 | this._storeState() 325 | }, 326 | 327 | /** 328 | * Persist comment thread state and perform any necessary internal cleanup. 329 | */ 330 | dispose() { 331 | // Cancel debounced callbacks in case any are pending 332 | this.numberOfCommentsChanged.cancel() 333 | this._storeState() 334 | } 335 | }) 336 | 337 | export default StoryCommentThreadStore 338 | -------------------------------------------------------------------------------- /src/stores/StoryStore.js: -------------------------------------------------------------------------------- 1 | var {EventEmitter} = require('events') 2 | 3 | var HNService = require('../services/HNService').default 4 | 5 | var extend = require('../utils/extend').default 6 | 7 | var ID_REGEXP = /^\d+$/ 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 | idCache[this.type] = snapshot.val() 97 | populateStoryList(this.type) 98 | this.emit('update', this.getState()) 99 | } 100 | 101 | start() { 102 | if (this.type === 'read') { 103 | var readStoryIds = Object.keys(window.localStorage) 104 | .filter(key => ID_REGEXP.test(key)) 105 | .map(id => Number(id)) 106 | .sort((a, b) => b - a) 107 | setImmediate(() => this.onStoriesUpdated({val: () => readStoryIds})) 108 | } 109 | else { 110 | firebaseRef = HNService.storiesRef(this.type) 111 | firebaseRef.on('value', this.onStoriesUpdated) 112 | } 113 | window.addEventListener('storage', this.onStorage) 114 | } 115 | 116 | stop() { 117 | if (firebaseRef !== null) { 118 | firebaseRef.off() 119 | firebaseRef = null 120 | } 121 | window.removeEventListener('storage', this.onStorage) 122 | } 123 | } 124 | 125 | // Static, cache-related functions 126 | extend(StoryStore, { 127 | /** 128 | * Get an item from the cache. 129 | */ 130 | getItem(id) { 131 | return itemCache[id] || null 132 | }, 133 | 134 | /** 135 | * Deserialise caches from sessionStorage. 136 | */ 137 | loadSession() { 138 | idCache = parseJSON(window.sessionStorage.idCache, {}) 139 | itemCache = parseJSON(window.sessionStorage.itemCache, {}) 140 | }, 141 | 142 | /** 143 | * Serialise caches to sessionStorage as JSON. 144 | */ 145 | saveSession() { 146 | window.sessionStorage.idCache = JSON.stringify(idCache) 147 | window.sessionStorage.itemCache = JSON.stringify(itemCache) 148 | } 149 | }) 150 | 151 | export default StoryStore 152 | -------------------------------------------------------------------------------- /src/stores/UpdatesStore.js: -------------------------------------------------------------------------------- 1 | var EventEmitter = require('events').EventEmitter 2 | 3 | var HNService = require('../services/HNService').default 4 | 5 | var {UPDATES_CACHE_SIZE} = require('../utils/constants').default 6 | var extend = require('../utils/extend').default 7 | 8 | /** 9 | * Firebase reference used to stream updates. 10 | */ 11 | var updatesRef = null 12 | 13 | /** 14 | * Contains item id -> item cache objects. Persisted to sessionStorage. 15 | * @prop .comments {Object.} comments cache. 16 | * @prop .stories {Object.} story cache. 17 | */ 18 | var updatesCache = null 19 | 20 | /** 21 | * Lists of items in reverse chronological order for display. 22 | * @prop .comments {Array.} comment updates. 23 | * @prop .stories {Array.} story updates. 24 | */ 25 | var updates = {} 26 | 27 | function sortByTimeDesc(a, b) { 28 | return b.time - a.time 29 | } 30 | 31 | function cacheObjToSortedArray(obj) { 32 | var arr = Object.keys(obj).map(function(id) { return obj[id] }) 33 | arr.sort(sortByTimeDesc) 34 | return arr 35 | } 36 | 37 | /** 38 | * Populate lists of updates for display from the cache. 39 | */ 40 | function populateUpdates() { 41 | updates.comments = processCacheObj(updatesCache.comments) 42 | updates.stories = processCacheObj(updatesCache.stories) 43 | } 44 | 45 | /** 46 | * Create an array of items from a cache object, sorted in reverse chronological 47 | * order. Evict the oldest items from the cache if it's grown above 48 | * UPDATES_CACHE_SIZE. 49 | */ 50 | function processCacheObj(cacheObj) { 51 | var arr = cacheObjToSortedArray(cacheObj) 52 | arr.splice(UPDATES_CACHE_SIZE, Math.max(0, arr.length - UPDATES_CACHE_SIZE)) 53 | .forEach(function(item) { 54 | delete cacheObj[item.id] 55 | }) 56 | return arr 57 | } 58 | 59 | /** 60 | * Lookup to filter out any items which appear in the updates feed which can't 61 | * be displayed by the Updates component. 62 | */ 63 | var updateItemTypes = { 64 | comment: true, 65 | job: true, 66 | poll: true, 67 | story: true 68 | } 69 | 70 | /** 71 | * Process incoming items from the update stream. 72 | */ 73 | function handleUpdateItems(items) { 74 | for (var i = 0, l = items.length; i < l; i++) { 75 | var item = items[i] 76 | // Silently ignore deleted items (because irony) 77 | if (item.deleted) { continue } 78 | 79 | if (typeof updateItemTypes[item.type] == 'undefined') { 80 | if (process.env.NODE_ENV !== 'production') { 81 | console.warn( 82 | "An item which can't be displayed by the Updates component was " + 83 | 'received in the updates stream: ' + JSON.stringify(item) 84 | ) 85 | } 86 | continue 87 | } 88 | 89 | if (item.type === 'comment') { 90 | updatesCache.comments[item.id] = item 91 | } 92 | else { 93 | updatesCache.stories[item.id] = item 94 | } 95 | } 96 | populateUpdates() 97 | UpdatesStore.emit('updates', updates) 98 | } 99 | 100 | var UpdatesStore = extend(new EventEmitter(), { 101 | loadSession() { 102 | var json = window.sessionStorage.updates 103 | updatesCache = (json ? JSON.parse(json) : {comments: {}, stories: {}}) 104 | populateUpdates() 105 | }, 106 | 107 | saveSession() { 108 | window.sessionStorage.updates = JSON.stringify(updatesCache) 109 | }, 110 | 111 | start() { 112 | if (updatesRef === null) { 113 | updatesRef = HNService.updatesRef() 114 | updatesRef.on('value', function(snapshot) { 115 | HNService.fetchItems(snapshot.val(), handleUpdateItems) 116 | }) 117 | } 118 | }, 119 | 120 | stop() { 121 | updatesRef.off() 122 | updatesRef = null 123 | }, 124 | 125 | getUpdates() { 126 | return updates 127 | }, 128 | 129 | getItem(id) { 130 | return (updatesCache.comments[id] || updatesCache.stories[id] || null) 131 | }, 132 | 133 | getComment(id) { 134 | return (updatesCache.comments[id] || null) 135 | }, 136 | 137 | getStory(id) { 138 | return (updatesCache.stories[id] || null) 139 | } 140 | }) 141 | UpdatesStore.off = UpdatesStore.removeListener 142 | 143 | export default UpdatesStore 144 | -------------------------------------------------------------------------------- /src/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #fff; 3 | margin: 0; 4 | } 5 | form { 6 | margin: 0; 7 | } 8 | h2 { 9 | padding-left: 1.25em; 10 | } 11 | img { 12 | vertical-align: text-bottom; 13 | } 14 | pre { 15 | white-space: pre-wrap; 16 | } 17 | span.control { 18 | cursor: pointer; 19 | } 20 | span.control:hover { 21 | text-decoration: underline; 22 | } 23 | 24 | /* From https://github.com/tobiasahlin/SpinKit */ 25 | .Spinner { 26 | } 27 | .Spinner > div { 28 | background-color: #666; 29 | border-radius: 100%; 30 | display: inline-block; 31 | -webkit-animation: bouncedelay 1.4s infinite ease-in-out; 32 | animation: bouncedelay 1.4s infinite ease-in-out; 33 | /* Prevent first frame from flickering when animation starts */ 34 | -webkit-animation-fill-mode: both; 35 | animation-fill-mode: both; 36 | } 37 | .Spinner .bounce1 { 38 | -webkit-animation-delay: -0.32s; 39 | animation-delay: -0.32s; 40 | } 41 | .Spinner .bounce2 { 42 | -webkit-animation-delay: -0.16s; 43 | animation-delay: -0.16s; 44 | } 45 | @-webkit-keyframes bouncedelay { 46 | 0%, 80%, 100% { -webkit-transform: scale(0.0) } 47 | 40% { -webkit-transform: scale(1.0) } 48 | } 49 | @keyframes bouncedelay { 50 | 0%, 80%, 100% { 51 | transform: scale(0.0); 52 | -webkit-transform: scale(0.0); 53 | } 40% { 54 | transform: scale(1.0); 55 | -webkit-transform: scale(1.0); 56 | } 57 | } 58 | 59 | .App__wrap { 60 | width: 90%; 61 | max-width: 1280px; 62 | margin: 8px auto; 63 | color: #000; 64 | background-color: #f5f5f5; 65 | font-size: 13.3333px; 66 | font-family: Verdana, Geneva, sans-serif; 67 | } 68 | .App__header { 69 | color: #00d8ff; 70 | background-color: #222; 71 | padding: 6px; 72 | line-height: 18px; 73 | vertical-align: middle; 74 | position: relative; 75 | } 76 | .App__settings { 77 | position: absolute; 78 | top: 6px; 79 | right: 10px; 80 | cursor: pointer; 81 | } 82 | .Settings { 83 | box-sizing: border-box; 84 | padding: .75em; 85 | position: absolute; 86 | width: 36%%; 87 | background-color: inherit; 88 | right: 0; 89 | border-bottom-left-radius: 1em; 90 | border-bottom-right-radius: 1em; 91 | } 92 | .Settings__setting td:first-child { 93 | text-align: right; 94 | } 95 | .Settings p { 96 | color: #fff; 97 | } 98 | .Settings div:last-child > p:last-child { 99 | margin-bottom: 0; 100 | } 101 | .App__header img { 102 | border: 1px solid #00d8ff; 103 | margin-right: .25em; 104 | } 105 | .App__header a { 106 | color: inherit; 107 | text-decoration: none; 108 | } 109 | .App__header a.active { 110 | color: #fff; 111 | } 112 | .App__homelink { 113 | text-decoration: none; 114 | font-weight: bold; 115 | color: #00d8ff !important; 116 | margin-right: .75em; 117 | } 118 | .App__homelink.active { 119 | color: #fff !important; 120 | } 121 | .App__content { 122 | overflow-wrap: break-word; 123 | word-wrap: break-word; 124 | } 125 | .App__footer { 126 | margin-top: 1em; 127 | border-top: 1px solid #e7e7e7; 128 | text-align: center; 129 | color: #333; 130 | padding: 6px 0; 131 | } 132 | .App__footer a { 133 | color: inherit; 134 | text-decoration: underline; 135 | } 136 | 137 | .Items > p { 138 | padding-left: 1.25em; 139 | } 140 | .Items__list { 141 | padding-left: 3em; 142 | padding-right: 1.25em; 143 | margin-top: 1em; 144 | margin-bottom: .5em; 145 | } 146 | .ListItem { 147 | margin-bottom: 16px; 148 | } 149 | .ListItem--loading { 150 | min-height: 34px; 151 | } 152 | 153 | .Updates--loading { 154 | padding: 1em 1.25em 0 1.25em; 155 | margin-bottom: 1em; 156 | } 157 | .Updates__notice { 158 | padding: 0 1.25em; 159 | } 160 | .Updates .Comment { 161 | margin-bottom: .75em; 162 | } 163 | 164 | .Paginator { 165 | margin-left: 3em; 166 | padding: .5em 0; 167 | } 168 | .Paginator a { 169 | font-weight: bold; 170 | color: #000; 171 | text-decoration: none; 172 | } 173 | .Paginator a:hover { 174 | text-decoration: underline; 175 | } 176 | 177 | .Item__content, 178 | .Item--loading { 179 | padding: 1em 1.25em 0 1.25em; 180 | margin-bottom: 1em; 181 | } 182 | .Item__title { 183 | color: #666; 184 | font-size:18px; 185 | } 186 | .Item__title a { 187 | text-decoration: none; 188 | color: #000; 189 | } 190 | .Item__title a:hover { 191 | text-decoration: underline; 192 | } 193 | .Item__title a:visited { 194 | color: #666; 195 | } 196 | .Item__meta { 197 | color: #666; 198 | } 199 | .Item > .Item__meta { 200 | margin-bottom: 1em; 201 | } 202 | .Item__meta a { 203 | text-decoration: none; 204 | color: inherit; 205 | } 206 | .Item__meta a:hover { 207 | text-decoration: underline; 208 | } 209 | .Item__meta em { 210 | font-style: normal; 211 | background-color: #ffffde; 212 | color: #000; 213 | } 214 | .Item__by a, .ListItem__newcomments a { 215 | font-weight: bold; 216 | } 217 | .Item__text { 218 | margin-top: 1em; 219 | } 220 | .Item__poll { 221 | margin-top: 1em; 222 | padding-left: 2.5em; 223 | } 224 | 225 | .PollOption { 226 | margin-bottom: 10px; 227 | } 228 | .PollOption__score { 229 | color: #666; 230 | } 231 | 232 | .PermalinkedComment > .Comment__content { 233 | margin-bottom: 1em; 234 | } 235 | .Comment { 236 | } 237 | .Comment--new > .Comment__content { 238 | background-color: #ffffde; 239 | } 240 | /* Highlights a comment and its descendants on hover -- too distracting? 241 | .Comment:hover > .Comment__content { 242 | background-color: #fff; 243 | } 244 | */ 245 | .Comment__meta { 246 | color: #666; 247 | margin-bottom: .5em 248 | } 249 | .Comment__meta a { 250 | text-decoration: none; 251 | color: inherit; 252 | } 253 | .Comment__meta a:hover { 254 | text-decoration: underline; 255 | } 256 | .Comment__meta em { 257 | font-style: normal; 258 | background-color: #ffffde; 259 | color: #000; 260 | } 261 | .Comment__user { 262 | font-weight: bold; 263 | } 264 | .Comment__content, .Comment--loading { 265 | padding-right: 1.25em; 266 | padding-top: .65em; 267 | padding-bottom: .65em; 268 | } 269 | .Comment--level0 .Comment__content, .Comment--level0.Comment--loading { padding-left: 1.25em; } 270 | .Comment--level1 .Comment__content, .Comment--level1.Comment--loading { padding-left: 3.75em; } 271 | .Comment--level2 .Comment__content, .Comment--level2.Comment--loading { padding-left: 6.25em; } 272 | .Comment--level3 .Comment__content, .Comment--level3.Comment--loading { padding-left: 8.75em; } 273 | .Comment--level4 .Comment__content, .Comment--level4.Comment--loading { padding-left: 11.25em; } 274 | .Comment--level5 .Comment__content, .Comment--level5.Comment--loading { padding-left: 13.75em; } 275 | .Comment--level6 .Comment__content, .Comment--level6.Comment--loading { padding-left: 16.25em; } 276 | .Comment--level7 .Comment__content, .Comment--level7.Comment--loading { padding-left: 18.75em; } 277 | .Comment--level8 .Comment__content, .Comment--level8.Comment--loading { padding-left: 21.25em; } 278 | .Comment--level9 .Comment__content, .Comment--level9.Comment--loading { padding-left: 23.75em; } 279 | .Comment--level10 .Comment__content, .Comment--level10.Comment--loading { padding-left: 26.25em; } 280 | .Comment--level11 .Comment__content, .Comment--level11.Comment--loading { padding-left: 28.75em; } 281 | .Comment--level12 .Comment__content, .Comment--level12.Comment--loading { padding-left: 31.25em; } 282 | .Comment--level13 .Comment__content, .Comment--level13.Comment--loading { padding-left: 33.75em; } 283 | .Comment--level14 .Comment__content, .Comment--level14.Comment--loading { padding-left: 36.25em; } 284 | .Comment--level15 .Comment__content, .Comment--level15.Comment--loading { padding-left: 38.75em; } 285 | .Comment__kids { 286 | } 287 | .Comment__collapse { 288 | cursor: pointer; 289 | } 290 | .Comment--collapsed .Comment__text, 291 | .Comment--collapsed > .Comment__kids { 292 | display: none; 293 | } 294 | .Comment__text a { 295 | color: #000; 296 | } 297 | .Comment__text a:visited { 298 | color: #666; 299 | } 300 | .Comment__text p:last-child, .Comment__text pre:last-child { 301 | margin-bottom: 0; 302 | } 303 | .Comment--dead > .Comment__content > .Comment__text { 304 | color: #ddd !important; 305 | } 306 | .Comment--error .Comment__meta { 307 | color: #f33; 308 | } 309 | 310 | .UserProfile { 311 | padding-left: 1.25em; 312 | padding-top: 1em; 313 | } 314 | .UserProfile h4 { 315 | margin: 0 0 1em 0; 316 | } 317 | 318 | @media only screen and (max-width: 750px) and (min-width: 300px) { 319 | .App__wrap { 320 | width: 100%; 321 | margin: 0px auto; 322 | } 323 | 324 | /* Hide the App title homelink on narrow viewports */ 325 | .App__homelink { 326 | display: none; 327 | } 328 | 329 | /* Safari only fix to ensure Settings menu is full width */ 330 | _::-webkit-:not(:root:root), .Settings { 331 | width: 100%; 332 | } 333 | } 334 | 335 | /* Hide the Settings link on iPhone 5 */ 336 | @media (device-height: 568px) and (device-width: 320px) and (-webkit-min-device-pixel-ratio: 2) { 337 | .App__settings { 338 | display: none; 339 | } 340 | } 341 | -------------------------------------------------------------------------------- /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 | export default 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 | export default cancellableDebounce 59 | -------------------------------------------------------------------------------- /src/utils/constants.js: -------------------------------------------------------------------------------- 1 | export default { 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 | export default 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 | export default pageCalc 9 | -------------------------------------------------------------------------------- /src/utils/pluralise.js: -------------------------------------------------------------------------------- 1 | function pluralise(howMany, suffixes) { 2 | return (suffixes || ',s').split(',')[(howMany === 1 ? 0 : 1)] 3 | } 4 | 5 | export default pluralise 6 | -------------------------------------------------------------------------------- /src/utils/setTitle.js: -------------------------------------------------------------------------------- 1 | var {SITE_TITLE} = require('./constants').default 2 | 3 | function setTitle(title) { 4 | if (typeof document === 'undefined') return 5 | document.title = (title ? title + ' | ' + SITE_TITLE : SITE_TITLE) 6 | } 7 | 8 | export default setTitle 9 | -------------------------------------------------------------------------------- /src/utils/storage.js: -------------------------------------------------------------------------------- 1 | export default { 2 | get(key, defaultValue) { 3 | var value = window.localStorage[key] 4 | return (typeof value != 'undefined' ? value : defaultValue) 5 | }, 6 | set(key, value) { 7 | window.localStorage[key] = value 8 | } 9 | } 10 | --------------------------------------------------------------------------------