├── .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 | [](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 |
60 |
61 | {this.props.children}
62 |
63 |
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 |
179 | {this.getButtonLabel()}
180 |
181 |
182 | {item.text &&
}
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
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
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
56 | },
57 |
58 | renderError(comment, options) {
59 | return
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 |
--------------------------------------------------------------------------------